@zgeoff/atc 2.11.0 → 2.13.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 +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -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 +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- 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 +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -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/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -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
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -10,8 +10,9 @@ import { truncateSummary } from './truncate-summary';
|
|
|
10
10
|
* runner, wires the run's output and completion into the session manager,
|
|
11
11
|
* passes the turn's start and end to the event trail the way a terminal
|
|
12
12
|
* turn's hooks do, and records the live handle on the session's runtime. Returns false
|
|
13
|
-
* without starting anything when the session is unknown, its
|
|
14
|
-
* headless runner,
|
|
13
|
+
* without starting anything when the session is unknown or exited, its
|
|
14
|
+
* agent has no headless runner, its target does not serve headless turns,
|
|
15
|
+
* or a turn is already running.
|
|
15
16
|
*/
|
|
16
17
|
export function startHeadlessTurn(
|
|
17
18
|
mgr: SessionManager,
|
|
@@ -28,6 +29,12 @@ export function startHeadlessTurn(
|
|
|
28
29
|
return false;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// The runner runs on the daemon's own host, so a session whose target
|
|
33
|
+
// does not serve headless turns never reaches it.
|
|
34
|
+
if (s.state === 'exited' || mgr.findExecutionRefusal(s, 'headless') !== null) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
|
|
32
39
|
|
|
33
40
|
recordTurnEvent(sessionID, { kind: 'prompt-submitted', detail: truncateDetail(prompt) });
|
|
@@ -37,8 +44,10 @@ export function startHeadlessTurn(
|
|
|
37
44
|
cwd: s.cwd,
|
|
38
45
|
prompt,
|
|
39
46
|
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
40
|
-
permissionMode: 'auto',
|
|
41
47
|
sessionID,
|
|
48
|
+
...(s.model === undefined ? {} : { model: s.model }),
|
|
49
|
+
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
50
|
+
...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
|
|
42
51
|
},
|
|
43
52
|
{
|
|
44
53
|
onOutput: (text) => {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
3
|
+
import type { MessageID } from '../shared/message-id';
|
|
4
|
+
import type { SessionID } from '../shared/session-id';
|
|
5
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
6
|
+
import type { MessageRecord } from '../store/message-record';
|
|
7
|
+
import type { TapClient } from './daemon-connection';
|
|
8
|
+
import type { HarnessRelay } from './execution-provider';
|
|
9
|
+
import type { HookEvent } from './hooks';
|
|
10
|
+
import { isBindingCurrent } from './is-binding-current';
|
|
11
|
+
import type { BridgeBinding } from './is-binding-current';
|
|
12
|
+
import { parseHookLine } from './parse-hook-line';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The fields of a live session the bridge checks its binding against and
|
|
16
|
+
* reads the status from.
|
|
17
|
+
*/
|
|
18
|
+
export interface BridgeSession {
|
|
19
|
+
readonly id: SessionID;
|
|
20
|
+
readonly target: string;
|
|
21
|
+
readonly targetIdentity: string;
|
|
22
|
+
readonly hostKey: SessionID;
|
|
23
|
+
readonly bridgeEpoch: number;
|
|
24
|
+
readonly state: string;
|
|
25
|
+
readonly lastMsg: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the bridge needs from the daemon. Each operation acts on the one
|
|
30
|
+
* session the caller names, which is always the bridge's own.
|
|
31
|
+
*/
|
|
32
|
+
export interface BridgeContext {
|
|
33
|
+
readonly findSession: (sessionID: SessionID) => BridgeSession | undefined;
|
|
34
|
+
readonly applyHookEvent: (e: HookEvent) => void;
|
|
35
|
+
|
|
36
|
+
// Applies a report under the id its reporter gave it, and settles once
|
|
37
|
+
// the report is recorded; false when the payload is no report.
|
|
38
|
+
readonly applyReport: (
|
|
39
|
+
sessionID: SessionID,
|
|
40
|
+
payload: Readonly<Record<string, unknown>>,
|
|
41
|
+
reportID: string,
|
|
42
|
+
) => Promise<boolean>;
|
|
43
|
+
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
44
|
+
readonly ackMessage: (
|
|
45
|
+
client: TapClient,
|
|
46
|
+
sessionID: SessionID,
|
|
47
|
+
messageID: MessageID,
|
|
48
|
+
) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
|
|
49
|
+
readonly detachTap: (client: TapClient) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const REQUEST_SCHEMA = z.looseObject({ v: z.literal(1), id: z.string().min(1), op: z.string() });
|
|
53
|
+
|
|
54
|
+
const REPORT_SCHEMA = z.looseObject({
|
|
55
|
+
reportID: z.string().min(1).max(128),
|
|
56
|
+
payload: z.record(z.string(), z.unknown()),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const ACK_SCHEMA = z.looseObject({ message: z.string().min(1) });
|
|
60
|
+
|
|
61
|
+
type BridgeRefusal = 'forbidden' | 'stale_binding' | 'not_tapping' | 'unknown_message';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serves one connection a remote harness's processes opened to the daemon:
|
|
65
|
+
* the session bridge. A line without an `op` is a hook line, as the local
|
|
66
|
+
* reporter socket takes it. A request line `{ v: 1, id, op }` gets one
|
|
67
|
+
* answer line `{ id, ok }`, and the bridge's own inbox events go out as
|
|
68
|
+
* protocol event lines.
|
|
69
|
+
*
|
|
70
|
+
* The bridge takes hooks, reports, its session's tap and acks, and its
|
|
71
|
+
* session's own status, for the one session it was bound to and nothing
|
|
72
|
+
* else: an unknown op, a malformed request, or a report or hook for any
|
|
73
|
+
* other session answers `forbidden` and closes the connection. Every line
|
|
74
|
+
* checks the binding against the live session first, and one that no
|
|
75
|
+
* longer matches answers `stale_binding` and closes.
|
|
76
|
+
*
|
|
77
|
+
* Trust: the binding holds no secret. A host serves the bridge's socket
|
|
78
|
+
* inside itself alone, so reaching the socket is the proof of being inside
|
|
79
|
+
* that host. One host is one trust domain: a sub-session in its parent's
|
|
80
|
+
* host runs as the same user and can reach the parent's socket too. The
|
|
81
|
+
* per-session socket and binding stop processes in the host from crossing
|
|
82
|
+
* sessions by accident, never a hostile process inside the same host.
|
|
83
|
+
*/
|
|
84
|
+
export function startSessionBridge(
|
|
85
|
+
relay: HarnessRelay,
|
|
86
|
+
binding: Readonly<BridgeBinding>,
|
|
87
|
+
ctx: BridgeContext,
|
|
88
|
+
): void {
|
|
89
|
+
let closed = false;
|
|
90
|
+
let writing: Promise<void> = Promise.resolve();
|
|
91
|
+
|
|
92
|
+
// Lines go out one at a time, in the order they were written, and none
|
|
93
|
+
// after the bridge closed.
|
|
94
|
+
const writeLine = async (value: Readonly<Record<string, unknown>>) => {
|
|
95
|
+
const line = JSON.stringify(value);
|
|
96
|
+
const previous = writing;
|
|
97
|
+
|
|
98
|
+
writing = (async () => {
|
|
99
|
+
await previous;
|
|
100
|
+
|
|
101
|
+
if (!closed) {
|
|
102
|
+
try {
|
|
103
|
+
await relay.writeLine(line);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
await writing;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const stopBridge = () => {
|
|
112
|
+
if (closed) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
closed = true;
|
|
117
|
+
|
|
118
|
+
ctx.detachTap(tap);
|
|
119
|
+
relay.close();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Answers a refusal, then closes once the answer has gone out.
|
|
123
|
+
const answerRefusal = async (id: string | null, code: BridgeRefusal) => {
|
|
124
|
+
await writeLine({ id, ok: false, code });
|
|
125
|
+
|
|
126
|
+
stopBridge();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const tap: TapClient = {
|
|
130
|
+
sendEvent: (event: EventMsg) => {
|
|
131
|
+
void (async () => {
|
|
132
|
+
await writeLine(event);
|
|
133
|
+
|
|
134
|
+
if (event.ev === 'InboxClosed') {
|
|
135
|
+
stopBridge();
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const isCurrent = () => isBindingCurrent(binding, ctx.findSession(binding.sessionID));
|
|
142
|
+
|
|
143
|
+
const applyRequest = async (
|
|
144
|
+
request: Readonly<Record<string, unknown>>,
|
|
145
|
+
id: string,
|
|
146
|
+
op: string,
|
|
147
|
+
) => {
|
|
148
|
+
if (op === 'report') {
|
|
149
|
+
const parsed = REPORT_SCHEMA.safeParse(request);
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
!parsed.success ||
|
|
153
|
+
!(await ctx.applyReport(binding.sessionID, parsed.data.payload, parsed.data.reportID))
|
|
154
|
+
) {
|
|
155
|
+
void answerRefusal(id, 'forbidden');
|
|
156
|
+
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
void writeLine({ id, ok: true });
|
|
161
|
+
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (op === 'tap.open') {
|
|
166
|
+
if (ctx.attachTap(tap, binding.sessionID) !== 'ok') {
|
|
167
|
+
void answerRefusal(id, 'forbidden');
|
|
168
|
+
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
void writeLine({ id, ok: true });
|
|
173
|
+
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (op === 'tap.ack') {
|
|
178
|
+
const parsed = ACK_SCHEMA.safeParse(request);
|
|
179
|
+
|
|
180
|
+
if (!parsed.success) {
|
|
181
|
+
void answerRefusal(id, 'forbidden');
|
|
182
|
+
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const acked = await ctx.ackMessage(tap, binding.sessionID, toMessageID(parsed.data.message));
|
|
187
|
+
|
|
188
|
+
if (acked === 'not_tapping' || acked === 'unknown') {
|
|
189
|
+
const code: BridgeRefusal = acked === 'unknown' ? 'unknown_message' : 'not_tapping';
|
|
190
|
+
|
|
191
|
+
void writeLine({ id, ok: false, code });
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
void writeLine({ id, ok: true, status: acked.status });
|
|
197
|
+
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (op === 'status.read') {
|
|
202
|
+
const s = ctx.findSession(binding.sessionID);
|
|
203
|
+
|
|
204
|
+
void writeLine({ id, ok: true, state: s?.state ?? 'exited', lastMsg: s?.lastMsg ?? '' });
|
|
205
|
+
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
void answerRefusal(id, 'forbidden');
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Lines are applied one at a time, in the order they arrived.
|
|
213
|
+
let applying: Promise<void> = Promise.resolve();
|
|
214
|
+
|
|
215
|
+
const applyLine = async (line: string) => {
|
|
216
|
+
if (closed) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let parsed: unknown;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
parsed = JSON.parse(line);
|
|
224
|
+
} catch {
|
|
225
|
+
void answerRefusal(null, 'forbidden');
|
|
226
|
+
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
231
|
+
void answerRefusal(null, 'forbidden');
|
|
232
|
+
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const record: Readonly<Record<string, unknown>> = Object.fromEntries(Object.entries(parsed));
|
|
237
|
+
|
|
238
|
+
if (!('op' in record)) {
|
|
239
|
+
const e = parseHookLine(line);
|
|
240
|
+
|
|
241
|
+
if (!isCurrent()) {
|
|
242
|
+
void answerRefusal(null, 'stale_binding');
|
|
243
|
+
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (e === null || e.atcId !== binding.sessionID) {
|
|
248
|
+
void answerRefusal(null, 'forbidden');
|
|
249
|
+
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
ctx.applyHookEvent(e);
|
|
254
|
+
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const request = REQUEST_SCHEMA.safeParse(record);
|
|
259
|
+
|
|
260
|
+
if (!request.success) {
|
|
261
|
+
const id = typeof record['id'] === 'string' ? record['id'] : null;
|
|
262
|
+
|
|
263
|
+
void answerRefusal(id, 'forbidden');
|
|
264
|
+
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!isCurrent()) {
|
|
269
|
+
void answerRefusal(request.data.id, 'stale_binding');
|
|
270
|
+
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await applyRequest(record, request.data.id, request.data.op);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
relay.onLine((line) => {
|
|
278
|
+
const previous = applying;
|
|
279
|
+
|
|
280
|
+
applying = (async () => {
|
|
281
|
+
await previous;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await applyLine(line);
|
|
285
|
+
} catch {}
|
|
286
|
+
})();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
relay.onClose(() => {
|
|
290
|
+
closed = true;
|
|
291
|
+
|
|
292
|
+
ctx.detachTap(tap);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -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';
|
|
@@ -60,9 +62,11 @@ export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise
|
|
|
60
62
|
.with('tools/list', async () => {
|
|
61
63
|
const features = await deps.caller.readFeatures();
|
|
62
64
|
|
|
65
|
+
const agents = features.has('agents.list') ? await tryReadAgents(deps) : null;
|
|
66
|
+
|
|
63
67
|
return {
|
|
64
68
|
kind: 'reply' as const,
|
|
65
|
-
body: buildRPCResult(id, { tools: buildToolList(features) }),
|
|
69
|
+
body: buildRPCResult(id, { tools: buildToolList(features, agents) }),
|
|
66
70
|
};
|
|
67
71
|
})
|
|
68
72
|
.with('tools/call', async () => {
|
|
@@ -97,6 +101,29 @@ function findMissingScope(
|
|
|
97
101
|
return scopes.includes(needed) ? null : needed;
|
|
98
102
|
}
|
|
99
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
|
+
|
|
100
127
|
async function answerToolCall(
|
|
101
128
|
deps: RPCDeps,
|
|
102
129
|
params: Readonly<Record<string, unknown>>,
|
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
2
|
import { isRecord } from '../shared/report';
|
|
3
|
+
import { buildSpawnDescriptions } from './build-spawn-descriptions';
|
|
4
|
+
import type { RegisteredAgent } from './build-spawn-descriptions';
|
|
3
5
|
import { MCP_TOOLS } from './mcp-tools';
|
|
4
6
|
|
|
5
7
|
interface MCPTool {
|
|
@@ -19,8 +21,16 @@ interface MCPTool {
|
|
|
19
21
|
* features. A tool the daemon cannot serve is left out, and a tool it serves
|
|
20
22
|
* in an older form is listed without the output schema and input properties
|
|
21
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.
|
|
22
27
|
*/
|
|
23
|
-
export function buildToolList(
|
|
28
|
+
export function buildToolList(
|
|
29
|
+
features: ReadonlySet<DaemonFeature>,
|
|
30
|
+
agents: readonly RegisteredAgent[] | null,
|
|
31
|
+
): readonly MCPTool[] {
|
|
32
|
+
const spawn = buildSpawnDescriptions(agents);
|
|
33
|
+
|
|
24
34
|
return MCP_TOOLS.flatMap((tool) => {
|
|
25
35
|
const requires = tool.requires ?? {};
|
|
26
36
|
|
|
@@ -28,20 +38,23 @@ export function buildToolList(features: ReadonlySet<DaemonFeature>): readonly MC
|
|
|
28
38
|
return [];
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
const
|
|
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;
|
|
32
45
|
|
|
33
46
|
return [
|
|
34
47
|
tool.outputSchema === undefined ||
|
|
35
48
|
(requires.output !== undefined && !features.has(requires.output))
|
|
36
49
|
? {
|
|
37
50
|
name: tool.name,
|
|
38
|
-
description
|
|
51
|
+
description,
|
|
39
52
|
inputSchema,
|
|
40
53
|
annotations: tool.annotations,
|
|
41
54
|
}
|
|
42
55
|
: {
|
|
43
56
|
name: tool.name,
|
|
44
|
-
description
|
|
57
|
+
description,
|
|
45
58
|
inputSchema,
|
|
46
59
|
outputSchema: tool.outputSchema,
|
|
47
60
|
annotations: tool.annotations,
|
|
@@ -72,3 +85,18 @@ function buildInputSchema(
|
|
|
72
85
|
),
|
|
73
86
|
};
|
|
74
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 } } };
|
|
102
|
+
}
|