@zgeoff/atc 2.10.4 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A target a session runs on, as the name it holds and the identity the
3
+ * session is bound to there.
4
+ */
5
+ export interface TargetGrant {
6
+ readonly target: string;
7
+ readonly targetIdentity: string;
8
+ }
9
+
10
+ /**
11
+ * The targets a principal may use, each as a name and an identity: a
12
+ * session on a granted name whose identity differs is outside the grant.
13
+ */
14
+ export class TargetAccess {
15
+ private readonly grants: ReadonlyMap<string, TargetGrant>;
16
+
17
+ constructor(grants: readonly TargetGrant[]) {
18
+ this.grants = new Map(grants.map((grant) => [buildGrantKey(grant), grant]));
19
+ }
20
+
21
+ canUse(grant: TargetGrant): boolean {
22
+ return this.grants.has(buildGrantKey(grant));
23
+ }
24
+
25
+ /**
26
+ * The grants both accesses hold, so a principal on a narrowed connection
27
+ * never reaches past the connection.
28
+ */
29
+ merge(other: TargetAccess): TargetAccess {
30
+ return new TargetAccess([...this.grants.values()].filter((grant) => other.canUse(grant)));
31
+ }
32
+ }
33
+
34
+ function buildGrantKey(grant: TargetGrant): string {
35
+ return JSON.stringify([grant.target, grant.targetIdentity]);
36
+ }
@@ -4,6 +4,7 @@ import type { GrantScope } from '../shared/grant-scope';
4
4
  import { normalizeClientName } from '../shared/normalize-client-name';
5
5
  import { isRecord } from '../shared/report';
6
6
  import { answerRPCRequest } from './answer-rpc-request';
7
+ import { buildPrincipalCaller } from './build-principal-caller';
7
8
  import { deriveTokenHash } from './derive-token-hash';
8
9
  import { findClientName } from './find-client-name';
9
10
  import { isSupportedProtocolVersion } from './is-supported-protocol-version';
@@ -21,7 +22,8 @@ interface MCPHTTPRequest {
21
22
  * database, so a revoked grant loses access at once, and a token bound to any
22
23
  * other resource is refused. A tool call outside the token's scopes is a 403
23
24
  * that names the missing scope. A message the client sends is always from the
24
- * client's own name.
25
+ * client's own name, and every request acts as the client's id, so the
26
+ * daemon limits it to the targets that principal may use.
25
27
  */
26
28
  export async function answerMCPRequest(
27
29
  ctx: HTTPServerContext,
@@ -65,7 +67,7 @@ export async function answerMCPRequest(
65
67
  }
66
68
 
67
69
  const outcome = await answerRPCRequest(message, {
68
- caller: ctx.caller,
70
+ caller: buildPrincipalCaller(ctx.caller, access.clientID),
69
71
  build: ctx.build,
70
72
  toolContext: { callerSessionID: null, sender: { kind: 'fixed', name: access.clientName } },
71
73
  scopes: access.scopes,
@@ -95,12 +97,13 @@ export async function answerMCPRequest(
95
97
  }
96
98
 
97
99
  interface VerifiedAccess {
100
+ readonly clientID: string;
98
101
  readonly clientName: string;
99
102
  readonly scopes: readonly GrantScope[];
100
103
  }
101
104
 
102
- // An inactive, unknown, or revoked token, or one bound to another resource,
103
- // verifies to null. Verifying stamps when the token's grant was last used.
105
+ // An inactive, unknown, or revoked token, one bound to another resource, or
106
+ // one without a client id verifies to null. Verifying stamps when the token's grant was last used.
104
107
  async function verifyAccessToken(
105
108
  ctx: HTTPServerContext,
106
109
  token: string,
@@ -124,11 +127,16 @@ async function verifyAccessToken(
124
127
  const granted = typeof payload['scope'] === 'string' ? payload['scope'].split(' ') : [];
125
128
  const clientID = typeof payload['client_id'] === 'string' ? payload['client_id'] : '';
126
129
 
130
+ if (clientID === '') {
131
+ return null;
132
+ }
133
+
127
134
  await upsertGrantUse(ctx, token);
128
135
 
129
136
  const storedName = await findClientName(ctx.store.db, clientID);
130
137
 
131
138
  return {
139
+ clientID,
132
140
  clientName: normalizeClientName(storedName, 'remote'),
133
141
  scopes: GRANT_SCOPES.filter((scope) => granted.includes(scope)),
134
142
  };
@@ -1,7 +1,9 @@
1
1
  import { match } from 'ts-pattern';
2
+ import { z } from 'zod';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import type { GrantScope } from '../shared/grant-scope';
4
5
  import { isRecord } from '../shared/report';
6
+ import type { RegisteredAgent } from './build-spawn-descriptions';
5
7
  import { buildToolList } from './build-tool-list';
6
8
  import { MCP_TOOLS } from './mcp-tools';
7
9
  import { pickProtocolVersion } from './pick-protocol-version';
@@ -57,10 +59,16 @@ export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise
57
59
  }),
58
60
  }))
59
61
  .with('ping', () => ({ kind: 'reply' as const, body: buildRPCResult(id, {}) }))
60
- .with('tools/list', () => ({
61
- kind: 'reply' as const,
62
- body: buildRPCResult(id, { tools: buildToolList() }),
63
- }))
62
+ .with('tools/list', async () => {
63
+ const features = await deps.caller.readFeatures();
64
+
65
+ const agents = features.has('agents.list') ? await tryReadAgents(deps) : null;
66
+
67
+ return {
68
+ kind: 'reply' as const,
69
+ body: buildRPCResult(id, { tools: buildToolList(features, agents) }),
70
+ };
71
+ })
64
72
  .with('tools/call', async () => {
65
73
  const result = await answerToolCall(deps, params);
66
74
 
@@ -93,6 +101,29 @@ function findMissingScope(
93
101
  return scopes.includes(needed) ? null : needed;
94
102
  }
95
103
 
104
+ // The part of an agents.list answer the spawn descriptions read.
105
+ const ROSTER_AGENT_SCHEMA = z.object({ id: z.string(), installed: z.boolean() });
106
+ const AGENT_ROSTER_SCHEMA = z.object({ agents: z.array(ROSTER_AGENT_SCHEMA) });
107
+
108
+ // The registered agents, or null when the daemon cannot answer, so the tool
109
+ // list still builds with descriptions that name no agent. A caller without
110
+ // the read scope gets null too, since listing agents is a read.
111
+ async function tryReadAgents(deps: RPCDeps): Promise<RegisteredAgent[] | null> {
112
+ if (deps.scopes !== undefined && !deps.scopes.includes('read')) {
113
+ return null;
114
+ }
115
+
116
+ try {
117
+ const answer = await deps.caller.sendRequest('agents.list');
118
+
119
+ const parsed = AGENT_ROSTER_SCHEMA.safeParse(answer);
120
+
121
+ return parsed.success ? parsed.data.agents : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
96
127
  async function answerToolCall(
97
128
  deps: RPCDeps,
98
129
  params: Readonly<Record<string, unknown>>,
@@ -101,9 +132,12 @@ async function answerToolCall(
101
132
  const args = isRecord(params['arguments']) ? params['arguments'] : {};
102
133
 
103
134
  try {
104
- const text = await runTool(deps.caller, name, args, deps.toolContext);
135
+ const result = await runTool(deps.caller, name, args, deps.toolContext);
105
136
 
106
- return { content: [{ type: 'text', text }] };
137
+ return {
138
+ content: [{ type: 'text', text: result.text }],
139
+ ...(result.structured === null ? {} : { structuredContent: result.structured }),
140
+ };
107
141
  } catch (error) {
108
142
  return { content: [{ type: 'text', text: formatToolError(error) }], isError: true };
109
143
  }
@@ -0,0 +1,15 @@
1
+ import type { FleetCaller } from './types';
2
+
3
+ /**
4
+ * The caller every request of one remote MCP client rides: each request acts
5
+ * as the client's principal, whatever principal it asked for, and goes only
6
+ * to a daemon that limits a request to its principal's targets, so a daemon
7
+ * that would ignore the principal never answers it with the owner's reach.
8
+ */
9
+ export function buildPrincipalCaller(caller: FleetCaller, principal: string): FleetCaller {
10
+ return {
11
+ sendRequest: (m, p, required = []) =>
12
+ caller.sendRequest(m, p, [...required, 'request.principal'], principal),
13
+ readFeatures: () => caller.readFeatures(),
14
+ };
15
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One agent as the spawn descriptions list it: its id and whether its binary
3
+ * resolves on the daemon's host.
4
+ */
5
+ export interface RegisteredAgent {
6
+ readonly id: string;
7
+ readonly installed: boolean;
8
+ }
9
+
10
+ interface SpawnDescriptions {
11
+ readonly tool: string;
12
+ readonly agent: string;
13
+ }
14
+
15
+ /**
16
+ * The `atc_session_spawn` description and its `agent` field description,
17
+ * naming the agents the daemon registered when the tool list was built. Only
18
+ * a registered id appears, and a registered agent whose binary is missing is
19
+ * marked not installed. null leaves every agent unnamed, for a tool list
20
+ * built while the daemon could not answer. Both point at `atc_agents_list`
21
+ * for the current list.
22
+ */
23
+ export function buildSpawnDescriptions(
24
+ agents: readonly RegisteredAgent[] | null,
25
+ ): SpawnDescriptions {
26
+ const roster =
27
+ agents === null
28
+ ? ''
29
+ : ` When this tool list was built, the host registered: ${formatRoster(agents)}.`;
30
+
31
+ return {
32
+ tool: `Spawn a new session in a directory. Optional agent is a registered agent id; omitted agent is always Claude, never the TUI last-used value.${roster} atc_agents_list returns the current agents, whether each is installed, and the model and effort each takes. An unregistered agent, a registered agent that is not installed, and a model or effort the agent does not take are refused before anything spawns. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.`,
33
+ agent: `Registered agent id to spawn; defaults to claude.${roster} atc_agents_list returns the current list.`,
34
+ };
35
+ }
36
+
37
+ function formatRoster(agents: readonly RegisteredAgent[]): string {
38
+ if (agents.length === 0) {
39
+ return 'no agents';
40
+ }
41
+
42
+ return agents
43
+ .map((agent) => (agent.installed ? agent.id : `${agent.id} (not installed)`))
44
+ .join(', ');
45
+ }
@@ -1,9 +1,14 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+ import { isRecord } from '../shared/report';
3
+ import { buildSpawnDescriptions } from './build-spawn-descriptions';
4
+ import type { RegisteredAgent } from './build-spawn-descriptions';
1
5
  import { MCP_TOOLS } from './mcp-tools';
2
6
 
3
7
  interface MCPTool {
4
8
  readonly name: string;
5
9
  readonly description: string;
6
10
  readonly inputSchema: Readonly<Record<string, unknown>>;
11
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
7
12
  readonly annotations: {
8
13
  readonly readOnlyHint: boolean;
9
14
  readonly destructiveHint: boolean;
@@ -11,11 +16,87 @@ interface MCPTool {
11
16
  };
12
17
  }
13
18
 
14
- export function buildToolList(): readonly MCPTool[] {
15
- return MCP_TOOLS.map((tool) => ({
16
- name: tool.name,
17
- description: tool.description,
18
- inputSchema: tool.inputSchema,
19
- annotations: tool.annotations,
20
- }));
19
+ /**
20
+ * The tools `tools/list` returns for a daemon announcing the given
21
+ * features. A tool the daemon cannot serve is left out, and a tool it serves
22
+ * in an older form is listed without the output schema and input properties
23
+ * that form lacks, so a client never sees an option the daemon would ignore.
24
+ * The spawn tool's description and its agent field's description name the
25
+ * registered agents; null leaves them unnamed. No schema depends on which
26
+ * agents the host registers or installs.
27
+ */
28
+ export function buildToolList(
29
+ features: ReadonlySet<DaemonFeature>,
30
+ agents: readonly RegisteredAgent[] | null,
31
+ ): readonly MCPTool[] {
32
+ const spawn = buildSpawnDescriptions(agents);
33
+
34
+ return MCP_TOOLS.flatMap((tool) => {
35
+ const requires = tool.requires ?? {};
36
+
37
+ if (requires.tool !== undefined && !features.has(requires.tool)) {
38
+ return [];
39
+ }
40
+
41
+ const gated = buildInputSchema(tool.inputSchema, requires.inputs ?? {}, features);
42
+ const isSpawn = tool.name === 'atc_session_spawn';
43
+ const description = isSpawn ? spawn.tool : tool.description;
44
+ const inputSchema = isSpawn ? buildAgentFieldSchema(gated, spawn.agent) : gated;
45
+
46
+ return [
47
+ tool.outputSchema === undefined ||
48
+ (requires.output !== undefined && !features.has(requires.output))
49
+ ? {
50
+ name: tool.name,
51
+ description,
52
+ inputSchema,
53
+ annotations: tool.annotations,
54
+ }
55
+ : {
56
+ name: tool.name,
57
+ description,
58
+ inputSchema,
59
+ outputSchema: tool.outputSchema,
60
+ annotations: tool.annotations,
61
+ },
62
+ ];
63
+ });
64
+ }
65
+
66
+ function buildInputSchema(
67
+ schema: Readonly<Record<string, unknown>>,
68
+ inputs: Readonly<Record<string, DaemonFeature>>,
69
+ features: ReadonlySet<DaemonFeature>,
70
+ ): Readonly<Record<string, unknown>> {
71
+ const withheld = Object.entries(inputs).flatMap(([name, feature]) =>
72
+ features.has(feature) ? [] : [name],
73
+ );
74
+
75
+ const properties = schema['properties'];
76
+
77
+ if (withheld.length === 0 || !isRecord(properties)) {
78
+ return schema;
79
+ }
80
+
81
+ return {
82
+ ...schema,
83
+ properties: Object.fromEntries(
84
+ Object.entries(properties).filter(([name]) => !withheld.includes(name)),
85
+ ),
86
+ };
87
+ }
88
+
89
+ // The input schema with only the agent field's description replaced.
90
+ function buildAgentFieldSchema(
91
+ schema: Readonly<Record<string, unknown>>,
92
+ description: string,
93
+ ): Readonly<Record<string, unknown>> {
94
+ const properties = schema['properties'];
95
+ const agent = isRecord(properties) ? properties['agent'] : undefined;
96
+
97
+ if (!isRecord(properties) || !isRecord(agent)) {
98
+ return schema;
99
+ }
100
+
101
+ return { ...schema, properties: { ...properties, agent: { ...agent, description } } };
21
102
  }