docs/plans/2026-07-10-workspace-qualified-acp-hardening.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Close the six confirmed routing, rate-limit, error, logging, lifecycle, and observability gaps in PR #6621 without redesigning the per-runtime ACP mount architecture.
Architecture: Preserve one mountAcpHttp call, one WebSocket upgrade listener, one primary mount, and one mount per trusted secondary runtime. Add only the readiness predicates and additive snapshot metadata needed to make the surrounding middleware and daemon diagnostics agree with the transport.
Tech Stack: TypeScript, Express 5, ws, Vitest, npm workspaces.
AcpHttpHandle./acp behavior and the public primary AcpHttpHandle.registry remain compatible.URIError parameter failures become 400 invalid_request; unrelated failures remain 500.dispose() is terminal and idempotent.workspaceId, workspaceCwd, and primary.packages/cli, not the repository root.Files:
packages/cli/src/serve/rate-limit.tspackages/cli/src/serve/rate-limit.test.tspackages/cli/src/serve/server/rate-limiter-setup.tspackages/cli/src/serve/server.tspackages/cli/src/serve/acp-http/index.tspackages/cli/src/serve/acp-http/workspace-qualified-acp.test.tsInterfaces:
Consumes: resolveAcpHttpEnabled(), WorkspaceRegistry.list(), and existing MountAcpHttpOptions.workspaceRegistry.
Produces: RateLimitConfig.workspaceQualifiedAcpEnabled?: boolean; installRateLimiter(..., workspaceQualifiedAcpEnabled?: boolean); qualified HTTP/WS paths mounted only when the registry has multiple runtimes.
Step 1: Write failing rate-limit tests
Add a second limiter inside the tier resolution describe so the readiness flag is explicit:
it('exempts only enabled workspace-qualified ACP transport paths', () => {
const qualifiedLimiter = createRateLimiter({
tiers: {
prompt: { windowMs: 60_000, max: 1 },
mutation: { windowMs: 60_000, max: 1 },
read: { windowMs: 60_000, max: 1 },
},
hostname: '127.0.0.1',
workspaceQualifiedAcpEnabled: true,
});
for (const path of [
'/workspaces/secondary-id/acp',
'/workspaces/secondary-id/acp/',
'/WORKSPACES/secondary-id/ACP',
]) {
const next = vi.fn();
qualifiedLimiter.middleware(mockReq({ path }), mockRes(), next);
qualifiedLimiter.middleware(mockReq({ path }), mockRes(), next);
expect(next).toHaveBeenCalledTimes(2);
}
const nearby = '/workspaces/secondary-id/acp/extra';
qualifiedLimiter.middleware(mockReq({ path: nearby }), mockRes(), vi.fn());
const res = mockRes();
qualifiedLimiter.middleware(mockReq({ path: nearby }), res, vi.fn());
expect(res.body).toMatchObject({ tier: 'mutation' });
qualifiedLimiter.dispose();
});
it('does not exempt workspace-qualified ACP when the route is disabled', () => {
const path = '/workspaces/secondary-id/acp';
limiter.middleware(mockReq({ path }), mockRes(), vi.fn());
const res = mockRes();
limiter.middleware(mockReq({ path }), res, vi.fn());
expect(res.body).toMatchObject({ tier: 'mutation' });
});
Run:
cd packages/cli && npx vitest run src/serve/rate-limit.test.ts
Expected: TypeScript/test failure because workspaceQualifiedAcpEnabled is not a RateLimitConfig property, or qualified paths are charged as mutation.
Add a separately owned server inside the test so the existing multi-workspace fixture stays unchanged:
it('does not expose qualified HTTP or WS routes with one runtime', async () => {
const primaryBridge = makeBridge();
const registry = createWorkspaceRegistry([
makeRuntime({
id: 'primary-id',
cwd: '/ws',
primary: true,
trusted: true,
bridge: primaryBridge,
}),
]);
const app = express();
app.use(express.json());
const singleHandle = mountAcpHttp(app, primaryBridge, {
boundWorkspace: '/ws',
workspace: {} as DaemonWorkspaceService,
enabled: true,
workspaceRegistry: registry,
workspaceRememberLane: new WorkspaceRememberTaskLane(primaryBridge),
})!;
const singleServer = await new Promise<Server>((resolve) => {
const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
});
singleHandle.attachServer(singleServer);
const singlePort = (singleServer.address() as AddressInfo).port;
try {
const qualified = await fetch(
`http://127.0.0.1:${singlePort}/workspaces/primary-id/acp`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: INITIALIZE,
},
);
expect(qualified.status).toBe(404);
const upgradeStatus = await new Promise<number>((resolve) => {
const ws = new WebSocket(
`ws://127.0.0.1:${singlePort}/workspaces/primary-id/acp`,
);
ws.on('open', () => {
ws.close();
resolve(101);
});
ws.on('unexpected-response', (_req, res) => {
ws.terminate();
resolve(res.statusCode ?? 0);
});
ws.on('error', () => resolve(0));
});
expect(upgradeStatus).not.toBe(101);
const legacy = await fetch(`http://127.0.0.1:${singlePort}/acp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: INITIALIZE,
});
expect(legacy.status).toBe(200);
} finally {
singleHandle.dispose();
singleServer.closeAllConnections?.();
await new Promise<void>((resolve) => singleServer.close(() => resolve()));
}
});
Run:
cd packages/cli && npx vitest run src/serve/acp-http/workspace-qualified-acp.test.ts
Expected: qualified HTTP returns 200 and/or the qualified WebSocket opens for the single-runtime registry.
In rate-limit.ts, extend the config and tier resolver:
export interface RateLimitConfig {
tiers: Record<RateLimitTier, RateLimitTierConfig>;
hostname: string;
workspaceQualifiedAcpEnabled?: boolean;
// existing callbacks stay unchanged
}
const WORKSPACE_QUALIFIED_ACP_PATH = /^\/workspaces\/[^/]+\/acp$/i;
function resolveTier(
method: string,
path: string,
workspaceQualifiedAcpEnabled: boolean,
): RateLimitTier | null {
const p = path.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path;
// existing exemptions
if (workspaceQualifiedAcpEnabled && WORKSPACE_QUALIFIED_ACP_PATH.test(p)) {
return null;
}
// existing tier resolution
}
Pass config.workspaceQualifiedAcpEnabled === true from the middleware call to resolveTier.
In server/rate-limiter-setup.ts, add the optional fourth argument and forward it:
export function installRateLimiter(
app: Application,
opts: ServeOptions,
daemonLog: DaemonLogger | undefined,
workspaceQualifiedAcpEnabled = false,
): RateLimiterInstance | undefined {
if (!opts.rateLimit) return undefined;
const windowMs = opts.rateLimitWindowMs ?? 60_000;
const rateLimiter = createRateLimiter({
tiers: {
prompt: { windowMs, max: opts.rateLimitPrompt ?? 10 },
mutation: { windowMs, max: opts.rateLimitMutation ?? 30 },
read: { windowMs, max: opts.rateLimitRead ?? 120 },
},
hostname: opts.hostname,
workspaceQualifiedAcpEnabled,
onLimitReached: daemonLog
? (tier, key, suppressed) => {
daemonLog.warn(
`rate limit hit${suppressed > 0 ? ` (${suppressed} suppressed)` : ''}`,
{ tier, key: key.slice(0, 64) },
);
}
: undefined,
onError: daemonLog
? (err, path) => {
daemonLog.warn(
`rate limiter error (fail-open): ${err instanceof Error ? err.message : String(err)}`,
{ path },
);
}
: undefined,
});
app.use(rateLimiter.middleware);
return rateLimiter;
}
In server.ts, after workspaceRegistry is created, compute and pass:
const workspaceQualifiedAcpEnabled =
resolveAcpHttpEnabled() && workspaceRegistry.list().length > 1;
const rateLimiter = installRateLimiter(
app,
opts,
daemonLog,
workspaceQualifiedAcpEnabled,
);
In mountAcpHttp, the function has already returned when ACP HTTP is disabled, so gate only on runtime count:
const workspaceQualifiedAcpEnabled =
(opts.workspaceRegistry?.list().length ?? 0) > 1;
if (workspaceQualifiedAcpEnabled) {
app.post(pluralAcpPath /* existing handler */);
app.get(pluralAcpPath /* existing handler */);
app.delete(pluralAcpPath /* existing handler */);
}
const pluralRawSelector = workspaceQualifiedAcpEnabled
? pluralAcpRawSelector(rawPath)
: null;
Run:
cd packages/cli && npx vitest run src/serve/rate-limit.test.ts src/serve/acp-http/workspace-qualified-acp.test.ts src/serve/server.test.ts
Expected: all files pass; legacy /acp remains green.
git add packages/cli/src/serve/rate-limit.ts packages/cli/src/serve/rate-limit.test.ts packages/cli/src/serve/server/rate-limiter-setup.ts packages/cli/src/serve/server.ts packages/cli/src/serve/acp-http/index.ts packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts
git commit -m "fix(cli): align workspace-qualified ACP routing"
Include Co-authored-by: Qwen-Coder <[email protected]> in the commit body.
Files:
packages/cli/src/serve/server/error-handlers.tspackages/cli/src/serve/server/error-handlers.test.tspackages/cli/src/serve/acp-http/index.tspackages/cli/src/serve/acp-http/workspace-qualified-acp.test.tsInterfaces:
Consumes: Express router errors with status or statusCode, and existing logSafe().
Produces: structured { error: 'Malformed URL encoding', code: 'invalid_request' }; log-safe selector interpolation.
Step 1: Write failing malformed-selector and log-safety tests
Add a production-shaped Express parameter-decoding test:
import express from 'express';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { installFinalErrorHandler } from './error-handlers.js';
describe('installFinalErrorHandler', () => {
it('returns 400 invalid_request for malformed route parameter encoding', async () => {
const app = express();
app.get('/workspaces/:workspace/acp', (_req, res) => res.sendStatus(204));
installFinalErrorHandler(app);
const res = await request(app).get('/workspaces/%ZZ/acp');
expect(res.status).toBe(400);
expect(res.body).toEqual({
error: 'Malformed URL encoding',
code: 'invalid_request',
});
});
});
Import the mocked writeStderrLine in workspace-qualified-acp.test.ts and add:
it('sanitizes decoded selectors before logging WS rejection', async () => {
vi.mocked(writeStderrLine).mockClear();
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${port}/workspaces/evil%0AFORGED/acp`,
{ handshakeTimeout: 2000 },
);
ws.on('unexpected-response', () => {
ws.terminate();
resolve();
});
ws.on('open', () => {
ws.close();
reject(new Error('unknown workspace selector should not upgrade'));
});
ws.on('error', () => resolve());
});
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('workspace-mismatch evil FORGED'),
);
for (const [message] of vi.mocked(writeStderrLine).mock.calls) {
expect(message).not.toMatch(/[\r\n\u001b]/u);
}
});
Run:
cd packages/cli && npx vitest run src/serve/server/error-handlers.test.ts src/serve/acp-http/workspace-qualified-acp.test.ts
Expected: malformed path returns 500; logged selector contains a newline.
In error-handlers.ts:
function isMalformedRouteEncoding(err: unknown): boolean {
if (!(err instanceof URIError)) return false;
const status = (err as { status?: unknown; statusCode?: unknown }).status;
const statusCode = (err as { statusCode?: unknown }).statusCode;
return status === 400 || statusCode === 400;
}
export function installFinalErrorHandler(app: Application): void {
app.use((err: unknown, _req, res, _next) => {
if (sendJsonBodyParserError(res, err)) return;
if (isMalformedRouteEncoding(err)) {
res.status(400).json({
error: 'Malformed URL encoding',
code: 'invalid_request',
});
return;
}
// existing generic path
});
}
In the WS mismatch log:
logReject(`workspace-mismatch ${logSafe(selector)}`);
Run the same command as Step 2. Expected: both files pass and unrelated generic-error assertions remain 500.
git add packages/cli/src/serve/server/error-handlers.ts packages/cli/src/serve/server/error-handlers.test.ts packages/cli/src/serve/acp-http/index.ts packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts
git commit -m "fix(cli): harden qualified ACP request errors"
Include the required co-author trailer.
Files:
packages/cli/src/serve/acp-http/index.tspackages/cli/src/serve/acp-http/workspace-qualified-acp.test.tsInterfaces:
Consumes: existing closure-scoped disposed latch.
Produces: attachServer() becomes a no-op after disposal.
Step 1: Write the failing lifecycle test
it('does not reattach a WebSocket listener after disposal', () => {
handle!.dispose();
const listenerCount = server.listenerCount('upgrade');
handle!.attachServer(server);
expect(server.listenerCount('upgrade')).toBe(listenerCount);
});
Run:
cd packages/cli && npx vitest run src/serve/acp-http/workspace-qualified-acp.test.ts
Expected: listener count increases after attachServer().
function setupWebSocket(httpServer: import('node:http').Server): void {
if (disposed || wss) return;
// existing setup
}
Do not add a second state variable or throw from attachServer().
Run the same command as Step 2. Expected: all tests pass.
git add packages/cli/src/serve/acp-http/index.ts packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts
git commit -m "fix(cli): make ACP disposal terminal"
Include the required co-author trailer.
Files:
packages/cli/src/serve/acp-http/index.tspackages/cli/src/serve/acp-http/workspace-qualified-acp.test.tspackages/cli/src/serve/daemon-status.tspackages/cli/src/serve/daemon-status.test.tsInterfaces:
Consumes: AcpConnectionDiagnostic, ConnectionRegistry.getSnapshot(), and per-runtime workspace metadata.
Produces: AcpHttpConnectionDiagnostic; AcpHttpSnapshot.connections; full daemon status sourced from the aggregate snapshot.
Step 1: Write failing aggregate snapshot and full-status tests
After initializing a secondary HTTP connection, assert:
const snap = handle!.getSnapshot();
expect(snap.connections).toEqual([
expect.objectContaining({
workspaceId: 'secondary-id',
workspaceCwd: '/ws-b',
primary: false,
}),
]);
In daemon-status.test.ts, supply an aggregate with decorated primary and secondary diagnostics and assert:
import type {
AcpHttpConnectionDiagnostic,
AcpHttpHandle,
AcpHttpSnapshot,
} from './acp-http/index.js';
// Add this property to the existing MakeOptionsInput declaration.
acpAggregate?: AcpHttpSnapshot;
function makeAcpDiagnostic(
workspaceId: string | null,
workspaceCwd: string,
primary: boolean,
): AcpHttpConnectionDiagnostic {
return {
connectionIdPrefix: primary ? 'primary' : 'secondary',
fromLoopback: true,
destroyed: false,
lastActiveMs: 0,
ownedSessionCount: 0,
sessionBindingCount: 0,
closingSessionCount: 0,
pendingClientRequests: 0,
connectionStreamOpen: true,
sessionStreams: 0,
sseStreams: 0,
wsStreams: 1,
bufferedConnectionFrames: 0,
bufferedSessionFrames: 0,
workspaceId,
workspaceCwd,
primary,
};
}
const primaryDiagnostic = makeAcpDiagnostic(null, BASE_WORKSPACE, true);
const secondaryDiagnostic = makeAcpDiagnostic(
'secondary-id',
'/work/secondary',
false,
);
const primaryRegistrySnapshot = {
connectionCount: 1,
connectionCap: 10,
connectionStreams: 1,
sessionStreams: 0,
sseStreams: 0,
wsStreams: 1,
pendingClientRequests: 0,
connections: [primaryDiagnostic],
};
const response = await buildDaemonStatusResponse(
'full',
makeOptions({
acpSnapshot: primaryRegistrySnapshot,
acpAggregate: {
connectionCount: 2,
connectionStreams: 2,
sessionStreams: 0,
sseStreams: 0,
wsStreams: 2,
pendingClientRequests: 0,
mounts: [],
connections: [primaryDiagnostic, secondaryDiagnostic],
},
}),
);
expect(response.runtime.transport.acp.connections).toBe(2);
expect(response.full?.acpConnections).toEqual([
primaryDiagnostic,
secondaryDiagnostic,
]);
Update makeOptions() so the mocked handle returns input.acpAggregate when
provided, otherwise it builds the existing one-mount aggregate and includes an
empty connections array:
getSnapshot: () =>
input.acpAggregate ?? {
connectionCount: input.acpSnapshot!.connectionCount,
connectionStreams: input.acpSnapshot!.connectionStreams,
sessionStreams: input.acpSnapshot!.sessionStreams,
sseStreams: input.acpSnapshot!.sseStreams,
wsStreams: input.acpSnapshot!.wsStreams,
pendingClientRequests: input.acpSnapshot!.pendingClientRequests,
mounts: [
{
workspaceId: null,
primary: true,
connectionCount: input.acpSnapshot!.connectionCount,
wsStreams: input.acpSnapshot!.wsStreams,
},
],
connections: [],
},
Run:
cd packages/cli && npx vitest run src/serve/acp-http/workspace-qualified-acp.test.ts src/serve/daemon-status.test.ts
Expected: AcpHttpSnapshot has no connections, and full status still returns the primary snapshot list.
Import the diagnostic type and extend the aggregate contracts:
import {
ConnectionRegistry,
type AcpConnection,
type AcpConnectionDiagnostic,
} from './connection-registry.js';
export interface AcpHttpConnectionDiagnostic extends AcpConnectionDiagnostic {
workspaceId: string | null;
workspaceCwd: string;
primary: boolean;
}
export interface AcpHttpSnapshot {
// existing counters
mounts: AcpHttpMountSnapshot[];
connections: AcpHttpConnectionDiagnostic[];
}
Add workspaceCwd to RuntimeAcpMount, set it on primary and secondary mounts, and decorate each registry diagnostic in getSnapshot():
connections: perMount.flatMap((mount) =>
mount.snap.connections.map((connection) => ({
...connection,
workspaceId: mount.workspaceId,
workspaceCwd: mount.workspaceCwd,
primary: mount.primary,
})),
),
In daemon-status.ts, define full diagnostics from AcpHttpSnapshot and pass the aggregate:
interface FullDaemonStatus {
sessions: BridgeDaemonStatusSnapshot['sessions'];
acpConnections: AcpHttpSnapshot['connections'];
workspace: Record<string, WorkspaceStatusSection>;
auth: {
supportedDeviceFlowProviders: string[];
pendingDeviceFlowCount: number;
};
}
if (detail === 'full') {
full = await buildFullStatus(
input,
acpAggregate,
workspaceSnapshots.flatMap((item) => item.snapshot.sessions),
);
}
// Change the second buildFullStatus parameter type.
acpSnapshot: AcpHttpSnapshot | undefined;
// Change the existing returned property; keep all section collectors intact.
acpConnections: acpSnapshot?.connections ?? [];
Keep limits.acpConnectionCap sourced from the primary registry because it is the uniform per-mount cap.
Run the same command as Step 2. Expected: all tests pass and both diagnostics are present.
git add packages/cli/src/serve/acp-http/index.ts packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts packages/cli/src/serve/daemon-status.ts packages/cli/src/serve/daemon-status.test.ts
git commit -m "fix(cli): aggregate ACP connection diagnostics"
Include the required co-author trailer.
Files:
d56f18d0d0fb48942475fb3e1eb2d15b8398cf19Interfaces:
Consumes: Tasks 1–4.
Produces: merge-ready evidence with no unresolved Critical or Important review findings.
Step 1: Run focused regression suites
cd packages/cli && npx vitest run src/serve/acp-http/workspace-qualified-acp.test.ts src/serve/rate-limit.test.ts src/serve/daemon-status.test.ts src/serve/server.test.ts src/serve/server/device-flow-registry.test.ts
Expected: all files pass.
npm run format
npm run lint
npm run build
npm run typecheck
npm run check:serve-fast-path-bundle
Expected: every command exits 0.
Inspect git diff d56f18d0...HEAD for routing parity, failure paths, compatibility, test quality, maintainability, excess abstraction, documentation consistency, and generated-file noise. Fix any clear actionable issue, rerun its covering test, then audit again until a pass finds no new actionable issue.
Provide the reviewer the implementation plan, the complete diff from d56f18d0, and verification results. Resolve all Critical and Important findings and rerun affected tests.
git status --short
git log --oneline d56f18d0..HEAD
Expected: clean worktree and the design plus Tasks 1–4 commits present.