Skip to content

Commit 88bc7d9

Browse files
authored
merge: per-device agent consent for simulators (#277)
2 parents aa4161f + ac22acc commit 88bc7d9

24 files changed

Lines changed: 749 additions & 23 deletions

File tree

apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { SimulatorBackend } from '@linkcode/engine';
2-
import { SimulatorService } from '@linkcode/engine';
2+
import { SimulatorConsentService, SimulatorService } from '@linkcode/engine';
33
import type { McpServer, SessionId } from '@linkcode/schema';
44
// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths defeat the resolver; tsc resolves them fine
55
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -53,6 +53,13 @@ function fakeBackend(): SimulatorBackend {
5353
};
5454
}
5555

56+
/** Consent pre-granted for the fixture device, so a test exercises the tools and not the gate. */
57+
async function granted(udid = 'U-1'): Promise<SimulatorConsentService> {
58+
const consent = new SimulatorConsentService();
59+
await consent.decide(udid, 'granted');
60+
return consent;
61+
}
62+
5663
function urlOf(entry: McpServer | undefined): string {
5764
if (entry?.type !== 'http') throw new Error('expected an http MCP endpoint');
5865
return entry.url;
@@ -74,11 +81,15 @@ describe('SimulatorMcpEndpoint', () => {
7481

7582
it('serves session-scoped tools over MCP streamable http', async () => {
7683
const activity: string[] = [];
77-
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), {
78-
activity(a) {
79-
activity.push(`${a.tool}:${a.phase}:${a.sessionId}`);
84+
endpoint = await SimulatorMcpEndpoint.create(
85+
new SimulatorService(fakeBackend()),
86+
await granted(),
87+
{
88+
activity(a) {
89+
activity.push(`${a.tool}:${a.phase}:${a.sessionId}`);
90+
},
8091
},
81-
});
92+
);
8293
const entry = endpoint.endpointFor(S1);
8394
expect(entry).toMatchObject({ type: 'http', name: 'linkcode-sim' });
8495

@@ -117,7 +128,7 @@ describe('SimulatorMcpEndpoint', () => {
117128

118129
it('enforces cross-session ownership through the shared service', async () => {
119130
const service = new SimulatorService(fakeBackend());
120-
endpoint = await SimulatorMcpEndpoint.create(service);
131+
endpoint = await SimulatorMcpEndpoint.create(service, await granted());
121132
const first = await connect(urlOf(endpoint.endpointFor(S1)));
122133
const second = await connect(urlOf(endpoint.endpointFor(S2)));
123134

@@ -137,8 +148,80 @@ describe('SimulatorMcpEndpoint', () => {
137148
await second.close();
138149
});
139150

151+
it('suspends an agent tool on an unknown device until the user answers', async () => {
152+
const consent = new SimulatorConsentService();
153+
const asked: string[] = [];
154+
consent.setHooks({
155+
ask(_sessionId, udid, tool) {
156+
asked.push(`${tool}:${udid}`);
157+
return true;
158+
},
159+
publish: noop,
160+
});
161+
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), consent);
162+
const client = await connect(urlOf(endpoint.endpointFor(S1)));
163+
164+
const call = client.callTool({ name: 'sim_boot', arguments: { udid: 'U-1' } });
165+
// The tool must still be in flight: it is waiting on the prompt, not failing fast.
166+
await vi.waitFor(() => expect(asked).toEqual(['sim_boot:U-1']));
167+
await consent.decide('U-1', 'granted');
168+
expect((await call).isError).toBeFalsy();
169+
170+
// The decision is remembered, so the next call goes straight through without asking again.
171+
const second = await client.callTool({ name: 'sim_boot', arguments: { udid: 'U-1' } });
172+
expect(second.isError).toBeFalsy();
173+
expect(asked).toEqual(['sim_boot:U-1']);
174+
await client.close();
175+
});
176+
177+
it('refuses a denied device and tells the agent not to retry', async () => {
178+
const consent = new SimulatorConsentService();
179+
await consent.decide('U-1', 'denied');
180+
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), consent);
181+
const client = await connect(urlOf(endpoint.endpointFor(S1)));
182+
183+
const refused = await client.callTool({ name: 'sim_boot', arguments: { udid: 'U-1' } });
184+
expect(refused.isError).toBe(true);
185+
expect(JSON.stringify(refused.content)).toContain('do not retry');
186+
await client.close();
187+
});
188+
189+
it('refuses everything while the global kill switch is off, including device-less tools', async () => {
190+
const consent = await granted();
191+
await consent.setAgentToolsEnabled(false);
192+
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), consent);
193+
const client = await connect(urlOf(endpoint.endpointFor(S1)));
194+
195+
const listed = await client.callTool({ name: 'sim_list_devices', arguments: {} });
196+
expect(listed.isError).toBe(true);
197+
expect(JSON.stringify(listed.content)).toContain('disabled for agents');
198+
199+
// And it lifts again without a restart.
200+
await consent.setAgentToolsEnabled(true);
201+
expect(
202+
(await client.callTool({ name: 'sim_list_devices', arguments: {} })).isError,
203+
).toBeFalsy();
204+
await client.close();
205+
});
206+
207+
it('refuses an unknown device outright when no client is attached to ask', async () => {
208+
// `ask` reporting false is the daemon's "nobody is listening" signal; blocking for the full
209+
// timeout there would look like a hang to the agent.
210+
const consent = new SimulatorConsentService();
211+
consent.setHooks({ ask: () => false, publish: noop });
212+
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()), consent);
213+
const client = await connect(urlOf(endpoint.endpointFor(S1)));
214+
215+
const refused = await client.callTool({ name: 'sim_boot', arguments: { udid: 'U-1' } });
216+
expect(refused.isError).toBe(true);
217+
await client.close();
218+
});
219+
140220
it('rejects unknown tokens and released sessions', async () => {
141-
endpoint = await SimulatorMcpEndpoint.create(new SimulatorService(fakeBackend()));
221+
endpoint = await SimulatorMcpEndpoint.create(
222+
new SimulatorService(fakeBackend()),
223+
await granted(),
224+
);
142225
const url = urlOf(endpoint.endpointFor(S1));
143226
endpoint.release(S1);
144227
await expect(connect(url)).rejects.toThrow();

apps/daemon/src/config.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
import { homedir } from 'node:os';
33
import { dirname, join } from 'node:path';
44
import { daemonRuntimeFilePath } from '@linkcode/common/node';
5-
import type { Accounts, ProvidersConfig } from '@linkcode/schema';
5+
import type { Accounts, ProvidersConfig, SimulatorConsentState } from '@linkcode/schema';
66
import {
77
AccountSchema,
88
AgentKindSchema,
99
DAEMON_DEFAULT_PORT,
1010
ProviderConfigSchema,
11+
SimulatorConsentStateSchema,
1112
} from '@linkcode/schema';
1213
import { WORKSPACES_DIRNAME } from '@linkcode/schema/product';
1314
import type { TransportServerOptions } from '@linkcode/transport/server';
@@ -29,6 +30,8 @@ export interface DaemonConfig {
2930
providers?: ProvidersConfig;
3031
/** Global account pool (data plane); undefined when nothing is configured. */
3132
accounts?: Accounts;
33+
/** Which simulators agents may drive, plus the global agent-tools switch (CODE-420). */
34+
simulatorConsent: SimulatorConsentState;
3235
}
3336

3437
const DEFAULT_PORT = DAEMON_DEFAULT_PORT;
@@ -40,6 +43,7 @@ interface ConfigFile {
4043
listeners?: unknown;
4144
providers?: unknown;
4245
accounts?: unknown;
46+
simulatorConsent?: unknown;
4347
}
4448

4549
function configPath(): string {
@@ -101,9 +105,30 @@ export function loadConfig(): DaemonConfig {
101105
),
102106
providers: parseProviders(file.providers),
103107
accounts: parseAccounts(file.accounts),
108+
simulatorConsent: parseSimulatorConsent(file.simulatorConsent),
104109
};
105110
}
106111

112+
/**
113+
* A malformed blob falls back to "nothing decided yet", which re-asks rather than silently
114+
* granting: consent is the one field where losing state must fail closed.
115+
*/
116+
function parseSimulatorConsent(raw: unknown): SimulatorConsentState {
117+
const empty: SimulatorConsentState = { entries: [], agentToolsEnabled: true };
118+
if (raw === undefined) return empty;
119+
const parsed = SimulatorConsentStateSchema.safeParse(raw);
120+
if (!parsed.success) {
121+
logger.warn({ operation: 'config.load' }, 'Dropping invalid simulator consent config');
122+
return empty;
123+
}
124+
return parsed.data;
125+
}
126+
127+
/** Persist simulator agent-consent to config.json, preserving its other fields; `0600`. */
128+
export function saveSimulatorConsent(state: SimulatorConsentState): void {
129+
writeConfigField('simulatorConsent', state);
130+
}
131+
107132
/**
108133
* Parse element by element: an invalid account is dropped and logged, never blanking the pool —
109134
* `saveAccounts` would persist that loss on the next write. Mirrors {@link parseProviders}.
@@ -167,7 +192,10 @@ export function saveAccounts(accounts: Accounts): void {
167192
}
168193

169194
/** Read-modify-write a single top-level field of config.json, preserving the rest; `0600`. */
170-
function writeConfigField(key: 'providers' | 'accounts', value: unknown): void {
195+
function writeConfigField(
196+
key: 'providers' | 'accounts' | 'simulatorConsent',
197+
value: unknown,
198+
): void {
171199
const path = configPath();
172200
let file: Record<string, unknown> = {};
173201
try {

apps/daemon/src/index.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
EngineService,
99
makeEngineInfrastructureLayer,
1010
PreviewRouteRegistry,
11+
SimulatorConsentService,
1112
SimulatorService,
1213
} from '@linkcode/engine';
1314
import type { DaemonIdentity, DaemonListenerInfo, DaemonRuntimeInfo } from '@linkcode/schema';
@@ -27,7 +28,13 @@ import { extractErrorMessage } from 'foxts/extract-error-message';
2728
import { createAiGatewaySidecar } from './ai-gateway';
2829
import { installAsarSpawnFix } from './asar-spawn';
2930
import type { DaemonConfig } from './config';
30-
import { chatWorkspaceRoot, daemonProfile, databasePath, loadConfig } from './config';
31+
import {
32+
chatWorkspaceRoot,
33+
daemonProfile,
34+
databasePath,
35+
loadConfig,
36+
saveSimulatorConsent,
37+
} from './config';
3138
import { runLoginCommand, runLogoutCommand } from './hq/login';
3239
import { startHqUplink } from './hq/uplink';
3340
import { DaemonLoggerLive, logger } from './logger';
@@ -225,9 +232,29 @@ async function main(): Promise<void> {
225232
const simulators = simSidecarPath
226233
? new SimulatorService(new SimSidecarClient(simSidecarPath))
227234
: undefined;
235+
// Decisions persist in config.json, so a grant outlives the session that earned it. The ask
236+
// hook reports whether anyone is attached: with no client there is nobody to answer, and
237+
// suspending the agent for two minutes would just look like a hang.
238+
const simulatorConsent = new SimulatorConsentService({
239+
load: () => Promise.resolve(config.simulatorConsent),
240+
save: (state) => Promise.resolve(saveSimulatorConsent(state)),
241+
});
242+
yield* Effect.promise(() => simulatorConsent.init());
243+
simulatorConsent.setHooks({
244+
ask(sessionId, udid, tool) {
245+
if (hub.size === 0) return false;
246+
hub.send(
247+
createWireMessage({ kind: 'simulator.consent.required', sessionId, udid, tool }),
248+
);
249+
return true;
250+
},
251+
publish(state) {
252+
hub.send(createWireMessage({ kind: 'simulator.consent.changed', state }));
253+
},
254+
});
228255
const simulatorMcp = simulators
229256
? yield* Effect.promise(() =>
230-
SimulatorMcpEndpoint.create(simulators, {
257+
SimulatorMcpEndpoint.create(simulators, simulatorConsent, {
231258
activity(activity) {
232259
hub.send(createWireMessage({ kind: 'simulator.activity', ...activity }));
233260
},
@@ -245,6 +272,7 @@ async function main(): Promise<void> {
245272
ptyBackend: new SidecarPtyBackend(resolveSidecarPath()),
246273
simulators,
247274
simulatorMcp,
275+
simulatorConsent,
248276
sessionStore: createSessionStore(databasePath()),
249277
// After sessionStore so its migration-ledger reconcile runs before this store migrates.
250278
scheduleStore: createScheduleStore(databasePath()),

apps/daemon/src/sim/mcp-endpoint.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { randomUUID } from 'node:crypto';
22
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
33
import { createServer } from 'node:http';
4-
import type { SimulatorMcpProvider, SimulatorService } from '@linkcode/engine';
4+
import type {
5+
SimulatorConsentService,
6+
SimulatorMcpProvider,
7+
SimulatorService,
8+
} from '@linkcode/engine';
59
import type { McpServer as McpServerEntry, SessionId } from '@linkcode/schema';
610
// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths (./server/*.js) defeat the resolver; tsc resolves them fine
711
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -47,12 +51,14 @@ export class SimulatorMcpEndpoint implements SimulatorMcpProvider {
4751
private constructor(
4852
private readonly server: Server,
4953
private readonly simulators: SimulatorService,
54+
private readonly consent: SimulatorConsentService,
5055
private readonly notify?: SimulatorMcpNotifications,
5156
) {}
5257

5358
static create(
5459
this: void,
5560
simulators: SimulatorService,
61+
consent: SimulatorConsentService,
5662
notify?: SimulatorMcpNotifications,
5763
): Promise<SimulatorMcpEndpoint> {
5864
return new Promise((resolve, reject) => {
@@ -61,7 +67,7 @@ export class SimulatorMcpEndpoint implements SimulatorMcpProvider {
6167
// handle() never rejects (it catches internally); the catch is stream-error paranoia.
6268
endpoint?.handle(req, res).catch(noop);
6369
});
64-
endpoint = new SimulatorMcpEndpoint(server, simulators, notify);
70+
endpoint = new SimulatorMcpEndpoint(server, simulators, consent, notify);
6571
server.once('error', reject);
6672
// Loopback only, ephemeral port: the endpoint carries no auth beyond its per-session
6773
// token path, so it must never be reachable off-host.
@@ -158,8 +164,11 @@ export class SimulatorMcpEndpoint implements SimulatorMcpProvider {
158164
udid: string | undefined,
159165
op: () => Promise<string>,
160166
): Promise<{ content: [{ type: 'text'; text: string }]; isError?: true }> => {
167+
// Announced before the consent gate on purpose: a tool suspended waiting for the user is
168+
// exactly when the panel should be showing this device.
161169
this.notify?.activity?.({ sessionId, udid, tool, phase: 'started' });
162170
try {
171+
await this.consent.require(sessionId, udid, tool);
163172
return { content: [{ type: 'text', text: await op() }] };
164173
} catch (err) {
165174
return {
@@ -289,6 +298,7 @@ export class SimulatorMcpEndpoint implements SimulatorMcpProvider {
289298
const tool = 'sim_screenshot';
290299
this.notify?.activity?.({ sessionId, udid, tool, phase: 'started' });
291300
try {
301+
await this.consent.require(sessionId, udid, tool);
292302
const chosen = format ?? 'jpeg';
293303
const image = await simulators.screenshot(sessionId, udid, chosen);
294304
return {

apps/desktop/e2e/simulator-panel.e2e.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const PORT = 43000 + (process.pid % 1000);
3939

4040
/** Must match `WIRE_PROTOCOL_VERSION` (node can't load the raw-TS schema barrel); a mismatch is
4141
* silently discarded by the daemon, surfacing here as the session.start timeout. */
42-
const WIRE_VERSION = 53;
42+
const WIRE_VERSION = 54;
4343

4444
function fail(message: string): never {
4545
console.error(`FAIL: ${message}`);

0 commit comments

Comments
 (0)