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.
This commit is contained in:
Ryder Sun committed 2026-10-15 19:04:42 +08:00
1 parent b2249f0859
commit 8d8f9e65f6
4 files changed
+78 -3

No files matched your search

@@ -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,
@@ -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) }))
}
@@ -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.')
})
@@ -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 }))