From 8d8f9e65f6d3b2eecd9cbe997ba6b334d483f337 Mon Sep 17 00:00:00 2001 From: Ryder Sun Date: Thu, 15 Oct 2026 19:04:42 +0800 Subject: [PATCH] fix: let discussion peers see workspace file paths instead of [path] Peer transcripts in natural discussions replaced every token that starts with "/" by [path]. Agents working on the same repository could not tell each other which file they changed, and API routes such as /api/users or commands such as /login were erased too. Natural discussion transcripts now show files inside the shared workspace as workspace-relative paths, keep non-filesystem tokens, and still redact other absolute local paths (/Users, /tmp, ...) and secrets. Phased-V4 delivery text keeps the previous redaction because its hashes take part in resume checks. Checks: collaboration-records (new peer path test), natural discussion (new end-to-end relative path test), natural transcript and delivery suites. Full desktop suite: 1537 pass. --- .../collaboration/collaboration-records.cjs | 32 +++++++++++++++++++ .../workspace/local-workspace-auto-runner.cjs | 14 ++++++-- .../collaboration-records.test.cjs | 12 +++++++ ...l-workspace-v4-natural-discussion.test.cjs | 23 +++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/desktop/src/collaboration/collaboration-records.cjs b/desktop/src/collaboration/collaboration-records.cjs index f490ce2..c07470e 100644 --- a/desktop/src/collaboration/collaboration-records.cjs +++ b/desktop/src/collaboration/collaboration-records.cjs @@ -533,6 +533,37 @@ function publicCollaborationText(value, limit = MAX_TEXT_CHARS) { .slice(0, limit) } +const PRIVATE_FILESYSTEM_ROOT = /^\/(?:Users|home|private|var|tmp|etc|opt|Volumes|root|usr|System|Library|Applications|mnt|srv)(?:\/|$)/ + +function peerPathText(candidate, workspaceRoot) { + const trailing = candidate.match(/[.,;:!?]+$/)?.[0] || '' + const clean = trailing ? candidate.slice(0, -trailing.length) : candidate + if (workspaceRoot && (clean === workspaceRoot || clean.startsWith(`${workspaceRoot}/`))) { + return `${clean === workspaceRoot ? '.' : clean.slice(workspaceRoot.length + 1)}${trailing}` + } + if (PRIVATE_FILESYSTEM_ROOT.test(clean)) return `[path]${trailing}` + // Routes and commands such as /api/users or /login are not local files. + return candidate +} + +// Peer-visible text for natural discussion transcripts. Unlike +// publicCollaborationText, files inside the shared workspace stay readable as +// workspace-relative paths and non-filesystem tokens (API routes, slash +// commands) are kept; other absolute local paths and secrets stay redacted. +function peerCollaborationText(value, options = {}) { + const workspaceRoot = typeof options.workspaceRoot === 'string' + ? options.workspaceRoot.replace(/\/+$/, '') + : '' + return redactSecrets(String(value || '')) + .replace(/\bfile:\/\/(\/[^\s"'`<>]+)/giu, (_match, filePath) => peerPathText(filePath, workspaceRoot)) + .replace(/(^|[\s("'`])(\/(?!\/)[^\s"'`<>)]*)/gmu, (_match, lead, candidate) => ( + `${lead}${peerPathText(candidate, workspaceRoot)}` + )) + .replace(/\b[A-Za-z]:\\(?:[^\s"'`<>]+\\)*[^\s"'`<>]*/gu, '[path]') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '') + .trim() +} + function collaborationPackageText(handoff, entries) { const lines = [ 'MELDWORK_COLLABORATION_PACKAGE_V1', @@ -557,6 +588,7 @@ function collaborationPackageText(handoff, entries) { } module.exports = { + peerCollaborationText, BLACKBOARD_ENTRY_VERSION, COLLABORATION_STATE_VERSION, HANDOFF_VERSION, diff --git a/desktop/src/workspace/local-workspace-auto-runner.cjs b/desktop/src/workspace/local-workspace-auto-runner.cjs index cd738ae..60c9043 100644 --- a/desktop/src/workspace/local-workspace-auto-runner.cjs +++ b/desktop/src/workspace/local-workspace-auto-runner.cjs @@ -1,4 +1,5 @@ const { createHash } = require('node:crypto') +const path = require('node:path') const { MAX_MESSAGE_TEXT_CHARS } = require('./local-workspace-contracts.cjs') const { @@ -21,6 +22,7 @@ const { collaborationPackageText, createBlackboardEntryRecord, emptyCollaborationState, + peerCollaborationText, publicCollaborationText, roleForIndex, visibleBlackboardEntries, @@ -1711,11 +1713,13 @@ class LocalWorkspaceAutoRunner { } } - v4SanitizeDeliveryText(value, limit = 6000) { + v4SanitizeDeliveryText(value, limit = 6000, options = {}) { const privateWrapper = '(?:private[ _-]?(?:reasoning|thoughts?)|analysis|chain[ _-]?of[ _-]?thought|cot)' const privateHeading = '(?:private[ _-]?(?:reasoning|thoughts?)|chain[ _-]?of[ _-]?thought|cot)' const stripped = this.v4StripMeldworkControls( - publicCollaborationText(value, Number.MAX_SAFE_INTEGER), + options.workspaceRoot + ? peerCollaborationText(value, { workspaceRoot: options.workspaceRoot }) + : publicCollaborationText(value, Number.MAX_SAFE_INTEGER), ) const maximum = Number.isSafeInteger(limit) && limit >= 0 ? limit : 6000 let privateSectionLevel = null @@ -2530,7 +2534,11 @@ class LocalWorkspaceAutoRunner { phase: message.trace?.phase || 'proposal', operationId: message.trace?.context?.operationId || '', })).map(entry => ({ - ...entry, text: this.v4SanitizeDeliveryText(entry.text, Number.MAX_SAFE_INTEGER), + // Peers collaborating on the same workspace must be able to name its + // files; keep workspace paths readable as relative paths. + ...entry, text: this.v4SanitizeDeliveryText(entry.text, Number.MAX_SAFE_INTEGER, { + workspaceRoot: group.workdir ? path.resolve(group.workdir) : '', + }), })) return entries.map(entry => ({ ...entry, hash: hashValue(entry) })) } diff --git a/desktop/test/collaboration/collaboration-records.test.cjs b/desktop/test/collaboration/collaboration-records.test.cjs index 021b539..fa80c8f 100644 --- a/desktop/test/collaboration/collaboration-records.test.cjs +++ b/desktop/test/collaboration/collaboration-records.test.cjs @@ -138,3 +138,15 @@ test('conflicting claims append a conflict and remain selectively visible', () = }) assert.deepEqual(parseCollaborationState(state), state) }) + +test('peer transcripts keep workspace files readable and other local paths private', () => { + const { peerCollaborationText } = require('../../src/collaboration/collaboration-records.cjs') + const text = peerCollaborationText( + 'Edited /work/proj/src/app.ts (see file:///work/proj/docs/a.md). /api/users still fails; ' + + 'logs are in /Users/me/private.log and /tmp/run.log; run /login.', + { workspaceRoot: '/work/proj' }, + ) + assert.equal(text, + 'Edited src/app.ts (see docs/a.md). /api/users still fails; ' + + 'logs are in [path] and [path]; run /login.') +}) diff --git a/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs b/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs index 1ae32f9..5c03175 100644 --- a/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs +++ b/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs @@ -161,6 +161,29 @@ test('Natural discussion accepts a fenced, loosely written owner receipt instead assert.equal(ledger.get(controller.runId).agentRuns.at(-1).context.taskDecision.status, 'completed') }) +test('Natural discussion shows peers workspace files as relative paths', async t => { + const { directory, options } = fixture() + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const prompts = [] + const workspace = new LocalWorkspace({ + ...options, naturalAgentResponses: true, + runAgent: async (agent, prompt) => { + prompts.push({ kind: agent.kind, phase: naturalPhase(prompt), prompt }) + if (naturalPhase(prompt) === 'proposal') { + return { outcome: 'completed', text: `${agent.kind} would change ${path.join(directory, 'src', 'app.ts')} and the /api/users route.` } + } + return { outcome: 'completed', text: decisionReply(`${agent.kind} reviewed the change.`) } + }, + }) + await workspace.refreshAgents() + const group = workspace.createGroup({ name: 'Paths', agentKinds: ['codex', 'hermes'], workdir: directory, allowWrite: false }) + await runDiscussion(workspace, group, { discussionStyle: 'agent-led', maxRounds: 3 }) + const discussion = prompts.find(item => item.phase === 'discussion' && item.kind === 'hermes') + assert.ok(discussion) + assert.match(discussion.prompt, /codex would change src\/app\.ts and the \/api\/users route\./) + assert.doesNotMatch(discussion.prompt, /would change \[path\]/) +}) + test('Natural discussion hands a peer blocker to the delivery owner instead of ending', async t => { const { directory, options } = fixture() t.after(() => fs.rmSync(directory, { recursive: true, force: true }))