[Bug]: ACP sends additional directories without negotiated support #35823
Send to my agentSummary#
An ACP session can fail to open when extra workspace directories are configured and the agent does not advertise support for them. T3 sends those directories even when the capability is missing or explicitly null. A deterministic fake-agent reproduction establishes the failure for both supported protocol generations; unsupported fields should stay out of session requests.
Before submitting#
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area#
apps/server
Steps to reproduce#
- Use a checkout of the commit stated below with its lockfile dependencies installed.
- Save the script as
repro-roots.mjsoutside the checkout and runnode repro-roots.mjs /path/to/checkout. - Inspect the printed capability cases and the final result.
The script reproduced the failure deterministically in one complete run. It calls the production ACP runtime service with a local fake JSON-RPC subprocess, creates all peer inputs and disposable HOME, TMPDIR, and T3 state under mkdtemp, and removes them on exit. It leaves the checkout unchanged and never runs a real agent or MCP server. Prerequisites are the checkout's Node runtime, Git, and already installed lockfile dependencies. It prints the versions it uses; exit 0 means the expected capability contract holds, 1 means a forbidden field reached the wire, and 2 means setup or a positive control failed.
Reproduction script: repro-roots.mjs
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir, platform, arch } from 'node:os';
import { resolve, join } from 'node:path';
import { spawnSync } from 'node:child_process';
const checkout = resolve(process.argv[2] || '.');
const scratch = mkdtempSync(join(tmpdir(), 'acp-capability-repro-'));
let status = 2;
try {
console.log(`Node ${process.version}; ${platform()} ${arch()}`);
const git = spawnSync('git', ['--version'], { encoding: 'utf8' });
const revision = spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
if (git.status !== 0 || revision.status !== 0) throw new Error('git setup failed');
console.log(git.stdout.trim()); console.log(`Revision ${revision.stdout.trim()}`);
console.log(`Effect ${JSON.parse(readFileSync(join(checkout, 'packages/provider-acp/node_modules/effect/package.json'), 'utf8')).version}`);
for (const dir of ['home', 'tmp', 'state', 'extra']) mkdirSync(join(scratch, dir));
symlinkSync(join(checkout, 'packages/provider-acp/node_modules'), join(scratch, 'node_modules'));
writeFileSync(join(scratch, 'peer.mjs'), `
import readline from 'node:readline';
import { appendFileSync } from 'node:fs';
const [version,capability,feature] = process.argv.slice(2);
for await (const line of readline.createInterface({input:process.stdin})) {
const r=JSON.parse(line); appendFileSync(process.env.WIRE_LOG,JSON.stringify(r)+'\\n');
let result, error;
if(r.method==='initialize') {
const caps=capability==='supported'?{additionalDirectories:{}}:capability==='null'?{additionalDirectories:null}:{};
result=version==='1'?{protocolVersion:1,agentInfo:{name:'fake',version:'1'},agentCapabilities:{sessionCapabilities:caps}}:{protocolVersion:2,info:{name:'fake',version:'1'},capabilities:{session:caps}};
} else if(r.method==='session/new') {
if(r.params.additionalDirectories?.length && capability!=='supported') error={code:-32602,message:'additionalDirectories not advertised'};
else result={sessionId:'test-session'};
} else result={};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,...(error?{error}:{result})})+'\\n');
}
`);
writeFileSync(join(scratch, 'harness.mjs'), `
import * as Effect from 'effect/Effect';
import * as Exit from 'effect/Exit';
import * as SecureRandom from 'effect/crypto/SecureRandom';
import * as NodeServices from '@effect/platform-node/NodeServices';
import {readFileSync} from 'node:fs';
import {pathToFileURL} from 'node:url';
const Runtime=await import(pathToFileURL(process.argv[2]+'/packages/provider-acp/src/server/AcpSessionRuntime.ts'));
let bad=0, controls=0;
await Effect.runPromise(Effect.gen(function*(){
for(const version of ['1','2']) for(const [capability,roots] of [['omitted',false],['supported',true],['omitted',true],['null',true]]) {
const name='v'+version+'-'+capability+'-roots-'+roots;
const log=import.meta.dirname+'/'+name+'.jsonl';
const outcome=yield* Effect.scoped(Effect.gen(function*(){
const runtime=yield* Runtime.make({spawn:{command:process.execPath,args:[import.meta.dirname+'/peer.mjs',version,capability],cwd:import.meta.dirname,env:{...process.env,WIRE_LOG:log}},cwd:import.meta.dirname,clientInfo:{name:'repro',version:'1'},mcpServers:[],additionalDirectories:roots?[import.meta.dirname+'/extra']:[]});
return yield* runtime.start();
})).pipe(Effect.timeout('5 seconds'),Effect.exit);
const wire=readFileSync(log,'utf8').trim().split('\\n').map(JSON.parse);
const setup=wire.find(r=>r.method==='session/new');
const sent=Object.hasOwn(setup?.params??{},'additionalDirectories');
console.log(name+': methods='+wire.map(r=>r.method).join(',')+' sentAdditionalDirectories='+sent+' start='+ (Exit.isSuccess(outcome)?'success':'failure'));
if(capability==='supported'||!roots) {
if(!Exit.isSuccess(outcome)||sent!==roots||wire[0]?.method!=='initialize') controls++;
} else if(sent) bad++;
}
}).pipe(Effect.provide(NodeServices.layer),Effect.provide(SecureRandom.layerWebCrypto)));
process.exitCode=controls?2:bad?1:0;
`);
const run = spawnSync(process.execPath, [join(scratch, 'harness.mjs'), checkout], { encoding:'utf8', timeout:60000, env:{...process.env,HOME:join(scratch,'home'),TMPDIR:join(scratch,'tmp'),T3CODE_HOME:join(scratch,'state'),T3_HOME:join(scratch,'state')} });
process.stdout.write(run.stdout || '');
if (run.stderr) console.log('Harness diagnostic: '+run.stderr.replaceAll(scratch,'<scratch>').replaceAll(checkout,'<checkout>'));
status = [0,1,2].includes(run.status) ? run.status : 2;
} catch(error) { console.log('Setup: '+error.message.replaceAll(scratch,'<scratch>').replaceAll(checkout,'<checkout>')); }
finally { rmSync(scratch,{recursive:true,force:true}); }
console.log(status===0?'RESULT: expected behavior':status===1?'RESULT: defect reproduced':'RESULT: inconclusive');
process.exitCode=status;
Expected behavior#
The pinned v1 specification says clients MUST only send additionalDirectories when the agent advertises sessionCapabilities.additionalDirectories; the pinned v2 specification imposes the same requirement for session.additionalDirectories. See v1 session setup, pinned schema-v1.21.0 and v2 session setup, pinned schema-v2.0.0-alpha.3. A session request must not carry the field when the capability is absent or null; a local refusal explaining unavailable extra roots would also preserve this contract.
Actual behavior#
Calling AcpSessionRuntime.make with a non-empty additionalDirectories option, then start(), sends that field in session/new after an initialize response that omits the capability or sets it to null. The fake peer rejects the request with -32602 and session startup fails in all four negative cases. The same configured directories work when advertised, and startup without extra directories works when the capability is absent.
Impact#
Minor bug or occasional failure
Impact: P2 - A configured optional field defeats capability negotiation and can prevent session creation with an agent that refuses the unsupported field. The fake-agent run establishes the failed session-start boundary, not the prevalence of rejecting real agents.
The verified control without the optional input opens a session. That is an available bypass only for callers that can omit that input and do not require its functionality. NOT_ENOUGH_INFO: affected installed agents and their frequency require real agent/version-specific evidence; no real provider or account was used.
Version or commit#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; packages/effect-acp/scripts/generate.ts pins schema-v1.21.0 and schema-v2.0.0-alpha.3.
Environment#
Node v26.12.1, macOS (darwin) arm64, Git 2.56.0, Effect 4.1.3. The script prints the same runtime and dependency identities in its captured output.
Logs or stack traces#
Observed output from the exact reproduction script above (no local paths or identity fields are included):
Node v26.12.1; darwin arm64
git version 2.56.0
Revision 59e64eef881632a23f89c4138bcf37f23d92bf93
Effect 4.1.3
v1-omitted-roots-false: methods=initialize,session/new sentAdditionalDirectories=false start=success
v1-supported-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=success
v1-omitted-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=failure
v1-null-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=failure
v2-omitted-roots-false: methods=initialize,session/new sentAdditionalDirectories=false start=success
v2-supported-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=success
v2-omitted-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=failure
v2-null-roots-true: methods=initialize,session/new sentAdditionalDirectories=true start=failure
RESULT: defect reproduced
The supported entry point is packages/provider-acp/src/server/AcpSessionRuntime.ts#L1243-L1251. Session creation calls the shared setup helper after initialization at packages/provider-acp/src/server/AcpSessionRuntime.ts#L2229-L2272. The relevant selection code is packages/provider-acp/src/server/AcpSessionRuntime.ts#L2148-L2173.
Workaround#
Omit the optional extra-directory input only if its functionality is not required; the no-extra-directory control verifies this bypass. Real provider-specific workaround availability is unknown.
Evidence#
Expected source: the pinned upstream specifications linked above. Failure source: the production runtime service and fake-peer wire capture in the reproduction. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete for the runtime service boundary. Missing fact: real-agent prevalence is unknown.
Source evidence: sessionSetupFields includes every non-empty configured additional-directory list without consulting the advertised additional-directory capability. Its result is shared by new/load/resume/fork requests; the reproduction directly exercises new-session creation.
Restoration check#
Run the included script against the changed checkout. It must exit 0, keep the no-extra-directory and advertised-capability controls passing, and send no additionalDirectories field for either omitted or null capabilities in v1 and v2.
References#
- https://github.com/pingdotgg/t3code/pull/21438 — related propagation of additional directories across session activation; the separate failure here is sending them without the required capability.
tanstack-source added labelsbug
github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced
Sign in to join this conversation.