@zgeoff/atc 2.11.0 → 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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { TargetConfigError } from '../shared/collect-targets';
3
+ import type { ExecutionTarget } from './build-execution-targets';
4
+
5
+ /**
6
+ * A short digest of the target config a daemon runs with: each target's id
7
+ * and identity, the default target, and the config errors. Two daemons with
8
+ * the same targets return the same revision, and any change to them
9
+ * changes it.
10
+ */
11
+ export function buildConfigRevision(
12
+ targets: readonly ExecutionTarget[],
13
+ defaultTarget: string | null,
14
+ errors: readonly TargetConfigError[],
15
+ ): string {
16
+ const canonical = JSON.stringify({
17
+ targets: targets.map((target) => [target.id, target.identity]),
18
+ defaultTarget,
19
+ errors: errors.map((error) =>
20
+ error.scope === 'config'
21
+ ? [error.scope, error.path, error.problem, error.detail]
22
+ : [error.scope, error.target ?? null, error.problem],
23
+ ),
24
+ });
25
+
26
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
27
+ }
@@ -0,0 +1,34 @@
1
+ import type { TargetConfig } from '../shared/collect-targets';
2
+ import { buildTargetIdentity } from './build-target-identity';
3
+ import type { ExecutionProvider } from './execution-provider';
4
+ import { LocalPTYProvider } from './local-pty-provider';
5
+
6
+ /**
7
+ * A configured place sessions run, with the provider that serves it. The
8
+ * provider is null when this daemon has no provider of the configured kind:
9
+ * the target still lists, and a spawn to it fails rather than running
10
+ * anywhere else.
11
+ */
12
+ export interface ExecutionTarget {
13
+ readonly id: string;
14
+ readonly kind: string;
15
+ readonly options: Readonly<Record<string, unknown>>;
16
+
17
+ // The identity a session spawned here binds to.
18
+ readonly identity: string;
19
+ readonly provider: ExecutionProvider | null;
20
+ }
21
+
22
+ /**
23
+ * Builds one target per configured entry, in config order, each with a
24
+ * provider of its kind when this daemon has one.
25
+ */
26
+ export function buildExecutionTargets(configs: readonly TargetConfig[]): ExecutionTarget[] {
27
+ return configs.map((config) => ({
28
+ id: config.id,
29
+ kind: config.provider,
30
+ options: config.options,
31
+ identity: buildTargetIdentity(config.provider, config.options),
32
+ provider: config.provider === 'local-pty' ? new LocalPTYProvider() : null,
33
+ }));
34
+ }
@@ -23,7 +23,8 @@ export function buildFleetEvents(
23
23
  sessions: readonly SessionDescriptor[],
24
24
  ): FleetEvent[] {
25
25
  return rows.map((row) => {
26
- // A restore re-mints atc ids, so the agent session id is the stable link.
26
+ // Rows written before atc session ids stayed stable across restores
27
+ // carry an earlier atc id, so the agent session id links them.
27
28
  const live =
28
29
  (row.agentSessionID === null
29
30
  ? undefined
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isRecord } from '../shared/report';
3
+
4
+ /**
5
+ * The SHA-256 of a request's params as canonical JSON: object keys sorted
6
+ * at every depth, with the idempotency key itself left out. Two requests
7
+ * that differ only in key order or in their idempotency key hash the same.
8
+ */
9
+ export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
10
+ const { idempotencyKey: _ignored, ...payload } = params;
11
+
12
+ return createHash('sha256')
13
+ .update(JSON.stringify(sortKeys(payload)))
14
+ .digest('hex');
15
+ }
16
+
17
+ function sortKeys(value: unknown): unknown {
18
+ if (Array.isArray(value)) {
19
+ return value.map((item) => sortKeys(item));
20
+ }
21
+
22
+ if (!isRecord(value)) {
23
+ return value;
24
+ }
25
+
26
+ return Object.fromEntries(
27
+ Object.keys(value)
28
+ .toSorted()
29
+ .map((key) => [key, sortKeys(value[key])]),
30
+ );
31
+ }
@@ -0,0 +1,232 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { SessionID } from '../shared/session-id';
3
+ import { toSessionID } from '../shared/to-session-id';
4
+ import { buildTargetForbiddenError } from './build-target-forbidden-error';
5
+ import { buildTargetIdentity } from './build-target-identity';
6
+ import type { DaemonContext } from './daemon-connection';
7
+ import type { KeyedRequest } from './idempotency-ledger';
8
+ import type { TargetAccess, TargetGrant } from './target-access';
9
+
10
+ /**
11
+ * The daemon as a principal with the given access sees it. Its keyed
12
+ * spawns and messages hold their keys in the given namespace, apart from
13
+ * every other namespace's. A session on a target outside the access does
14
+ * not exist here: every lookup of it answers as a lookup of an unknown
15
+ * session does, so the request's own handling refuses it with the words
16
+ * and data it gives a session that never existed. Lists leave such
17
+ * sessions out, and a spawn may use only a target the access holds, a
18
+ * spawn's replayed answer included. A kill, or a pin, of a session whose
19
+ * sub-sessions reach past the access is refused whole, before anything
20
+ * changes.
21
+ */
22
+ export function buildScopedContext(
23
+ ctx: DaemonContext,
24
+ access: TargetAccess,
25
+ keyNamespace: string,
26
+ ): DaemonContext {
27
+ // A keyed request's key, held in this namespace alone.
28
+ const buildPrincipalKey = (keyed: KeyedRequest | null): KeyedRequest | null =>
29
+ keyed === null ? null : { ...keyed, principal: keyNamespace };
30
+
31
+ const canSee = (id: SessionID): boolean => {
32
+ const grant = ctx.findSessionGrant(id);
33
+
34
+ return grant !== null && access.canUse(grant);
35
+ };
36
+
37
+ const canUseTarget = (target: string): boolean => {
38
+ const targetIdentity = ctx.findTargetIdentity(target);
39
+
40
+ return targetIdentity !== null && access.canUse({ target, targetIdentity });
41
+ };
42
+
43
+ // The target a session is bound to: the live session's, else its fleet
44
+ // row's, else null for a session the daemon holds nowhere.
45
+ const findGrant = async (id: SessionID): Promise<TargetGrant | null> => {
46
+ const live = ctx.findSessionGrant(id);
47
+
48
+ if (live !== null) {
49
+ return live;
50
+ }
51
+
52
+ const fleet = await ctx.collectFleet();
53
+
54
+ const entry = fleet.find((row) => row.sessionID === id);
55
+
56
+ if (entry === undefined) {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ target: entry.target ?? 'local',
62
+ targetIdentity: entry.targetIdentity ?? buildTargetIdentity('local-pty', {}),
63
+ };
64
+ };
65
+
66
+ // Throws the refusal a fresh spawn to the session's target gets when a
67
+ // held key's refusal carries a session out of reach.
68
+ const requireSessionInReach = async (id: SessionID): Promise<void> => {
69
+ const grant = await findGrant(id);
70
+
71
+ if (grant !== null && !access.canUse(grant)) {
72
+ throw buildTargetForbiddenError(grant.target);
73
+ }
74
+ };
75
+
76
+ // Throws the refusal of a change to a session whose sub-sessions the
77
+ // change would reach too when any of them is out of reach. The refusal
78
+ // holds only the session the caller sent, never a sub-session's id or
79
+ // how many there are.
80
+ const requireTreeInReach = (id: SessionID, action: string): void => {
81
+ if (ctx.collectChildIDs(id).every((child) => canSee(child))) {
82
+ return;
83
+ }
84
+
85
+ throw new DaemonError(
86
+ 'target_forbidden',
87
+ `this client may not ${action} session '${id}': it has a sub-session on a target this client may not use`,
88
+ { session: id },
89
+ );
90
+ };
91
+
92
+ return {
93
+ ...ctx,
94
+ collectSessions: () => ctx.collectSessions().filter((session) => canSee(session.id)),
95
+ collectAgents: () => {
96
+ const list = ctx.collectAgents();
97
+ const defaultTarget = list.spawnDefaults.target;
98
+
99
+ return {
100
+ ...list,
101
+ targets: list.targets.filter((target) => canUseTarget(target.id)),
102
+ spawnDefaults: {
103
+ ...list.spawnDefaults,
104
+ target: defaultTarget !== null && canUseTarget(defaultTarget) ? defaultTarget : null,
105
+ },
106
+
107
+ // The config file's own problem holds a path on the daemon's host,
108
+ // which is the owner's alone.
109
+ targetErrors: list.targetErrors.filter((error) => {
110
+ if (error.scope === 'config') {
111
+ return false;
112
+ }
113
+
114
+ return error.target === undefined || canUseTarget(error.target);
115
+ }),
116
+ };
117
+ },
118
+ collectFleet: async () => {
119
+ const fleet = await ctx.collectFleet();
120
+
121
+ return fleet.filter((entry) => canSee(entry.sessionID));
122
+ },
123
+ resolveSpawnTarget: (requested) => {
124
+ if (requested !== undefined) {
125
+ requireTarget(canUseTarget, requested);
126
+ }
127
+
128
+ const target = ctx.resolveSpawnTarget(requested);
129
+
130
+ requireTarget(canUseTarget, target);
131
+
132
+ return target;
133
+ },
134
+ spawnSession: async (plan, keyed) => {
135
+ let answer: Readonly<Record<string, unknown>>;
136
+
137
+ // A held key's replay is checked against the target its session was
138
+ // bound to, inside the spawn, before its answer leaves the daemon.
139
+ try {
140
+ answer = await ctx.spawnSession(plan, buildPrincipalKey(keyed), access);
141
+ } catch (error) {
142
+ // A held key's refusal may carry the session its spawn made.
143
+ if (error instanceof DaemonError && typeof error.data?.['effectRef'] === 'string') {
144
+ await requireSessionInReach(toSessionID(error.data['effectRef']));
145
+ }
146
+
147
+ throw error;
148
+ }
149
+
150
+ return answer;
151
+ },
152
+ killSession: (id) => {
153
+ if (!canSee(id)) {
154
+ return Promise.resolve(false);
155
+ }
156
+
157
+ requireTreeInReach(id, 'kill');
158
+
159
+ return ctx.killSession(id);
160
+ },
161
+ updateSession: (id, name, pinned) => {
162
+ if (!canSee(id)) {
163
+ return false;
164
+ }
165
+
166
+ if (pinned !== undefined) {
167
+ requireTreeInReach(id, 'pin');
168
+ }
169
+
170
+ return ctx.updateSession(id, name, pinned);
171
+ },
172
+ ackSession: (id) => canSee(id) && ctx.ackSession(id),
173
+ buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
174
+ readSessionScreen: (id) =>
175
+ canSee(id) ? ctx.readSessionScreen(id) : Promise.resolve('missing' as const),
176
+ answerPermission: (request, decision) => {
177
+ const owner = ctx.findPermissionSession(request);
178
+
179
+ return owner !== null && canSee(owner) ? ctx.answerPermission(request, decision) : 'unknown';
180
+ },
181
+ attachSession: (client, sessionID, dims) =>
182
+ canSee(sessionID) ? ctx.attachSession(client, sessionID, dims) : 'missing',
183
+ detachSession: (client, sessionID) => {
184
+ if (canSee(sessionID)) {
185
+ ctx.detachSession(client, sessionID);
186
+ }
187
+ },
188
+ writeSessionInput: (sessionID, data) =>
189
+ canSee(sessionID) ? ctx.writeSessionInput(sessionID, data) : 'missing',
190
+ ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
191
+ adoptSession: (id, cols, rows) => (canSee(id) ? ctx.adoptSession(id, cols, rows) : 'missing'),
192
+ resizeSession: (client, sessionID, dims) =>
193
+ canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
194
+ readSessionRecord: (id) =>
195
+ canSee(id) ? ctx.readSessionRecord(id) : Promise.resolve('missing' as const),
196
+ loadSessionTranscript: (id, from, limit) =>
197
+ canSee(id) ? ctx.loadSessionTranscript(id, from, limit) : Promise.resolve('missing' as const),
198
+ readEvents: (afterID, limit, waitMs, sessionID, outer) => {
199
+ const merged = outer === null ? access : outer.merge(access);
200
+
201
+ return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
202
+ },
203
+ writeSessionMessage: (sessionID, from, text, keyed) =>
204
+ canSee(sessionID)
205
+ ? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
206
+ : Promise.resolve('missing' as const),
207
+ readMessage: async (messageID, waitMs) => {
208
+ // The owner is checked before any wait, so a message outside the
209
+ // access answers at once, as an unknown message does.
210
+ const view = await ctx.readMessage(messageID, 0);
211
+
212
+ if (view === null || !canSee(view.session)) {
213
+ return null;
214
+ }
215
+
216
+ return waitMs === 0 ? view : ctx.readMessage(messageID, waitMs);
217
+ },
218
+ attachTap: (client, sessionID) =>
219
+ canSee(sessionID) ? ctx.attachTap(client, sessionID) : 'missing',
220
+ ackMessage: (client, sessionID, messageID) =>
221
+ canSee(sessionID)
222
+ ? ctx.ackMessage(client, sessionID, messageID)
223
+ : Promise.resolve('unknown' as const),
224
+ };
225
+ }
226
+
227
+ // Throws the refusal of a spawn to a target the access leaves out.
228
+ function requireTarget(canUseTarget: (target: string) => boolean, target: string): void {
229
+ if (!canUseTarget(target)) {
230
+ throw buildTargetForbiddenError(target);
231
+ }
232
+ }
@@ -0,0 +1,33 @@
1
+ import type { ExecutionTarget } from './build-execution-targets';
2
+ import { buildTargetIdentity } from './build-target-identity';
3
+ import { TargetAccess } from './target-access';
4
+
5
+ /**
6
+ * The targets a principal may use. With no principals in the config, every
7
+ * principal gets the implicit `local` target alone: the name `local` with
8
+ * the identity of a `local-pty` target without options, so a `local` that
9
+ * now holds another provider or other options is outside it. With
10
+ * principals, a principal gets each target its entry lists that the config
11
+ * holds, at that target's identity now, and an unknown principal gets none.
12
+ */
13
+ export function buildTargetAccess(
14
+ principals: ReadonlyMap<string, readonly string[]> | null,
15
+ targets: ReadonlyMap<string, ExecutionTarget>,
16
+ principal: string,
17
+ ): TargetAccess {
18
+ if (principals === null) {
19
+ return new TargetAccess([
20
+ { target: 'local', targetIdentity: buildTargetIdentity('local-pty', {}) },
21
+ ]);
22
+ }
23
+
24
+ const granted = principals.get(principal) ?? [];
25
+
26
+ return new TargetAccess(
27
+ granted.flatMap((id) => {
28
+ const target = targets.get(id);
29
+
30
+ return target === undefined ? [] : [{ target: id, targetIdentity: target.identity }];
31
+ }),
32
+ );
33
+ }
@@ -0,0 +1,13 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+
3
+ /**
4
+ * The refusal of a spawn to a target the principal may not use, which a
5
+ * replay of a held spawn key outside the principal's reach answers with too.
6
+ */
7
+ export function buildTargetForbiddenError(target: string): DaemonError {
8
+ return new DaemonError(
9
+ 'target_forbidden',
10
+ `this client may not use execution target '${target}'. Grant it to the client under principals in config.json and restart the daemon`,
11
+ { target },
12
+ );
13
+ }
@@ -0,0 +1,22 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sortJSONKeys } from '../shared/sort-json-keys';
3
+
4
+ /**
5
+ * The identity a session binds to when it spawns on a target: the provider
6
+ * kind, a colon, and the first 16 hex digits of a sha256 over the target's
7
+ * options with sorted keys. A target name reused with another provider or
8
+ * other options has another identity. Target options hold no credential
9
+ * values, only references such as an environment variable's name, so the
10
+ * identity never derives from a secret.
11
+ */
12
+ export function buildTargetIdentity(
13
+ kind: string,
14
+ options: Readonly<Record<string, unknown>>,
15
+ ): string {
16
+ const digest = createHash('sha256')
17
+ .update(JSON.stringify(sortJSONKeys(options)))
18
+ .digest('hex')
19
+ .slice(0, 16);
20
+
21
+ return `${kind}:${digest}`;
22
+ }
@@ -0,0 +1,50 @@
1
+ import type { ExecutionTarget } from './build-execution-targets';
2
+ import type { ExecutionCapabilities } from './execution-provider';
3
+
4
+ export interface TargetEntry {
5
+ readonly id: string;
6
+
7
+ // The provider kind the config selects for the target.
8
+ readonly provider: string;
9
+
10
+ // The identity a session spawned on the target binds to.
11
+ readonly identity: string;
12
+
13
+ // Whether this daemon has a provider of that kind, so a spawn can run there.
14
+ readonly available: boolean;
15
+ readonly default: boolean;
16
+ readonly capabilities: ExecutionCapabilities;
17
+ }
18
+
19
+ // What a target without a provider can do: nothing.
20
+ const NO_CAPABILITIES: ExecutionCapabilities = {
21
+ spawn: false,
22
+ attach: false,
23
+ input: false,
24
+ resize: false,
25
+ kill: false,
26
+ transfer: false,
27
+ run: false,
28
+ headless: false,
29
+ suspend: false,
30
+ destroy: false,
31
+ };
32
+
33
+ /**
34
+ * One `agents.list` target entry per configured target, in config order.
35
+ * An entry holds the target's id, provider kind, and capabilities, never its
36
+ * options, since those can hold a host's address or an account.
37
+ */
38
+ export function buildTargetList(
39
+ targets: readonly ExecutionTarget[],
40
+ defaultTarget: string | null,
41
+ ): TargetEntry[] {
42
+ return targets.map((target) => ({
43
+ id: target.id,
44
+ provider: target.kind,
45
+ identity: target.identity,
46
+ available: target.provider !== null,
47
+ default: target.id === defaultTarget,
48
+ capabilities: target.provider?.capabilities ?? NO_CAPABILITIES,
49
+ }));
50
+ }