@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
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { toAgentID } from '../agents/agent-adapter';
6
6
  import type { AgentID } from '../agents/agent-adapter';
7
+ import type { DaemonFeature } from '../protocol/daemon-features';
8
+ import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
7
9
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
8
10
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
11
  import { getBuild } from '../shared/get-build';
@@ -18,6 +20,9 @@ export interface DaemonBoot {
18
20
  readonly stale: boolean;
19
21
  readonly lastUsedAgent: AgentID;
20
22
 
23
+ // What the daemon announced it serves; an older daemon announces less.
24
+ readonly features: ReadonlySet<DaemonFeature>;
25
+
21
26
  // The socket the client reached, which differs from the computed one when
22
27
  // the daemon was found through its record in the state directory.
23
28
  readonly socketPath: string;
@@ -49,6 +54,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
49
54
  client,
50
55
  stale: hello['daemon'] !== build,
51
56
  lastUsedAgent: toAgentID(hello['lastUsedAgent']),
57
+ features: parseDaemonFeatures(hello),
52
58
  socketPath: opened.socketPath,
53
59
  };
54
60
  } catch (error) {
@@ -65,9 +65,12 @@ export class DaemonClient {
65
65
  return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
66
66
  }
67
67
 
68
+ // A request with a principal acts as that principal, within what the
69
+ // connection may reach.
68
70
  sendRequest(
69
71
  m: string,
70
72
  p?: Readonly<Record<string, unknown>>,
73
+ as?: string,
71
74
  ): Promise<Readonly<Record<string, unknown>>> {
72
75
  if (this.closedReason !== null) {
73
76
  return Promise.reject(new DaemonError('internal', this.closedReason));
@@ -77,7 +80,16 @@ export class DaemonClient {
77
80
  const resolvers = Promise.withResolvers<Readonly<Record<string, unknown>>>();
78
81
 
79
82
  this.pending.set(id, { resolve: resolvers.resolve, reject: resolvers.reject });
80
- this.queue?.send(encodeMessage({ v: PROTOCOL_V, id, m, ...(p === undefined ? {} : { p }) }));
83
+
84
+ this.queue?.send(
85
+ encodeMessage({
86
+ v: PROTOCOL_V,
87
+ id,
88
+ m,
89
+ ...(p === undefined ? {} : { p }),
90
+ ...(as === undefined ? {} : { as }),
91
+ }),
92
+ );
81
93
 
82
94
  return resolvers.promise;
83
95
  }
@@ -138,7 +150,7 @@ export class DaemonClient {
138
150
  if (msg.err === undefined) {
139
151
  waiter.resolve(msg.ok ?? {});
140
152
  } else {
141
- waiter.reject(new DaemonError(msg.err.code, msg.err.msg));
153
+ waiter.reject(new DaemonError(msg.err.code, msg.err.msg, msg.err.data));
142
154
  }
143
155
  }
144
156
 
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import type { AgentID } from '../agents/agent-adapter';
3
+ import { DaemonError } from '../protocol/daemon-error';
3
4
  import { loadConfig } from '../shared/config';
4
5
  import { collectAgentPicks } from './collect-agent-picks';
5
6
  import type { AgentPick } from './collect-agent-picks';
@@ -32,7 +33,8 @@ export interface SpawnPickerDeps<TMirror> {
32
33
  /**
33
34
  * The modal flow behind n and r: agent, then directory, then name, then an
34
35
  * optional first prompt. Every path out of it either attaches the new
35
- * session or returns the client to the screen it came from.
36
+ * session or returns the client to the screen it came from. A spawn the
37
+ * daemon refuses is no path out: the flow stays open and shows why.
36
38
  */
37
39
  export class SpawnPicker<TMirror extends { readonly id: string }> {
38
40
  private readonly deps: SpawnPickerDeps<TMirror>;
@@ -60,6 +62,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
60
62
 
61
63
  private agent: AgentID = 'claude';
62
64
 
65
+ // The daemon's refusal of the last spawn, shown in place of the hint until
66
+ // the next key.
67
+ private refusal: string | null = null;
68
+
63
69
  constructor(deps: SpawnPickerDeps<TMirror>) {
64
70
  this.deps = deps;
65
71
  }
@@ -82,12 +88,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
82
88
 
83
89
  this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
84
90
  this.step = 'agent';
91
+ this.refusal = null;
85
92
 
86
93
  process.stdout.write(ansi.clear);
87
94
  this.render();
88
95
  }
89
96
 
90
97
  applyKey(buf: Buffer) {
98
+ this.refusal = null;
99
+
91
100
  const edit = planTextEdit(buf, this.input, {
92
101
  isLeaderKey: this.deps.isLeaderKey,
93
102
  moves: this.step === 'agent' || this.step === 'dir',
@@ -168,7 +177,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
168
177
  selected: -1,
169
178
  input: this.input,
170
179
  placeholder: formatDirName(this.dir),
171
- hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
180
+ hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
172
181
  });
173
182
  } else {
174
183
  drawPicker({
@@ -177,7 +186,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
177
186
  selected: -1,
178
187
  input: this.input,
179
188
  placeholder: 'optional — ⏎ to start interactive',
180
- hint: 'first message for the session · ⏎ spawn · esc back',
189
+ hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
181
190
  });
182
191
  }
183
192
 
@@ -320,7 +329,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
320
329
 
321
330
  return;
322
331
  }
323
- } catch {}
332
+ } catch (error) {
333
+ // A refused spawn keeps the picker on the step that sent it, with the
334
+ // entered text back in the input, so the user can fix the cause and
335
+ // retry or back out.
336
+ const reason =
337
+ error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
338
+
339
+ this.refusal = `${reason} · ⏎ retry · esc back`;
340
+ this.input = this.resume ? this.name : prompt;
341
+
342
+ process.stdout.write(ansi.clear);
343
+ this.render();
344
+
345
+ return;
346
+ }
324
347
 
325
348
  this.deps.toBase();
326
349
  }
@@ -0,0 +1,94 @@
1
+ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
+
3
+ interface AgentCapabilities {
4
+ // Only an installed agent can start a session.
5
+ readonly spawn: boolean;
6
+ readonly readTranscript: boolean;
7
+
8
+ // Whether a session takes inbox messages through a tap.
9
+ readonly message: boolean;
10
+ readonly attach: boolean;
11
+ readonly screen: boolean;
12
+ readonly input: boolean;
13
+ }
14
+
15
+ export interface AgentEntry {
16
+ readonly id: string;
17
+ readonly label: string;
18
+ readonly kind: string;
19
+
20
+ // Whether the agent's binary resolves, on PATH or at its configured path.
21
+ readonly installed: boolean;
22
+ readonly capabilities: AgentCapabilities;
23
+ readonly models: Readonly<Record<string, string>> | null;
24
+ readonly spawnOptions: SpawnOptionEntries;
25
+ }
26
+
27
+ // A spawn option as `agents.list` shows it: the agent's own spec, plus
28
+ // whether a spawn on this host can pass it now. Only an installed agent's
29
+ // supported option is available.
30
+ export interface SpawnOptionEntry extends SpawnOptionSpec {
31
+ readonly available: boolean;
32
+ }
33
+
34
+ interface SpawnOptionEntries {
35
+ readonly model: SpawnOptionEntry;
36
+ readonly effort: SpawnOptionEntry;
37
+ }
38
+
39
+ /**
40
+ * One `agents.list` entry per registered adapter, in registration order. An
41
+ * entry carries only the adapter's profile and what its interface offers,
42
+ * so no environment value, credential, helper command, or base URL reaches
43
+ * it. Every session runs in a PTY, so each agent can be attached, read as a
44
+ * screen, and typed into. A stand-in adapter without a profile is listed
45
+ * under its id as both label and kind.
46
+ */
47
+ export function buildAgentList(
48
+ adapters: readonly AgentAdapter[],
49
+ isInstalled: (bin: string) => boolean,
50
+ ): AgentEntry[] {
51
+ return adapters.map((adapter) => {
52
+ const profile = adapter.profile;
53
+ const installed = profile === undefined ? false : isInstalled(profile.bin);
54
+
55
+ return {
56
+ id: adapter.id,
57
+ label: profile?.label ?? adapter.id,
58
+ kind: profile?.kind ?? adapter.id,
59
+ installed,
60
+ capabilities: {
61
+ spawn: installed,
62
+ readTranscript: adapter.parseTranscriptLine !== undefined,
63
+ message: adapter.takesMessages,
64
+ attach: true,
65
+ screen: true,
66
+ input: true,
67
+ },
68
+ models: profile?.models ?? null,
69
+ spawnOptions: {
70
+ model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
71
+ effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
72
+ },
73
+ };
74
+ });
75
+ }
76
+
77
+ // A stand-in adapter declares no spawn options, so it takes none.
78
+ const NO_SPAWN_OPTION: SpawnOptionSpec = {
79
+ supported: false,
80
+ values: null,
81
+ examples: [],
82
+ default: null,
83
+ backendEffect: null,
84
+ note: null,
85
+ };
86
+
87
+ function buildSpawnOptionEntry(
88
+ spec: SpawnOptionSpec | undefined,
89
+ installed: boolean,
90
+ ): SpawnOptionEntry {
91
+ const resolved = spec ?? NO_SPAWN_OPTION;
92
+
93
+ return { ...resolved, available: installed && resolved.supported };
94
+ }
@@ -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
+ }