@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +371 -99
- package/src/daemon/daemon.ts +537 -90
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +287 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +40 -6
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- 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,
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
135
|
+
const result = await runTool(deps.caller, name, args, deps.toolContext);
|
|
105
136
|
|
|
106
|
-
return {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
}
|