@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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +4 -0
- package/src/agents/build-claude-query-options.ts +1 -0
- package/src/agents/build-headless-env.ts +4 -0
- package/src/agents/gateway-adapter.ts +38 -20
- package/src/build-migrated-config.ts +1 -0
- package/src/client/daemon-client.ts +6 -1
- package/src/collect-restart-env.ts +6 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/check-session-scope.ts +235 -0
- package/src/daemon/daemon-connection.ts +74 -0
- package/src/daemon/daemon-context.ts +21 -0
- package/src/daemon/daemon.ts +21 -0
- package/src/daemon/find-host-branch.ts +28 -0
- package/src/daemon/materialize-workspace.ts +6 -1
- package/src/daemon/merge-recorded-scope.ts +50 -0
- package/src/daemon/published-records.ts +269 -0
- package/src/daemon/request-access-classes.ts +1 -0
- package/src/daemon/run-host-git.ts +52 -0
- package/src/daemon/sessions.ts +185 -53
- package/src/daemon/start-headless-turn.ts +3 -0
- package/src/federation/id-rules.ts +11 -2
- package/src/mcp/mcp-tools.ts +50 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +14 -0
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/parse-declared-scope.ts +158 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/protocol/published-record.ts +62 -0
- package/src/protocol/request-param-schemas.ts +16 -0
- package/src/shared/check-gateway-auth.ts +16 -10
- package/src/shared/collect-agents.ts +0 -1
- package/src/shared/collect-gateways.ts +4 -2
- package/src/store/parse-published-record.ts +59 -0
- package/src/store/run-migrations.ts +23 -0
- package/src/store/state-store.ts +32 -0
- package/src/test-utils/build-mock-published-record.ts +39 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
- package/src/test-utils/build-stub-daemon-context.ts +2 -0
- package/src/test-utils/build-stub-daemon-requests.ts +3 -3
- package/src/test-utils/build-stub-harness-relay.ts +2 -2
- package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
- package/src/test-utils/build-stub-host-hold.ts +7 -6
- package/src/test-utils/build-stub-launch-admission.ts +20 -0
- package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
- package/src/test-utils/build-stub-terminal.ts +3 -3
- package/src/test-utils/create-git-fixture.ts +2 -2
- package/src/test-utils/create-stub-imp-port.ts +37 -37
- package/src/test-utils/create-stub-systemd.ts +6 -6
- package/src/test-utils/isolate-home.ts +8 -5
- package/src/test-utils/read-query-plan.ts +10 -6
- package/src/test-utils/register-test-cleanup.ts +10 -10
- package/src/test-utils/setup-mcp-home.ts +7 -6
- package/src/test-utils/setup-mcp-http.ts +6 -6
- package/src/test-utils/setup-temp-dir.ts +4 -10
- package/src/test-utils/start-cut-proxy.ts +2 -4
- package/src/test-utils/start-daemon-process.ts +13 -13
- package/src/test-utils/start-git-http-server.ts +2 -4
- package/src/test-utils/start-mcp-stdio.ts +5 -5
- package/src/test-utils/start-stub-answering-listener.ts +3 -3
- package/src/test-utils/start-stub-closing-listener.ts +3 -3
- package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
- package/src/test-utils/start-stub-impd-info.ts +3 -7
- package/src/test-utils/start-stub-impd.ts +3 -5
- package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
- package/src/test-utils/start-stub-mcp-server.ts +3 -5
- package/src/test-utils/start-stub-recording-listener.ts +3 -3
- package/src/test-utils/start-stub-refusing-listener.ts +3 -3
- package/src/test-utils/start-stub-reporter-socket.ts +3 -9
- package/src/test-utils/start-stub-restore-daemon.ts +2 -7
- package/src/test-utils/start-stub-session-bridge.ts +3 -9
- package/src/test-utils/start-stub-silent-server.ts +3 -6
- package/src/test-utils/start-stub-source-daemon.ts +2 -2
- package/src/test-utils/start-stub-stalled-client.ts +3 -3
- package/src/test-utils/start-stub-stalled-listener.ts +3 -3
- package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
- package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
- package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
- package/src/test-utils/start-test-daemon.ts +62 -18
- package/src/test-utils/start-tui-harness.ts +17 -10
- package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
- package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
package/package.json
CHANGED
|
@@ -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
|
|
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:
|
|
172
|
+
brokerRequired: isBrokerOnlyGateway(this.gateway),
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// A gateway whose credential comes through impd's broker never
|
|
177
|
-
// the daemon's machine: started without the broker, the CLI
|
|
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
|
|
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(
|
|
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.
|
|
200
|
-
//
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
336
|
-
// settings set, else the CLI's manual mode, which asks before
|
|
337
|
-
// action.
|
|
338
|
-
private buildGuestModeArgs(): string[] {
|
|
339
|
-
if (findFlagValue(
|
|
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
|
|
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
|
|
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;
|