@zgeoff/atc 3.4.1 → 3.5.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/claude-adapter.ts +4 -3
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -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 +51 -1
- 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/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-gh.ts +5 -7
- package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- 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-claude.ts +3 -1
- package/src/test-utils/create-stub-imp-port.ts +46 -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/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +1 -1
- 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 +3 -3
- 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/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
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
|
);
|
|
@@ -48,7 +48,7 @@ import { loadClaudeConfigBundle } from './load-claude-config-bundle';
|
|
|
48
48
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
49
49
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
50
50
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
51
|
-
import {
|
|
51
|
+
import { planClaudeLineInput } from './plan-claude-line-input';
|
|
52
52
|
import { resolveAgentHome } from './resolve-agent-home';
|
|
53
53
|
import { resolveClaudeGlobalConfigPath } from './resolve-claude-global-config-path';
|
|
54
54
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
@@ -88,8 +88,9 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
88
88
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
89
89
|
|
|
90
90
|
// Claude's TUI takes a long burst of input as a paste and keeps its
|
|
91
|
-
// newline in the composer, so a line is pasted and then submitted
|
|
92
|
-
|
|
91
|
+
// newline in the composer, so a line is pasted and then submitted, with a
|
|
92
|
+
// leading slash command's name typed so the command still runs.
|
|
93
|
+
readonly planLineInput = planClaudeLineInput;
|
|
93
94
|
|
|
94
95
|
readonly profile: AgentProfile;
|
|
95
96
|
|
|
@@ -38,7 +38,7 @@ import { findFlagValue } from './find-flag-value';
|
|
|
38
38
|
import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
|
|
39
39
|
import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
|
|
40
40
|
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
41
|
-
import {
|
|
41
|
+
import { planClaudeLineInput } from './plan-claude-line-input';
|
|
42
42
|
import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
|
|
43
43
|
import { writeATCBridge } from './write-atc-bridge';
|
|
44
44
|
import { writeHookSettings } from './write-hook-settings';
|
|
@@ -68,7 +68,7 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
68
68
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
69
69
|
|
|
70
70
|
// The gateway runs the Claude CLI, whose TUI takes a line the same way.
|
|
71
|
-
readonly planLineInput =
|
|
71
|
+
readonly planLineInput = planClaudeLineInput;
|
|
72
72
|
|
|
73
73
|
readonly takesMessages = true;
|
|
74
74
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TerminalInputModes } from './agent-adapter';
|
|
2
|
+
import { planPastedLineInput } from './plan-pasted-line-input';
|
|
3
|
+
|
|
4
|
+
const PASTE_START = '\u001B[200~';
|
|
5
|
+
const PASTE_END = '\u001B[201~';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pastes a line into the Claude TUI and submits it, as the pasted line
|
|
9
|
+
* planner does, except for a line that opens with a slash command and an
|
|
10
|
+
* argument. Claude takes a long paste as pasted text and sends it as a
|
|
11
|
+
* message, so such a line has its command name and the spaces after it typed
|
|
12
|
+
* as keys and only the argument pasted, then one carriage return. The
|
|
13
|
+
* command then runs at any argument length. A TUI that has not turned
|
|
14
|
+
* bracketed paste on gets every line as the pasted line planner writes it.
|
|
15
|
+
*/
|
|
16
|
+
export function planClaudeLineInput(text: string, modes: TerminalInputModes): readonly string[] {
|
|
17
|
+
const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
|
|
18
|
+
const command = modes.bracketedPaste ? findSlashCommand(unmarked) : null;
|
|
19
|
+
|
|
20
|
+
if (command === null) {
|
|
21
|
+
return planPastedLineInput(text, modes);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return [command.typed, `${PASTE_START}${command.argument}${PASTE_END}`, '\r'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A slash, a command name that may hold a plugin namespace, at least one
|
|
28
|
+
// space, then the argument. A path such as `/tmp/out` has a slash after its
|
|
29
|
+
// first name, so it never matches.
|
|
30
|
+
const SLASH_COMMAND = /^(?<typed>\/[A-Za-z0-9][\w:-]* +)(?<argument>.+)$/su;
|
|
31
|
+
|
|
32
|
+
function findSlashCommand(text: string): { typed: string; argument: string } | null {
|
|
33
|
+
const groups = SLASH_COMMAND.exec(text)?.groups;
|
|
34
|
+
const typed = groups?.['typed'];
|
|
35
|
+
const argument = groups?.['argument'];
|
|
36
|
+
|
|
37
|
+
// An argument of spaces alone leaves a bare command, which the line
|
|
38
|
+
// pastes whole like any short line.
|
|
39
|
+
if (typed === undefined || argument === undefined || argument.trim() === '') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { typed, argument };
|
|
44
|
+
}
|
|
@@ -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;
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -82,6 +82,7 @@ import { mintMessageID } from './mint-message-id';
|
|
|
82
82
|
import { mintSessionID } from './mint-session-id';
|
|
83
83
|
import { parseReport } from './parse-report';
|
|
84
84
|
import { PermissionRegistry } from './permission-registry';
|
|
85
|
+
import { PublishedRecords } from './published-records';
|
|
85
86
|
import { requireGitTransports } from './require-git-transports';
|
|
86
87
|
import { restoreFleet } from './restore-fleet';
|
|
87
88
|
import type { RestoreSettled } from './restore-fleet';
|
|
@@ -228,6 +229,10 @@ export interface DaemonOptions {
|
|
|
228
229
|
// services it uses; none when unset.
|
|
229
230
|
readonly sources?: readonly SourceProvider[];
|
|
230
231
|
|
|
232
|
+
// The gh executable that checks a pull request a session's scope
|
|
233
|
+
// declares; `gh` on PATH when unset.
|
|
234
|
+
readonly ghBin?: string;
|
|
235
|
+
|
|
231
236
|
// The transports a git workspace source may use and git may fetch over,
|
|
232
237
|
// or the invalid list the config holds, which refuses every git
|
|
233
238
|
// operation; https and ssh when unset.
|
|
@@ -422,6 +427,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
422
427
|
mgr.log = opts.log;
|
|
423
428
|
}
|
|
424
429
|
|
|
430
|
+
const records = new PublishedRecords({
|
|
431
|
+
store,
|
|
432
|
+
daemonID: store.daemonID,
|
|
433
|
+
localDir: join(stateDir, 'records'),
|
|
434
|
+
ghBin: opts.ghBin ?? 'gh',
|
|
435
|
+
now: () => Date.now(),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
mgr.records = records;
|
|
439
|
+
|
|
425
440
|
const authBinder = new RuntimeAuthBinder(store);
|
|
426
441
|
|
|
427
442
|
mgr.authBinder = authBinder;
|
|
@@ -1159,6 +1174,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1159
1174
|
targetIdentity: string,
|
|
1160
1175
|
) => Promise<Readonly<{
|
|
1161
1176
|
workspace: SessionWorkspace;
|
|
1177
|
+
branch: string | null;
|
|
1162
1178
|
withheldEnv: readonly string[];
|
|
1163
1179
|
}> | null>)
|
|
1164
1180
|
| null,
|
|
@@ -1180,6 +1196,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1180
1196
|
materialize,
|
|
1181
1197
|
requireInReach,
|
|
1182
1198
|
p.autoDir,
|
|
1199
|
+
p.scope,
|
|
1183
1200
|
);
|
|
1184
1201
|
|
|
1185
1202
|
const runtime = runtimes.get(s.id);
|
|
@@ -1623,6 +1640,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1623
1640
|
});
|
|
1624
1641
|
},
|
|
1625
1642
|
updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
|
|
1643
|
+
updateSessionScope: (id, scope) => mgr.updateScope(id, scope),
|
|
1644
|
+
isCallerTree: (id, caller) => mgr.isCallerTree(id, caller),
|
|
1626
1645
|
quitDaemon: () => {
|
|
1627
1646
|
// The ok response for the quit request must flush before the sockets
|
|
1628
1647
|
// close under it.
|
|
@@ -1960,6 +1979,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1960
1979
|
const linked = access !== null || shared ? undefined : s.agentSessionID;
|
|
1961
1980
|
|
|
1962
1981
|
const lastEventAt = await store.loadLastActivityAt(s.id, linked);
|
|
1982
|
+
const sessionRecord = await records.findRecord(s.id);
|
|
1963
1983
|
|
|
1964
1984
|
return {
|
|
1965
1985
|
session,
|
|
@@ -1967,6 +1987,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1967
1987
|
lastActivityAt: lastEventAt ?? createdAt,
|
|
1968
1988
|
pending,
|
|
1969
1989
|
result,
|
|
1990
|
+
sessionRecord,
|
|
1970
1991
|
};
|
|
1971
1992
|
},
|
|
1972
1993
|
loadSessionTranscript: async (id, from, limit) => {
|