@zgeoff/atc 3.4.2 → 3.6.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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/agents/gateway-adapter.ts +38 -20
  6. package/src/build-migrated-config.ts +1 -0
  7. package/src/client/daemon-client.ts +6 -1
  8. package/src/collect-restart-env.ts +6 -3
  9. package/src/daemon/build-scoped-context.ts +2 -0
  10. package/src/daemon/check-session-scope.ts +235 -0
  11. package/src/daemon/daemon-connection.ts +74 -0
  12. package/src/daemon/daemon-context.ts +21 -0
  13. package/src/daemon/daemon.ts +21 -0
  14. package/src/daemon/find-host-branch.ts +28 -0
  15. package/src/daemon/materialize-workspace.ts +6 -1
  16. package/src/daemon/merge-recorded-scope.ts +50 -0
  17. package/src/daemon/published-records.ts +269 -0
  18. package/src/daemon/request-access-classes.ts +1 -0
  19. package/src/daemon/run-host-git.ts +52 -0
  20. package/src/daemon/sessions.ts +185 -53
  21. package/src/daemon/start-headless-turn.ts +3 -0
  22. package/src/federation/id-rules.ts +11 -2
  23. package/src/mcp/mcp-tools.ts +50 -0
  24. package/src/mcp/require-daemon-features.ts +1 -0
  25. package/src/mcp/run-tool.ts +14 -0
  26. package/src/protocol/daemon-features.ts +5 -0
  27. package/src/protocol/parse-declared-scope.ts +158 -0
  28. package/src/protocol/protocol.ts +1 -0
  29. package/src/protocol/published-record.ts +62 -0
  30. package/src/protocol/request-param-schemas.ts +16 -0
  31. package/src/shared/check-gateway-auth.ts +16 -10
  32. package/src/shared/collect-agents.ts +0 -1
  33. package/src/shared/collect-gateways.ts +4 -2
  34. package/src/store/parse-published-record.ts +59 -0
  35. package/src/store/run-migrations.ts +23 -0
  36. package/src/store/state-store.ts +32 -0
  37. package/src/test-utils/build-mock-published-record.ts +39 -0
  38. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  39. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  40. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  41. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  42. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  43. package/src/test-utils/build-stub-host-hold.ts +7 -6
  44. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  45. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  46. package/src/test-utils/build-stub-terminal.ts +3 -3
  47. package/src/test-utils/create-git-fixture.ts +2 -2
  48. package/src/test-utils/create-stub-imp-port.ts +37 -37
  49. package/src/test-utils/create-stub-systemd.ts +6 -6
  50. package/src/test-utils/isolate-home.ts +8 -5
  51. package/src/test-utils/read-query-plan.ts +10 -6
  52. package/src/test-utils/register-test-cleanup.ts +10 -10
  53. package/src/test-utils/setup-mcp-home.ts +7 -6
  54. package/src/test-utils/setup-mcp-http.ts +6 -6
  55. package/src/test-utils/setup-temp-dir.ts +4 -10
  56. package/src/test-utils/start-cut-proxy.ts +2 -4
  57. package/src/test-utils/start-daemon-process.ts +13 -13
  58. package/src/test-utils/start-git-http-server.ts +2 -4
  59. package/src/test-utils/start-mcp-stdio.ts +5 -5
  60. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  61. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  62. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  63. package/src/test-utils/start-stub-impd-info.ts +3 -7
  64. package/src/test-utils/start-stub-impd.ts +3 -5
  65. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  66. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  67. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  68. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  69. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  70. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  71. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  72. package/src/test-utils/start-stub-silent-server.ts +3 -6
  73. package/src/test-utils/start-stub-source-daemon.ts +2 -2
  74. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  75. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  76. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  77. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  78. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  79. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  80. package/src/test-utils/start-test-daemon.ts +62 -18
  81. package/src/test-utils/start-tui-harness.ts +17 -10
  82. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  83. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.4.2",
3
+ "version": "3.6.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -169,6 +169,10 @@ export interface HeadlessRunRequest {
169
169
  // Environment variable names the run's process goes without, such as the
170
170
  // credential a session's workspace was cloned with.
171
171
  readonly withheldEnv?: readonly string[];
172
+
173
+ // Where the session's published record lies, which the run's process
174
+ // reads from ATC_SESSION_RECORD.
175
+ readonly recordPath?: string;
172
176
  }
173
177
 
174
178
  export interface HeadlessRunEvents {
@@ -55,6 +55,7 @@ export function buildClaudeQueryOptions(
55
55
  ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
56
56
  ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
57
57
  ...(opts.withheldEnv === undefined ? {} : { withheldEnv: opts.withheldEnv }),
58
+ ...(opts.recordPath === undefined ? {} : { recordPath: opts.recordPath }),
58
59
  }),
59
60
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
60
61
  ...(mode === undefined ? {} : { permissionMode: mode }),
@@ -8,6 +8,9 @@ interface HeadlessEnvParams {
8
8
 
9
9
  // Variables the run goes without, such as a workspace credential.
10
10
  readonly withheldEnv?: readonly string[];
11
+
12
+ // Where the session's published record lies.
13
+ readonly recordPath?: string;
11
14
  }
12
15
 
13
16
  /**
@@ -23,6 +26,7 @@ export function buildHeadlessEnv(params: HeadlessEnvParams): Record<string, stri
23
26
  ...(params.sessionID === undefined
24
27
  ? {}
25
28
  : { ATC_SESSION_ID: params.sessionID, ATC_SOCKET: params.socketPath }),
29
+ ...(params.recordPath === undefined ? {} : { ATC_SESSION_RECORD: params.recordPath }),
26
30
  },
27
31
  params.withheldEnv,
28
32
  );
@@ -58,7 +58,7 @@ export class GatewayAdapter implements AgentAdapter {
58
58
 
59
59
  // A headless turn carries the same settings file the terminal spawn does,
60
60
  // so it reaches this backend rather than the default one. A gateway whose
61
- // credential comes through impd's broker runs no headless turn.
61
+ // credential comes through impd's broker alone runs no headless turn.
62
62
  readonly headlessRunner: HeadlessRunner | null;
63
63
 
64
64
  // The CLI's hooks are authoritative; no screen heuristics needed.
@@ -132,7 +132,7 @@ export class GatewayAdapter implements AgentAdapter {
132
132
  );
133
133
 
134
134
  this.headlessRunner =
135
- headlessRun === null || gateway.auth !== undefined
135
+ headlessRun === null || isBrokerOnlyGateway(gateway)
136
136
  ? null
137
137
  : makeClaudeHeadlessRunner(headlessRun, {
138
138
  claudeBin: gateway.bin,
@@ -169,15 +169,16 @@ export class GatewayAdapter implements AgentAdapter {
169
169
  return {
170
170
  gateway: { id: this.gateway.id, baseURL: this.gateway.baseURL, auth },
171
171
  profiles: this.config.authProfiles,
172
- brokerRequired: true,
172
+ brokerRequired: isBrokerOnlyGateway(this.gateway),
173
173
  };
174
174
  }
175
175
 
176
- // A gateway whose credential comes through impd's broker never starts on
177
- // the daemon's machine: started without the broker, the CLI would send
178
- // whatever credential it holds to the gateway's host.
176
+ // A gateway whose credential comes through impd's broker alone never
177
+ // starts on the daemon's machine: started without the broker, the CLI
178
+ // would send whatever credential it holds to the gateway's host. One
179
+ // with a credential helper as well starts there under that helper.
179
180
  planSpawn(opts: SpawnOptions): SpawnPlan {
180
- if (this.gateway.auth !== undefined) {
181
+ if (isBrokerOnlyGateway(this.gateway)) {
181
182
  throw this.buildBrokerRefusal('which only an imp target can give it');
182
183
  }
183
184
 
@@ -186,7 +187,13 @@ export class GatewayAdapter implements AgentAdapter {
186
187
 
187
188
  return {
188
189
  bin: this.gateway.bin,
189
- args: this.buildArgs(opts, modeArgs, this.writeSettings(), this.writeBridge()),
190
+ args: this.buildArgs(
191
+ this.gateway.args,
192
+ opts,
193
+ modeArgs,
194
+ this.writeSettings(),
195
+ this.writeBridge(),
196
+ ),
190
197
  };
191
198
  }
192
199
 
@@ -196,8 +203,9 @@ export class GatewayAdapter implements AgentAdapter {
196
203
  // and placeholders in place of the credential, which the broker swaps
197
204
  // for the real one on the host's side. A shell seeds the config folder
198
205
  // before it runs the CLI, since a transferred file would replace the
199
- // state an earlier run left. Any other gateway's credential
200
- // helper runs on the daemon's machine, so it never runs remotely.
206
+ // state an earlier run left. The launch takes the auth's own arguments
207
+ // when it sets them, and never the credential helper, which runs on the
208
+ // daemon's machine alone. A gateway without auth never runs remotely.
201
209
  planGuestSpawn(opts: SpawnOptions, guest: GuestPaths): GuestSpawnPlan | null {
202
210
  if (this.gateway.auth === undefined) {
203
211
  return null;
@@ -244,11 +252,14 @@ export class GatewayAdapter implements AgentAdapter {
244
252
  argv,
245
253
  );
246
254
 
255
+ const args = this.gateway.auth.args ?? this.gateway.args;
256
+
247
257
  const launch = buildClaudeGuestLaunch(guest.dir, [
248
258
  this.gateway.bin,
249
259
  ...this.buildArgs(
260
+ args,
250
261
  opts,
251
- this.buildGuestModeArgs(),
262
+ this.buildGuestModeArgs(args),
252
263
  `${guest.dir}/${settingsPath}`,
253
264
  `${guest.dir}/atc-bridge`,
254
265
  ),
@@ -291,10 +302,10 @@ export class GatewayAdapter implements AgentAdapter {
291
302
  // explicit flag, so that mode overrides the one the CLI would restore, and
292
303
  // the generated settings file, because
293
304
  // without it the CLI would resume the session against the default backend.
294
- // A gateway whose credential comes through impd's broker has none, since
295
- // outside atc the broker never reaches it.
305
+ // A gateway whose credential comes through impd's broker alone has none,
306
+ // since outside atc the broker never reaches it.
296
307
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
297
- if (this.gateway.auth !== undefined) {
308
+ if (isBrokerOnlyGateway(this.gateway)) {
298
309
  return null;
299
310
  }
300
311
 
@@ -312,13 +323,14 @@ export class GatewayAdapter implements AgentAdapter {
312
323
  }
313
324
 
314
325
  private buildArgs(
326
+ args: readonly string[],
315
327
  opts: SpawnOptions,
316
328
  modeArgs: readonly string[],
317
329
  settings: string,
318
330
  pluginDir: string,
319
331
  ): string[] {
320
332
  return [
321
- ...buildClaudeOverrideArgs(this.gateway.args, opts),
333
+ ...buildClaudeOverrideArgs(args, opts),
322
334
  ...modeArgs,
323
335
  '--settings',
324
336
  settings,
@@ -332,11 +344,11 @@ export class GatewayAdapter implements AgentAdapter {
332
344
 
333
345
  // A brokered session's Claude config is fresh, so the CLI's own default
334
346
  // mode would apply rather than the one the owner's settings set. Every
335
- // start therefore names its mode: the one the gateway's arguments or
336
- // settings set, else the CLI's manual mode, which asks before each
337
- // action.
338
- private buildGuestModeArgs(): string[] {
339
- if (findFlagValue(this.gateway.args, ['--permission-mode']) !== null) {
347
+ // start therefore names its mode: the one the launch's arguments or the
348
+ // gateway's settings set, else the CLI's manual mode, which asks before
349
+ // each action.
350
+ private buildGuestModeArgs(args: readonly string[]): string[] {
351
+ if (findFlagValue(args, ['--permission-mode']) !== null) {
340
352
  return [];
341
353
  }
342
354
 
@@ -464,6 +476,12 @@ export class GatewayAdapter implements AgentAdapter {
464
476
  }
465
477
  }
466
478
 
479
+ // Whether a gateway takes its credential from impd's broker with no
480
+ // credential helper to fall back on, so it starts only behind the broker.
481
+ function isBrokerOnlyGateway(gateway: GatewayConfig): boolean {
482
+ return gateway.auth !== undefined && gateway.apiKeyHelper === undefined;
483
+ }
484
+
467
485
  // The variable the Claude CLI sends as a bearer authorization header, and
468
486
  // the value impd's broker replaces with the credential on the host's side.
469
487
  const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
@@ -142,6 +142,7 @@ function renderAgentEntry(entry: AgentEntry): Record<string, unknown> {
142
142
  ...(Object.keys(entry.auth.placeholderEnv).length > 0
143
143
  ? { placeholderEnv: entry.auth.placeholderEnv }
144
144
  : {}),
145
+ ...(entry.auth.args === undefined ? {} : { args: entry.auth.args }),
145
146
  ...(entry.mcpServers === undefined
146
147
  ? {}
147
148
  : {
@@ -68,11 +68,16 @@ export class DaemonClient implements DaemonChannel {
68
68
  }
69
69
 
70
70
  // A handshake with a token presents it as a bearer token, which a TCP
71
- // listener requires.
71
+ // listener requires. A client started inside an atc session gives that
72
+ // session, so the daemon never takes a change to the session's own record
73
+ // from it.
72
74
  sendHello(build: string, token?: string): Promise<Readonly<Record<string, unknown>>> {
75
+ const session = process.env['ATC_SESSION_ID'];
76
+
73
77
  return this.sendRequest('daemon.hello', {
74
78
  client: build,
75
79
  auth: token === undefined ? { scheme: 'none' } : { scheme: 'bearer', token },
80
+ ...(session === undefined || session === '' ? {} : { session }),
76
81
  });
77
82
  }
78
83
 
@@ -1,6 +1,9 @@
1
+ // The variables atc sets for the harness of a session it hosts.
2
+ const SESSION_VARIABLES = new Set(['ATC_SESSION_ID', 'ATC_SOCKET', 'ATC_SESSION_RECORD']);
3
+
1
4
  /**
2
- * Copies an environment without the variables that name the session or
3
- * socket of whoever runs the restart, so the daemon it starts never mistakes
5
+ * Copies an environment without the variables that name the session, its
6
+ * record, or the socket of whoever runs the restart, so the daemon it starts never mistakes
4
7
  * itself for a hosted session. Entries with no value drop out.
5
8
  */
6
9
  export function collectRestartEnv(
@@ -9,7 +12,7 @@ export function collectRestartEnv(
9
12
  const copy: Record<string, string> = {};
10
13
 
11
14
  for (const [key, value] of Object.entries(env)) {
12
- if (value !== undefined && key !== 'ATC_SESSION_ID' && key !== 'ATC_SOCKET') {
15
+ if (value !== undefined && !SESSION_VARIABLES.has(key)) {
13
16
  copy[key] = value;
14
17
  }
15
18
  }
@@ -144,6 +144,8 @@ export function buildScopedContext(
144
144
  revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
145
145
  updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
146
146
  updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
147
+ updateSessionScope: (id, scope) =>
148
+ canSee(id) ? ctx.updateSessionScope(id, scope) : Promise.resolve('missing' as const),
147
149
  ackSession: (id) => canSee(id) && ctx.ackSession(id),
148
150
  buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
149
151
  readSessionScreen: (id) =>
@@ -0,0 +1,235 @@
1
+ import { z } from 'zod';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
4
+ import type {
5
+ RecordedBranch,
6
+ RecordedPullRequest,
7
+ RecordedWorktree,
8
+ } from '../protocol/published-record';
9
+ import { runGH } from '../sources/github/run-gh';
10
+ import type { ExecutionProvider } from './execution-provider';
11
+ import { findHostBranch } from './find-host-branch';
12
+ import { runHostGit } from './run-host-git';
13
+
14
+ /**
15
+ * Where a declared scope is checked: the session's host and directory,
16
+ * and the repository URL of its materialized workspace, null for a
17
+ * directory that runs as it stands.
18
+ */
19
+ export interface ScopeCheckRequest {
20
+ readonly provider: ExecutionProvider;
21
+ readonly host: string;
22
+ readonly dir: string;
23
+ readonly repoURL: string | null;
24
+ readonly declared: DeclaredScope;
25
+ }
26
+
27
+ export interface CheckedScope {
28
+ readonly worktrees: readonly RecordedWorktree[];
29
+ readonly branches: readonly RecordedBranch[];
30
+ readonly pullRequests: readonly RecordedPullRequest[];
31
+ }
32
+
33
+ // How long one gh call may take before the entry it checks is refused.
34
+ const GH_TIMEOUT_MS = 20_000;
35
+
36
+ /**
37
+ * Checks each declared entry against the session's host and returns what
38
+ * the record holds for it: a worktree's branch as git reads it there, a
39
+ * branch with the repository it exists in, and a pull request as GitHub
40
+ * holds it. The first entry that fails refuses the whole scope with
41
+ * `scope_invalid`, the entry in its message and `data.entry`.
42
+ */
43
+ export async function checkSessionScope(
44
+ request: ScopeCheckRequest,
45
+ ghBin: string,
46
+ ): Promise<CheckedScope> {
47
+ const declared = request.declared;
48
+ const needsHost = declared.worktrees.length > 0 || declared.branches.length > 0;
49
+
50
+ if (needsHost && request.provider.remote && !request.provider.capabilities.run) {
51
+ const first = declared.worktrees.length > 0 ? 'scope.worktrees[0]' : 'scope.branches[0]';
52
+
53
+ throw buildScopeRefusal(first, 'cannot be checked: the target runs no commands');
54
+ }
55
+
56
+ const worktrees: RecordedWorktree[] = [];
57
+
58
+ for (const [index, worktree] of declared.worktrees.entries()) {
59
+ const checked = await checkWorktree(request, `scope.worktrees[${index}]`, worktree.path);
60
+
61
+ worktrees.push(checked);
62
+ }
63
+
64
+ const branches: RecordedBranch[] = [];
65
+
66
+ for (const [index, branch] of declared.branches.entries()) {
67
+ const repo = branch.repo ?? request.dir;
68
+
69
+ const checked = await checkBranch(request, `scope.branches[${index}]`, branch.name, repo);
70
+
71
+ branches.push(checked);
72
+ }
73
+
74
+ const pullRequests: RecordedPullRequest[] = [];
75
+
76
+ for (const [index, pr] of declared.pullRequests.entries()) {
77
+ const entry = `scope.pullRequests[${index}]`;
78
+ let repo = pr.repo;
79
+
80
+ repo ??= await findGitHubRepo(request, entry);
81
+
82
+ const checked = await checkPullRequest(entry, repo, pr.number, ghBin);
83
+
84
+ pullRequests.push(checked);
85
+ }
86
+
87
+ return { worktrees, branches, pullRequests };
88
+ }
89
+
90
+ async function checkWorktree(
91
+ request: ScopeCheckRequest,
92
+ entry: string,
93
+ path: string,
94
+ ): Promise<RecordedWorktree> {
95
+ const top = await runHostGit(request.provider, request.host, path, [
96
+ 'rev-parse',
97
+ '--show-toplevel',
98
+ ]);
99
+
100
+ if (top.exitCode !== 0) {
101
+ throw buildScopeRefusal(entry, `${path} is not a git worktree on the session's host`);
102
+ }
103
+
104
+ if (top.stdout.trim() !== path.replace(/\/+$/u, '')) {
105
+ throw buildScopeRefusal(
106
+ entry,
107
+ `${path} is inside the worktree ${top.stdout.trim()}, not its top level`,
108
+ );
109
+ }
110
+
111
+ return { path, branch: await findHostBranch(request.provider, request.host, path) };
112
+ }
113
+
114
+ async function checkBranch(
115
+ request: ScopeCheckRequest,
116
+ entry: string,
117
+ name: string,
118
+ repo: string,
119
+ ): Promise<RecordedBranch> {
120
+ const format = await runHostGit(request.provider, request.host, repo, [
121
+ 'check-ref-format',
122
+ '--branch',
123
+ name,
124
+ ]);
125
+
126
+ if (format.exitCode !== 0) {
127
+ throw buildScopeRefusal(entry, `${name} is not a valid branch name`);
128
+ }
129
+
130
+ for (const ref of [`refs/heads/${name}`, `refs/remotes/origin/${name}`]) {
131
+ const found = await runHostGit(request.provider, request.host, repo, [
132
+ 'rev-parse',
133
+ '--verify',
134
+ '--quiet',
135
+ '--end-of-options',
136
+ `${ref}^{commit}`,
137
+ ]);
138
+
139
+ if (found.exitCode === 0) {
140
+ return { name, repo };
141
+ }
142
+ }
143
+
144
+ throw buildScopeRefusal(entry, `${repo} has no branch ${name}`);
145
+ }
146
+
147
+ // The GitHub repository a pull request defaults to: that of the workspace's
148
+ // origin.
149
+ async function findGitHubRepo(request: ScopeCheckRequest, entry: string): Promise<string> {
150
+ let url = request.repoURL;
151
+
152
+ if (url === null && (!request.provider.remote || request.provider.capabilities.run)) {
153
+ const origin = await runHostGit(request.provider, request.host, request.dir, [
154
+ 'remote',
155
+ 'get-url',
156
+ 'origin',
157
+ ]);
158
+
159
+ url = origin.exitCode === 0 ? origin.stdout.trim() : null;
160
+ }
161
+
162
+ const repo = url === null ? null : findGitHubRepoInURL(url);
163
+
164
+ if (repo === null) {
165
+ throw buildScopeRefusal(entry, "needs a repo: the workspace's origin is not on GitHub");
166
+ }
167
+
168
+ return repo;
169
+ }
170
+
171
+ const GITHUB_URL =
172
+ /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)(?<repo>[\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/u;
173
+
174
+ function findGitHubRepoInURL(url: string): string | null {
175
+ return GITHUB_URL.exec(url)?.groups?.['repo'] ?? null;
176
+ }
177
+
178
+ const BASE_REPO_SCHEMA = z.object({ full_name: z.string() });
179
+
180
+ const PULL_REQUEST_SCHEMA = z.object({
181
+ number: z.number(),
182
+ html_url: z.string(),
183
+ head: z.object({ ref: z.string() }),
184
+ base: z.object({ repo: BASE_REPO_SCHEMA }),
185
+ });
186
+
187
+ async function checkPullRequest(
188
+ entry: string,
189
+ repo: string,
190
+ number: number,
191
+ ghBin: string,
192
+ ): Promise<RecordedPullRequest> {
193
+ let run: Awaited<ReturnType<typeof runGH>>;
194
+
195
+ try {
196
+ run = await runGH(ghBin, AbortSignal.timeout(GH_TIMEOUT_MS), [
197
+ 'api',
198
+ `repos/${repo}/pulls/${number}`,
199
+ ]);
200
+ } catch {
201
+ throw buildScopeRefusal(entry, 'cannot be checked: gh is not installed on the daemon host');
202
+ }
203
+
204
+ if (run.timedOut) {
205
+ throw buildScopeRefusal(entry, 'cannot be checked: gh took too long');
206
+ }
207
+
208
+ const parsed =
209
+ run.exitCode === 0 ? PULL_REQUEST_SCHEMA.safeParse(tryParseJSON(run.stdout)) : null;
210
+
211
+ if (parsed === null || !parsed.success) {
212
+ throw buildScopeRefusal(entry, `${repo} has no pull request #${number}`);
213
+ }
214
+
215
+ if (parsed.data.base.repo.full_name.toLowerCase() !== repo.toLowerCase()) {
216
+ throw buildScopeRefusal(
217
+ entry,
218
+ `pull request #${number} belongs to ${parsed.data.base.repo.full_name}, not ${repo}`,
219
+ );
220
+ }
221
+
222
+ return { repo, number, url: parsed.data.html_url, branch: parsed.data.head.ref };
223
+ }
224
+
225
+ function tryParseJSON(text: string): unknown {
226
+ try {
227
+ return JSON.parse(text);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ function buildScopeRefusal(entry: string, reason: string): DaemonError {
234
+ return new DaemonError('scope_invalid', `${entry} ${reason}`, { entry });
235
+ }
@@ -6,6 +6,8 @@ import { encodeCursor } from '../protocol/encode-cursor';
6
6
  import { LineDecoder } from '../protocol/line-decoder';
7
7
  import { OutboundQueue } from '../protocol/outbound-queue';
8
8
  import type { SocketWriter } from '../protocol/outbound-queue';
9
+ import { parseDeclaredScope } from '../protocol/parse-declared-scope';
10
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
9
11
  import { parseRequestParams } from '../protocol/parse-request-params';
10
12
  import type { RequestMethod } from '../protocol/parse-request-params';
11
13
  import {
@@ -103,6 +105,11 @@ export class DaemonConnection {
103
105
 
104
106
  private access: TargetAccess | null = null;
105
107
 
108
+ // The atc session the client runs inside, from its handshake; null for a
109
+ // client that gave none. A session never changes its own record or that
110
+ // of a session it is a sub-session of through it.
111
+ private callerSession: SessionID | null = null;
112
+
106
113
  // The sessions in this limited connection's view, the sessions the daemon
107
114
  // holds that the view leaves out, and the permission requests the
108
115
  // connection was shown, so the events that end them reach it after the
@@ -629,6 +636,11 @@ export class DaemonConnection {
629
636
 
630
637
  return;
631
638
  }
639
+ case 'session.scope.add': {
640
+ await this.applyScopeAdd(req, ctx);
641
+
642
+ return;
643
+ }
632
644
  case 'session.kill': {
633
645
  await this.applySessionVerb(req, 'session.kill', ctx.killSession);
634
646
 
@@ -919,6 +931,48 @@ export class DaemonConnection {
919
931
  }
920
932
  }
921
933
 
934
+ // A client inside a session never adds to the record of that session or
935
+ // of a session it is a sub-session of: only a caller outside the tree
936
+ // vouches for what the session may touch.
937
+ private async applyScopeAdd(req: RequestMsg, ctx: DaemonContext): Promise<void> {
938
+ const parsed = parseRequestParams('session.scope.add', req.p);
939
+
940
+ if (!parsed.ok) {
941
+ this.sendErr(req.id, 'bad_args', parsed.message);
942
+
943
+ return;
944
+ }
945
+
946
+ const sessionID = parsed.data.session;
947
+ const declared = parseDeclaredScope(parsed.data.scope);
948
+
949
+ if (!declared.ok) {
950
+ this.sendErr(req.id, 'scope_invalid', declared.message, { entry: declared.entry });
951
+
952
+ return;
953
+ }
954
+
955
+ if (this.callerSession !== null && ctx.isCallerTree(sessionID, this.callerSession)) {
956
+ this.sendErr(
957
+ req.id,
958
+ 'unauthorized',
959
+ `session '${this.callerSession}' cannot add to the scope of session '${sessionID}': a session never widens its own scope or that of a session above it`,
960
+ );
961
+
962
+ return;
963
+ }
964
+
965
+ const record = await ctx.updateSessionScope(sessionID, declared.scope);
966
+
967
+ if (record === 'missing') {
968
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
969
+
970
+ return;
971
+ }
972
+
973
+ this.sendOk(req.id, { record });
974
+ }
975
+
922
976
  private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
923
977
  const parsed = parseRequestParams('session.spawn', req.p);
924
978
 
@@ -1041,6 +1095,7 @@ export class DaemonConnection {
1041
1095
  target,
1042
1096
  workspace: data.workspace ?? null,
1043
1097
  autoDir: cwd === undefined,
1098
+ scope: requireDeclaredScope(data.scope),
1044
1099
  };
1045
1100
  };
1046
1101
 
@@ -1668,6 +1723,8 @@ export class DaemonConnection {
1668
1723
  this.setPrincipal(principal);
1669
1724
  }
1670
1725
 
1726
+ this.callerSession = parsedHello.ok ? (parsedHello.data.session ?? null) : null;
1727
+
1671
1728
  if (req.v !== PROTOCOL_V) {
1672
1729
  this.sendErr(
1673
1730
  req.id,
@@ -1745,6 +1802,7 @@ export class DaemonConnection {
1745
1802
  this.setPrincipal(principal);
1746
1803
  }
1747
1804
 
1805
+ this.callerSession = parsedHello.data.session ?? null;
1748
1806
  this.fingerprint = fingerprint;
1749
1807
 
1750
1808
  await this.sendHelloOk(req.id);
@@ -1851,3 +1909,19 @@ function isPlainWorkspaceDir(dir: string): boolean {
1851
1909
  !CONTROL_CHARACTER.test(dir) && !dir.split('/').some((part) => part === '.' || part === '..')
1852
1910
  );
1853
1911
  }
1912
+
1913
+ // A spawn without a scope declares none; one whose scope does not read
1914
+ // refuses before anything starts, with the entry at fault.
1915
+ function requireDeclaredScope(raw: unknown): DeclaredScope | null {
1916
+ if (raw === undefined) {
1917
+ return null;
1918
+ }
1919
+
1920
+ const parsed = parseDeclaredScope(raw);
1921
+
1922
+ if (!parsed.ok) {
1923
+ throw new DaemonError('scope_invalid', parsed.message, { entry: parsed.entry });
1924
+ }
1925
+
1926
+ return parsed.scope;
1927
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AgentAdapter, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
2
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
2
3
  import type { EventMsg } from '../protocol/protocol';
4
+ import type { PublishedRecord } from '../protocol/published-record';
3
5
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
4
6
  import type { AgentID } from '../shared/agent-id';
5
7
  import type { TargetConfigError } from '../shared/collect-targets';
@@ -40,6 +42,10 @@ export interface SpawnParams {
40
42
  // session in cwd as it stands.
41
43
  readonly workspace: SpawnWorkspaceSource | null;
42
44
 
45
+ // The scope beyond its workspace the session's record holds once each
46
+ // entry is checked on its host; null declares none.
47
+ readonly scope: DeclaredScope | null;
48
+
43
49
  // Whether the daemon picked cwd for a git workspace the spawn gave no
44
50
  // directory for, so a held directory moves the spawn to a numbered one
45
51
  // beside it.
@@ -52,6 +58,10 @@ interface SessionRecord {
52
58
  readonly lastActivityAt: number;
53
59
  readonly pending: { readonly message: string } | null;
54
60
  readonly result: string | null;
61
+
62
+ // The record atc publishes for the session; null for a session that has
63
+ // none yet.
64
+ readonly sessionRecord: PublishedRecord | null;
55
65
  }
56
66
 
57
67
  /**
@@ -248,6 +258,17 @@ export interface DaemonContext {
248
258
  // answers the new revision, or null for no such session.
249
259
  readonly updateSessionAuth: (id: SessionID) => Promise<number | null>;
250
260
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
261
+
262
+ // Adds a checked scope to a session's published record and returns the
263
+ // record as it stands after; 'missing' for no such session.
264
+ readonly updateSessionScope: (
265
+ id: SessionID,
266
+ scope: DeclaredScope,
267
+ ) => Promise<PublishedRecord | 'missing'>;
268
+
269
+ // Whether the session is the caller itself or a session the caller is a
270
+ // sub-session of, at any depth.
271
+ readonly isCallerTree: (id: SessionID, caller: SessionID) => boolean;
251
272
  readonly quitDaemon: () => void;
252
273
  readonly ackSession: (id: SessionID) => boolean;
253
274
  readonly buildResumeCommand: (id: SessionID) => string | null;