Skip to content

Commit b532d6b

Browse files
Fix 3 critical bugs: message privacy (#11), Windows server start (#17), status bar label (#12) (#32)
Fixes the three most critical open bugs, each via strict **test-first TDD** (a failing test was written and confirmed to fail *because of the bug*, then the minimal fix was applied). ## #11 — Direct messages leak to all agents (privacy/security) 🔴 The `status` MCP tool called `db.listAllMessages()` and returned **every** message — including direct messages between *other* agents — to any caller, with no identity filtering. **Fix:** `status` now resolves the caller's identity (`agent_key` arg or session) and returns only broadcasts (`*`) plus the caller's own sent/received messages. Anonymous callers see broadcasts only. Tagged `[MSG-PRIVACY]`. - `packages/core/src/tools/status_tool.ts`, `packages/core/src/server.ts` - New test: `test/status_message_privacy_test.ts`; pre-existing tests that asserted the leak updated to assert correct privacy. ## #17 — Windows local server fails to start; ENOENT swallowed 🔴 `spawn()` ran with no shell, so Node couldn't resolve the global npm `.cmd` shim on Windows, and the `'error'` event was never handled — so the real ENOENT was swallowed and users only saw the misleading `Local server did not start within 15000ms`. **Fix:** spawn via the shell on Windows (`buildLocalSpawnConfig`) so the `.cmd` shim resolves, and surface spawn failures promptly via a cancellable readiness race instead of the generic timeout. - `src/services/connectionManager.ts` - New test: `test/pure/connectionManagerSpawn.test.ts` ## #12 — Status bar shows "TMC: Disconnected" while connected The mode label equated a `null` connection target with "disconnected", but the auto-connect (restore-on-activation) path never calls `setTarget`, so the target stayed `null` while live. **Fix:** a connected manager with no explicit target is, by construction, the default local server (cloud always sets a target via the picker), so the label is now derived from connection status in a pure, centralized `selectModeLabel` selector. - `src/state/selectors.ts`, `src/ui/statusBar.ts` - New test: `test/pure/statusBarModeLabel.test.ts` ## Verification - MCP server: `npm run build`, `npm run lint`, `npm test` → **363/363 pass** - Extension: compile, `npm run lint`, pure tests + coverage → **156/156 pass, 93.18%** (threshold 80%) - `make lint` clean across both packages - No test was skipped, deleted, or had assertions removed. Closes #11 Closes #12 Closes #17 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f9dfa4 commit b532d6b

11 files changed

Lines changed: 344 additions & 46 deletions

File tree

too-many-cooks/packages/core/src/server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,11 @@ const planZodSchema: z.ZodRawShape = {
148148
.describe("Agent key for authentication (optional, uses session if omitted)"),
149149
};
150150

151-
/** Zod schema for status tool input (empty). */
152-
const statusZodSchema: z.ZodRawShape = {};
151+
/** Zod schema for status tool input. */
152+
const statusZodSchema: z.ZodRawShape = {
153+
agent_key: z.string().optional()
154+
.describe("Agent key for authentication (optional, uses session if omitted)"),
155+
};
153156

154157
/**
155158
* Wrap a local ToolCallback so it satisfies the MCP SDK's typed callback
@@ -203,7 +206,7 @@ const registerTools: (
203206
server.registerTool(
204207
"status",
205208
{ description: statusToolConfig.description, inputSchema: statusZodSchema },
206-
wrapHandler(createStatusHandler(db, log)),
209+
wrapHandler(createStatusHandler(db, log, getSession)),
207210
);
208211
};
209212

too-many-cooks/packages/core/src/tools/status_tool.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ import {
1212
} from "../types.js";
1313
import {
1414
type CallToolResult,
15+
type SessionGetter,
1516
type ToolCallback,
1617
textContent,
1718
} from "../mcp-types.js";
18-
import { makeErrorResult } from "./tool_utils.js";
19+
import { BROADCAST_RECIPIENT } from "../notifications.js";
20+
import { type IdentityResult, makeErrorResult, resolveIdentity } from "./tool_utils.js";
1921

20-
/** Input schema for status tool (no inputs required). */
22+
/** Input schema for status tool. */
2123
export const STATUS_INPUT_SCHEMA: {
2224
readonly type: "object";
23-
readonly properties: Record<string, never>;
25+
readonly properties: Record<string, unknown>;
2426
} = {
2527
type: "object",
26-
properties: {},
28+
properties: {
29+
agent_key: {
30+
type: "string",
31+
description: "Agent key for authentication (optional, uses session if omitted)",
32+
},
33+
},
2734
} as const;
2835

2936
/** Tool config for status. */
@@ -41,15 +48,28 @@ export const STATUS_TOOL_CONFIG: {
4148
annotations: null,
4249
} as const;
4350

51+
/// [MSG-PRIVACY] Issue #11: an agent may only see broadcasts plus its own
52+
/// (sent or received) messages. Direct messages addressed to other agents
53+
/// must never be returned by the status overview. A caller with no resolved
54+
/// identity (agentName === null) sees broadcasts only.
55+
const isVisibleTo: (message: Message, agentName: string | null) => boolean = (
56+
message: Message,
57+
agentName: string | null,
58+
): boolean =>
59+
message.toAgent === BROADCAST_RECIPIENT ||
60+
(agentName !== null && (message.toAgent === agentName || message.fromAgent === agentName));
61+
4462
/** Create status tool handler. */
4563
export const createStatusHandler: (
4664
db: TooManyCooksDb,
4765
logger: Logger,
66+
getSession?: SessionGetter,
4867
) => ToolCallback = (
4968
db: TooManyCooksDb,
5069
logger: Logger,
70+
getSession: SessionGetter = (): null => null,
5171
): ToolCallback =>
52-
{return async (): Promise<CallToolResult> => {
72+
{return async (args: Record<string, unknown>): Promise<CallToolResult> => {
5373
const log: Logger = logger.child({ tool: "status" });
5474

5575
const agentsResult: Result<readonly AgentIdentity[], DbError> = await db.listAgents();
@@ -66,7 +86,13 @@ export const createStatusHandler: (
6686

6787
const messagesResult: Result<readonly Message[], DbError> = await db.listAllMessages();
6888
if (!messagesResult.ok) {return makeErrorResult(messagesResult.error);}
69-
const messages: Array<Record<string, unknown>> = messagesResult.value.map(messageToJson);
89+
90+
// [MSG-PRIVACY] Issue #11: filter direct messages by resolved caller identity.
91+
const identity: IdentityResult = await resolveIdentity(db, args, getSession);
92+
const agentName: string | null = identity.isError ? null : identity.agentName;
93+
const messages: Array<Record<string, unknown>> = messagesResult.value
94+
.filter((message: Message): boolean => isVisibleTo(message, agentName))
95+
.map(messageToJson);
7096

7197
log.debug("Status queried");
7298

too-many-cooks/packages/too-many-cooks/test/integration_test.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -406,27 +406,39 @@ describe("Too Many Cooks MCP Server Integration", () => {
406406
});
407407
}
408408

409-
// Check status - MUST include messages!
409+
// Check status as a known agent. [MSG-PRIVACY] Issue #11: status only
410+
// returns messages visible to the caller (broadcasts + its own sent or
411+
// received), so the ring of direct sends yields exactly the two that touch
412+
// the viewer (one it sent, one it received).
413+
const viewer = agents[0]!;
410414
const statusJson = JSON.parse(
411-
await client.callTool("status", {}),
415+
await client.callTool("status", { agent_key: viewer.key }),
412416
) as Record<string, unknown>;
413417
assert.strictEqual((statusJson.agents as unknown[]).length, 5);
414418
assert.strictEqual((statusJson.locks as unknown[]).length, 5);
415419
assert.strictEqual((statusJson.plans as unknown[]).length, 5);
416-
// CRITICAL: Status MUST return messages!
420+
// CRITICAL: Status MUST include the messages field.
417421
assert.strictEqual(
418422
"messages" in statusJson,
419423
true,
420424
"Status response MUST include messages field",
421425
);
426+
const msgs = statusJson.messages as Array<Record<string, unknown>>;
422427
assert.strictEqual(
423-
(statusJson.messages as unknown[]).length,
424-
5,
425-
"Status MUST return all 5 messages sent",
428+
msgs.length,
429+
2,
430+
"Status returns only the caller's own sent/received messages",
426431
);
432+
// [MSG-PRIVACY] Issue #11: every returned message must involve the caller.
433+
for (const m of msgs) {
434+
assert.strictEqual(
435+
m.to_agent === viewer.name || m.from_agent === viewer.name,
436+
true,
437+
"status must not leak messages unrelated to the caller",
438+
);
439+
}
427440

428441
// Verify message structure
429-
const msgs = statusJson.messages as Array<Record<string, unknown>>;
430442
const firstMsg = msgs[0]!;
431443
assert.ok("id" in firstMsg);
432444
assert.ok("from_agent" in firstMsg);

too-many-cooks/packages/too-many-cooks/test/status_handler_test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ describe("status handler", () => {
7676
await db.sendMessage(reg1.value.agentName, reg1.value.agentKey, reg2.value.agentName, "hello");
7777

7878
const handler = createStatusHandler(db, createTestLogger());
79-
const result = await handler({}, {});
79+
// [MSG-PRIVACY] Issue #11: the direct message (reg1 -> reg2) is only visible
80+
// to its recipient, so query status as reg2 to see it.
81+
const result = await handler({ agent_key: reg2.value.agentKey }, {});
8082
assert.strictEqual(result.isError, false);
8183
const parsed = JSON.parse(result.content[0].text) as Record<string, unknown>;
8284
assert.strictEqual((parsed.agents as unknown[]).length, 2);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/// [MSG-PRIVACY] Issue #11: the `status` tool must NOT leak direct messages
2+
/// addressed to other agents. An agent calling `status` may only see
3+
/// broadcasts (to_agent = "*") plus its own inbox/sent messages.
4+
5+
import { describe, it, beforeEach, afterEach } from "node:test";
6+
import assert from "node:assert";
7+
import fs from "node:fs";
8+
import {
9+
type TooManyCooksDb,
10+
createDataConfig,
11+
createLoggerWithContext,
12+
createLoggingContext,
13+
createStatusHandler,
14+
} from "too-many-cooks-core";
15+
import { createDb } from "../src/db-sqlite.js";
16+
17+
const TEST_DB_PATH = ".test_status_message_privacy.db";
18+
const BROADCAST = "*";
19+
20+
const deleteIfExists = (filePath: string): void => {
21+
try {
22+
if (fs.existsSync(filePath)) {
23+
fs.unlinkSync(filePath);
24+
}
25+
} catch {
26+
// Ignore
27+
}
28+
};
29+
30+
const createTestLogger = () =>
31+
createLoggerWithContext(createLoggingContext());
32+
33+
type StatusMessage = {
34+
readonly content: string;
35+
};
36+
37+
const statusMessagesFor = async (
38+
db: TooManyCooksDb,
39+
agentKey: string,
40+
): Promise<readonly StatusMessage[]> => {
41+
const handler = createStatusHandler(db, createTestLogger());
42+
const result = await handler({ agent_key: agentKey }, {});
43+
assert.strictEqual(result.isError, false, "status must not error");
44+
const parsed = JSON.parse(result.content[0].text) as { readonly messages: readonly StatusMessage[] };
45+
return parsed.messages;
46+
};
47+
48+
describe("status tool message privacy (#11)", () => {
49+
let db: TooManyCooksDb | undefined;
50+
51+
beforeEach(() => {
52+
deleteIfExists(TEST_DB_PATH);
53+
const config = createDataConfig({ dbPath: TEST_DB_PATH });
54+
const result = createDb(config);
55+
assert.strictEqual(result.ok, true);
56+
if (!result.ok) { throw new Error("expected ok db"); }
57+
db = result.value;
58+
});
59+
60+
afterEach(() => {
61+
db?.close();
62+
deleteIfExists(TEST_DB_PATH);
63+
});
64+
65+
it("does not expose a direct message between two other agents to a third agent", async () => {
66+
if (!db) { throw new Error("expected db"); }
67+
const alice = await db.register("privacy-alice");
68+
const bob = await db.register("privacy-bob");
69+
const carol = await db.register("privacy-carol");
70+
if (!alice.ok || !bob.ok || !carol.ok) { throw new Error("expected registrations ok"); }
71+
72+
const secret = "secret-for-bob-only";
73+
const sent = await db.sendMessage(alice.value.agentName, alice.value.agentKey, bob.value.agentName, secret);
74+
assert.strictEqual(sent.ok, true, "direct send must succeed");
75+
76+
const carolMessages = await statusMessagesFor(db, carol.value.agentKey);
77+
assert.strictEqual(
78+
carolMessages.some((m) => m.content === secret),
79+
false,
80+
"Carol must NOT see a direct message addressed to Bob",
81+
);
82+
});
83+
84+
it("still shows the recipient their own direct message and broadcasts to everyone", async () => {
85+
if (!db) { throw new Error("expected db"); }
86+
const alice = await db.register("privacy-alice2");
87+
const bob = await db.register("privacy-bob2");
88+
const carol = await db.register("privacy-carol2");
89+
if (!alice.ok || !bob.ok || !carol.ok) { throw new Error("expected registrations ok"); }
90+
91+
const direct = "direct-to-bob";
92+
const broadcast = "hello-everyone";
93+
await db.sendMessage(alice.value.agentName, alice.value.agentKey, bob.value.agentName, direct);
94+
await db.sendMessage(alice.value.agentName, alice.value.agentKey, BROADCAST, broadcast);
95+
96+
const bobMessages = await statusMessagesFor(db, bob.value.agentKey);
97+
assert.strictEqual(bobMessages.some((m) => m.content === direct), true, "Bob must see his own direct message");
98+
assert.strictEqual(bobMessages.some((m) => m.content === broadcast), true, "Bob must see the broadcast");
99+
100+
const carolMessages = await statusMessagesFor(db, carol.value.agentKey);
101+
assert.strictEqual(carolMessages.some((m) => m.content === direct), false, "Carol must NOT see Bob's direct message");
102+
assert.strictEqual(carolMessages.some((m) => m.content === broadcast), true, "Carol must see the broadcast");
103+
});
104+
});

too_many_cooks_vscode_extension/src/services/connectionManager.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,32 @@ interface ManagerState {
8282
target: ConnectionTarget | null;
8383
}
8484

85+
/** Everything needed to spawn and reach the local server. */
86+
interface LocalServerSpec {
87+
readonly bin: string;
88+
readonly port: number;
89+
readonly workspaceFolder: string;
90+
}
91+
8592
/** Build the local base URL for a given port. */
8693
function buildLocalBaseUrl(port: number): string {
8794
return `${LOCAL_BASE_URL_PREFIX}${String(port)}`;
8895
}
8996

90-
/** Poll until the local server responds on /admin/status. */
91-
async function pollUntilReady(baseUrl: string, log: LogFn): Promise<void> {
97+
/** Platform-aware spawn configuration for the local server (Issue #17).
98+
* On Windows the global npm bin is a `.cmd` shim that Node's spawn() can only
99+
* resolve via the shell; on posix the shell is unnecessary (and lets a missing
100+
* binary surface as an ENOENT 'error' event instead of a shell exit code). */
101+
export function buildLocalSpawnConfig(platform: NodeJS.Platform): { readonly shell: boolean } {
102+
return { shell: platform === 'win32' };
103+
}
104+
105+
/** Poll until the local server responds on /admin/status. Stops early if the
106+
* signal aborts (e.g. spawn failed first), so the loser of the startup race
107+
* does not keep polling a dead port. */
108+
async function pollUntilReady(baseUrl: string, log: LogFn, signal: AbortSignal): Promise<void> {
92109
const deadline: number = Date.now() + STARTUP_TIMEOUT_MS;
93-
while (Date.now() < deadline) {
110+
while (Date.now() < deadline && !signal.aborted) {
94111
const available: boolean = await checkServerAvailable(baseUrl);
95112
if (available) {
96113
log(`[ConnectionManager] Server ready at ${baseUrl}`);
@@ -100,16 +117,18 @@ async function pollUntilReady(baseUrl: string, log: LogFn): Promise<void> {
100117
setTimeout(resolve, POLL_INTERVAL_MS);
101118
});
102119
}
120+
if (signal.aborted) { return; }
103121
throw new Error(`Local server did not start within ${String(STARTUP_TIMEOUT_MS)}ms`);
104122
}
105123

106124
/** Spawn the too-many-cooks CLI as a child process. */
107-
function spawnLocalServer(port: number, workspaceFolder: string, log: LogFn): ChildProcess {
108-
log(`[ConnectionManager] Spawning local server on port ${String(port)} (workspace: ${workspaceFolder})`);
109-
const child: ChildProcess = spawn(LOCAL_SERVER_BIN, [], {
110-
cwd: workspaceFolder,
125+
function spawnLocalServer(spec: LocalServerSpec, log: LogFn): ChildProcess {
126+
log(`[ConnectionManager] Spawning local server '${spec.bin}' on port ${String(spec.port)} (workspace: ${spec.workspaceFolder})`);
127+
const child: ChildProcess = spawn(spec.bin, [], {
128+
cwd: spec.workspaceFolder,
111129
detached: false,
112-
env: { ...process.env, [TMC_PORT_ENV]: String(port), [TMC_WORKSPACE_ENV]: workspaceFolder },
130+
env: { ...process.env, [TMC_PORT_ENV]: String(spec.port), [TMC_WORKSPACE_ENV]: spec.workspaceFolder },
131+
shell: buildLocalSpawnConfig(process.platform).shell,
113132
stdio: ['ignore', 'pipe', 'pipe'],
114133
});
115134
child.stderr?.on('data', (data: Buffer): void => {
@@ -121,6 +140,26 @@ function spawnLocalServer(port: number, workspaceFolder: string, log: LogFn): Ch
121140
return child;
122141
}
123142

143+
/** Resolve once the server is ready, or reject promptly if the child fails to
144+
* spawn (ENOENT 'error'). Without this the failure is swallowed and the user
145+
* only sees the misleading generic startup timeout. Issue #17. */
146+
async function awaitServerReady(child: ChildProcess, spec: LocalServerSpec, log: LogFn): Promise<void> {
147+
const controller: AbortController = new AbortController();
148+
const failure: Promise<never> = new Promise<never>(
149+
(_resolve: (value: never) => void, reject: (reason: Error) => void): void => {
150+
child.once('error', (err: Error): void => {
151+
reject(new Error(`Local server '${spec.bin}' could not be started: ${err.message}`));
152+
});
153+
},
154+
);
155+
try {
156+
await Promise.race([pollUntilReady(buildLocalBaseUrl(spec.port), log, controller.signal), failure]);
157+
} finally {
158+
controller.abort();
159+
child.removeAllListeners('error');
160+
}
161+
}
162+
124163
/** Kill a child process with SIGTERM, escalating to SIGKILL after grace period. */
125164
function killProcess(child: ChildProcess, log: LogFn): void {
126165
log('[ConnectionManager] Sending SIGTERM to local server');
@@ -154,7 +193,7 @@ async function validateCloudCredentials(target: CloudTarget, log: LogFn): Promis
154193
}
155194

156195
/** Create a connection manager instance. */
157-
export function createConnectionManager(workspaceFolder: string, log: LogFn): ConnectionManager {
196+
export function createConnectionManager(workspaceFolder: string, log: LogFn, serverBin: string = LOCAL_SERVER_BIN): ConnectionManager {
158197
const state: ManagerState = {
159198
localProcess: null,
160199
mode: 'disconnected',
@@ -173,9 +212,10 @@ export function createConnectionManager(workspaceFolder: string, log: LogFn): Co
173212

174213
async function startLocal(port: number = DEFAULT_LOCAL_PORT): Promise<void> {
175214
if (state.mode !== 'disconnected') { disconnect(); }
176-
const child: ChildProcess = spawnLocalServer(port, workspaceFolder, log);
215+
const spec: LocalServerSpec = { bin: serverBin, port, workspaceFolder };
216+
const child: ChildProcess = spawnLocalServer(spec, log);
177217
state.localProcess = child;
178-
await pollUntilReady(buildLocalBaseUrl(port), log);
218+
await awaitServerReady(child, spec, log);
179219
Object.assign(state, { mode: 'local' satisfies ConnectionMode, target: { mode: 'local', port, transport: LOCAL_TRANSPORT } satisfies LocalTarget });
180220
}
181221

too_many_cooks_vscode_extension/src/state/selectors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
// Derived state selectors.
22

33
import type { AgentDetails, AgentIdentity, AgentPlan, AppState, ConnectionStatus, FileLock, Message } from './types';
4+
import type { ConnectionTarget } from '../services/connectionTypes';
5+
6+
/** Connection mode labels shown in the status bar. */
7+
export const MODE_LOCAL: string = 'Local/HTTP';
8+
export const MODE_CLOUD_STDIO: string = 'Cloud/stdio';
9+
export const MODE_CLOUD_HTTP: string = 'Cloud/HTTP';
10+
export const MODE_DISCONNECTED: string = 'Disconnected';
411

512
export function selectConnectionStatus(state: Readonly<AppState>): ConnectionStatus {
613
return state.connectionStatus;
714
}
815

16+
/** Status-bar mode label for the current connection status and target.
17+
* Issue #12: a live connection with no explicit target is, by construction,
18+
* the default local server (cloud always sets a target via the picker), so it
19+
* must read as the local mode — never "Disconnected" while connected. */
20+
export function selectModeLabel(status: ConnectionStatus, target: ConnectionTarget | null): string {
21+
if (status !== 'connected') { return MODE_DISCONNECTED; }
22+
if (target === null || target.mode === 'local') { return MODE_LOCAL; }
23+
return target.transport === 'stdio' ? MODE_CLOUD_STDIO : MODE_CLOUD_HTTP;
24+
}
25+
926
export function selectAgents(state: Readonly<AppState>): readonly AgentIdentity[] {
1027
return state.agents;
1128
}

0 commit comments

Comments
 (0)