@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/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) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExecutionProvider } from './execution-provider';
|
|
2
|
+
import { runHostGit } from './run-host-git';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The branch a directory on a session's host has checked out; null for a
|
|
6
|
+
* detached checkout, a directory outside git, or a remote host that runs
|
|
7
|
+
* no commands.
|
|
8
|
+
*/
|
|
9
|
+
export async function findHostBranch(
|
|
10
|
+
provider: ExecutionProvider,
|
|
11
|
+
host: string,
|
|
12
|
+
dir: string,
|
|
13
|
+
): Promise<string | null> {
|
|
14
|
+
if (provider.remote && !provider.capabilities.run) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const head = await runHostGit(provider, host, dir, [
|
|
19
|
+
'symbolic-ref',
|
|
20
|
+
'--quiet',
|
|
21
|
+
'--short',
|
|
22
|
+
'HEAD',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const branch = head.stdout.trim();
|
|
26
|
+
|
|
27
|
+
return head.exitCode === 0 && branch !== '' ? branch : null;
|
|
28
|
+
}
|
|
@@ -76,6 +76,9 @@ type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
|
|
|
76
76
|
interface ReadyWorkspace {
|
|
77
77
|
readonly kind: 'ready';
|
|
78
78
|
readonly workspace: SessionWorkspace;
|
|
79
|
+
|
|
80
|
+
// The branch the checkout is on, null for a detached checkout.
|
|
81
|
+
readonly branch: string | null;
|
|
79
82
|
readonly warnings: readonly string[];
|
|
80
83
|
|
|
81
84
|
// The variables every harness the session starts goes without.
|
|
@@ -298,6 +301,7 @@ async function runMaterialization(
|
|
|
298
301
|
...(ref === null ? {} : { ref }),
|
|
299
302
|
materializedAt,
|
|
300
303
|
},
|
|
304
|
+
branch: clone.branch,
|
|
301
305
|
warnings: pinned.warnings,
|
|
302
306
|
};
|
|
303
307
|
}
|
|
@@ -532,6 +536,7 @@ async function recordPhase(
|
|
|
532
536
|
|
|
533
537
|
interface CleanClone {
|
|
534
538
|
readonly sha: string;
|
|
539
|
+
readonly branch: string | null;
|
|
535
540
|
readonly archive: Uint8Array;
|
|
536
541
|
}
|
|
537
542
|
|
|
@@ -580,7 +585,7 @@ async function createCleanClone(
|
|
|
580
585
|
throw new DaemonError(outcome.code, outcome.message, { phase: 'cloning' });
|
|
581
586
|
}
|
|
582
587
|
|
|
583
|
-
return { sha: clone.sha, archive };
|
|
588
|
+
return { sha: clone.sha, branch: clone.branch, archive };
|
|
584
589
|
}
|
|
585
590
|
|
|
586
591
|
function toDaemonError(error: unknown, code: ErrorCode, phase: MaterializationPhase): DaemonError {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RecordedScope } from '../protocol/published-record';
|
|
2
|
+
import type { CheckedScope } from './check-session-scope';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds each checked entry the recorded scope lacks, keeping the order the
|
|
6
|
+
* entries arrived in. A worktree matches by path, a branch by name and
|
|
7
|
+
* repository, and a pull request by repository and number. Returns the
|
|
8
|
+
* recorded scope itself when every entry is already there.
|
|
9
|
+
*/
|
|
10
|
+
export function mergeRecordedScope(recorded: RecordedScope, added: CheckedScope): RecordedScope {
|
|
11
|
+
const worktrees = mergeEntries(recorded.worktrees, added.worktrees, (w) => w.path);
|
|
12
|
+
const branches = mergeEntries(recorded.branches, added.branches, (b) => `${b.repo}\n${b.name}`);
|
|
13
|
+
|
|
14
|
+
const pullRequests = mergeEntries(
|
|
15
|
+
recorded.pullRequests,
|
|
16
|
+
added.pullRequests,
|
|
17
|
+
(pr) => `${pr.repo.toLowerCase()}#${pr.number}`,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
worktrees === recorded.worktrees &&
|
|
22
|
+
branches === recorded.branches &&
|
|
23
|
+
pullRequests === recorded.pullRequests
|
|
24
|
+
) {
|
|
25
|
+
return recorded;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { workspace: recorded.workspace, worktrees, branches, pullRequests };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mergeEntries<T>(
|
|
32
|
+
recorded: readonly T[],
|
|
33
|
+
added: readonly T[],
|
|
34
|
+
toKey: (entry: T) => string,
|
|
35
|
+
): readonly T[] {
|
|
36
|
+
const seen = new Set(recorded.map((entry) => toKey(entry)));
|
|
37
|
+
|
|
38
|
+
const fresh: T[] = [];
|
|
39
|
+
|
|
40
|
+
for (const entry of added) {
|
|
41
|
+
const key = toKey(entry);
|
|
42
|
+
|
|
43
|
+
if (!seen.has(key)) {
|
|
44
|
+
seen.add(key);
|
|
45
|
+
fresh.push(entry);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return fresh.length === 0 ? recorded : [...recorded, ...fresh];
|
|
50
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { DeclaredScope } from '../protocol/parse-declared-scope';
|
|
5
|
+
import type { PublishedRecord, RecordedScope } from '../protocol/published-record';
|
|
6
|
+
import type { SessionID } from '../shared/session-id';
|
|
7
|
+
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
8
|
+
import { checkSessionScope } from './check-session-scope';
|
|
9
|
+
import type { CheckedScope } from './check-session-scope';
|
|
10
|
+
import type { ExecutionProvider } from './execution-provider';
|
|
11
|
+
import { findHostBranch } from './find-host-branch';
|
|
12
|
+
import { mergeRecordedScope } from './merge-recorded-scope';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the state store keeps each session's published record.
|
|
16
|
+
*/
|
|
17
|
+
interface PublishedRecordStore {
|
|
18
|
+
readonly findPublishedRecord: (sessionID: SessionID) => Promise<PublishedRecord | null>;
|
|
19
|
+
readonly writePublishedRecord: (record: PublishedRecord) => Promise<void>;
|
|
20
|
+
readonly removePublishedRecord: (sessionID: SessionID) => Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One session as its record sees it: the host it runs on, the directory
|
|
25
|
+
* it runs in, and what its workspace was materialized from.
|
|
26
|
+
*/
|
|
27
|
+
export interface RecordSubject {
|
|
28
|
+
readonly session: SessionID;
|
|
29
|
+
readonly target: string;
|
|
30
|
+
readonly provider: ExecutionProvider;
|
|
31
|
+
readonly host: SessionID;
|
|
32
|
+
readonly dir: string;
|
|
33
|
+
readonly workspace: SessionWorkspace | null;
|
|
34
|
+
|
|
35
|
+
// The branch atc checked the workspace out on, when atc materialized it;
|
|
36
|
+
// absent leaves the host to show the directory's branch.
|
|
37
|
+
readonly branch?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PublishedRecordsOptions {
|
|
41
|
+
readonly store: PublishedRecordStore;
|
|
42
|
+
readonly daemonID: string;
|
|
43
|
+
|
|
44
|
+
// The directory on the daemon's machine each local copy lands in.
|
|
45
|
+
readonly localDir: string;
|
|
46
|
+
|
|
47
|
+
// The gh executable that checks a declared pull request.
|
|
48
|
+
readonly ghBin: string;
|
|
49
|
+
readonly now: () => number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMPTY_SCOPE: CheckedScope = { worktrees: [], branches: [], pullRequests: [] };
|
|
53
|
+
|
|
54
|
+
// Writes a record copy into a directory on a remote host: the content to a
|
|
55
|
+
// staged file, made read-only, then renamed over the copy.
|
|
56
|
+
const PLACE_COPY =
|
|
57
|
+
'umask 022 && mkdir -p "$1" && printf "%s" "$4" > "$1/$3" && chmod 0444 "$1/$3" && mv -f "$1/$3" "$1/$2"';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Publishes each session's record: the state store holds it, and the
|
|
61
|
+
* session's provider places a read-only copy where the session reads it.
|
|
62
|
+
* Only the daemon writes either, from what it checked itself.
|
|
63
|
+
*/
|
|
64
|
+
export class PublishedRecords {
|
|
65
|
+
private readonly options: PublishedRecordsOptions;
|
|
66
|
+
|
|
67
|
+
// The change under way for each session, which the next change to the
|
|
68
|
+
// same record waits on, so two additions never write over each other.
|
|
69
|
+
private readonly changes = new Map<SessionID, Promise<void>>();
|
|
70
|
+
|
|
71
|
+
constructor(options: PublishedRecordsOptions) {
|
|
72
|
+
this.options = options;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Checks a spawn's declared scope on its readied host, records it with
|
|
76
|
+
// the workspace the session runs in, and places the copy. Returns the
|
|
77
|
+
// copy's path on the host.
|
|
78
|
+
async createRecord(subject: RecordSubject, declared: DeclaredScope | null): Promise<string> {
|
|
79
|
+
const checked = declared === null ? EMPTY_SCOPE : await this.checkScope(subject, declared);
|
|
80
|
+
|
|
81
|
+
const workspace = await this.buildWorkspace(subject);
|
|
82
|
+
|
|
83
|
+
const record = this.buildRecord(subject, { workspace, ...checked }, 1);
|
|
84
|
+
|
|
85
|
+
await this.options.store.writePublishedRecord(record);
|
|
86
|
+
|
|
87
|
+
return this.writeCopy(subject, record);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Places the stored copy again before a harness starts on a revived or
|
|
91
|
+
// restored session, recording a fresh one for a session without one.
|
|
92
|
+
async restoreCopy(subject: RecordSubject): Promise<string> {
|
|
93
|
+
const stored = await this.options.store.findPublishedRecord(subject.session);
|
|
94
|
+
|
|
95
|
+
if (stored === null) {
|
|
96
|
+
return this.createRecord(subject, null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.writeCopy(subject, stored);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Checks an added scope on the session's host, adds each entry the record
|
|
103
|
+
// lacks, and places the changed copy. A scope that adds nothing leaves
|
|
104
|
+
// the record and its revision as they were.
|
|
105
|
+
async updateScope(subject: RecordSubject, declared: DeclaredScope): Promise<PublishedRecord> {
|
|
106
|
+
const before = this.changes.get(subject.session);
|
|
107
|
+
const turn = Promise.withResolvers<void>();
|
|
108
|
+
|
|
109
|
+
this.changes.set(subject.session, turn.promise);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await before;
|
|
113
|
+
|
|
114
|
+
return await this.writeScope(subject, declared);
|
|
115
|
+
} finally {
|
|
116
|
+
turn.resolve();
|
|
117
|
+
|
|
118
|
+
if (this.changes.get(subject.session) === turn.promise) {
|
|
119
|
+
this.changes.delete(subject.session);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
findRecord(sessionID: SessionID): Promise<PublishedRecord | null> {
|
|
125
|
+
return this.options.store.findPublishedRecord(sessionID);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Where the copy of a session on the daemon's own machine lies, the only
|
|
129
|
+
// machine a headless turn runs on.
|
|
130
|
+
getLocalPath(sessionID: SessionID): string {
|
|
131
|
+
return join(this.options.localDir, `${sessionID}.json`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Drops a session's record and its copy on the daemon's machine.
|
|
135
|
+
async remove(sessionID: SessionID): Promise<void> {
|
|
136
|
+
await this.options.store.removePublishedRecord(sessionID);
|
|
137
|
+
|
|
138
|
+
await rm(this.getLocalPath(sessionID), { force: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async writeScope(
|
|
142
|
+
subject: RecordSubject,
|
|
143
|
+
declared: DeclaredScope,
|
|
144
|
+
): Promise<PublishedRecord> {
|
|
145
|
+
const stored = await this.options.store.findPublishedRecord(subject.session);
|
|
146
|
+
|
|
147
|
+
let current = stored;
|
|
148
|
+
|
|
149
|
+
current ??= await this.buildFirstRecord(subject);
|
|
150
|
+
|
|
151
|
+
const checked = await this.checkScope(subject, declared);
|
|
152
|
+
|
|
153
|
+
const merged = mergeRecordedScope(current.scope, checked);
|
|
154
|
+
|
|
155
|
+
// A change that adds nothing still places the copy, so a retry after a
|
|
156
|
+
// failed copy write brings the copy level with the store.
|
|
157
|
+
if (stored !== null && merged === current.scope) {
|
|
158
|
+
await this.writeCopy(subject, stored);
|
|
159
|
+
|
|
160
|
+
return stored;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const record = this.buildRecord(subject, merged, current.revision + 1);
|
|
164
|
+
|
|
165
|
+
await this.options.store.writePublishedRecord(record);
|
|
166
|
+
await this.writeCopy(subject, record);
|
|
167
|
+
|
|
168
|
+
return record;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The record a session without one starts from, at revision 0 so its
|
|
172
|
+
// first change records revision 1.
|
|
173
|
+
private async buildFirstRecord(subject: RecordSubject): Promise<PublishedRecord> {
|
|
174
|
+
const workspace = await this.buildWorkspace(subject);
|
|
175
|
+
|
|
176
|
+
return this.buildRecord(subject, { workspace, ...EMPTY_SCOPE }, 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private checkScope(subject: RecordSubject, declared: DeclaredScope): Promise<CheckedScope> {
|
|
180
|
+
return checkSessionScope(
|
|
181
|
+
{
|
|
182
|
+
provider: subject.provider,
|
|
183
|
+
host: subject.host,
|
|
184
|
+
dir: subject.dir,
|
|
185
|
+
repoURL: subject.workspace?.repoURL ?? null,
|
|
186
|
+
declared,
|
|
187
|
+
},
|
|
188
|
+
this.options.ghBin,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async buildWorkspace(subject: RecordSubject): Promise<RecordedScope['workspace']> {
|
|
193
|
+
const branch = await this.findBranch(subject);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
path: subject.dir,
|
|
197
|
+
branch,
|
|
198
|
+
repoURL: subject.workspace?.repoURL ?? null,
|
|
199
|
+
sha: subject.workspace?.sha ?? null,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The branch atc checked a workspace out on, else the branch of a
|
|
204
|
+
// directory on the daemon's machine. A remote directory atc did not
|
|
205
|
+
// materialize records none, so a spawn there runs no command on the host
|
|
206
|
+
// before its harness starts.
|
|
207
|
+
private findBranch(subject: RecordSubject): Promise<string | null> {
|
|
208
|
+
if (subject.branch !== undefined) {
|
|
209
|
+
return Promise.resolve(subject.branch);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return subject.provider.remote
|
|
213
|
+
? Promise.resolve(null)
|
|
214
|
+
: findHostBranch(subject.provider, subject.host, subject.dir);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private buildRecord(
|
|
218
|
+
subject: RecordSubject,
|
|
219
|
+
scope: RecordedScope,
|
|
220
|
+
revision: number,
|
|
221
|
+
): PublishedRecord {
|
|
222
|
+
return {
|
|
223
|
+
format: 'atc.session-record',
|
|
224
|
+
version: 1,
|
|
225
|
+
session: subject.session,
|
|
226
|
+
daemonID: this.options.daemonID,
|
|
227
|
+
target: subject.target,
|
|
228
|
+
revision,
|
|
229
|
+
updatedAt: new Date(this.options.now()).toISOString(),
|
|
230
|
+
scope,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Replaces the copy through a rename, so a reader never sees half of it.
|
|
235
|
+
private async writeCopy(subject: RecordSubject, record: PublishedRecord): Promise<string> {
|
|
236
|
+
const content = `${JSON.stringify(record, null, 2)}\n`;
|
|
237
|
+
const name = `${subject.session}.json`;
|
|
238
|
+
|
|
239
|
+
if (!subject.provider.remote) {
|
|
240
|
+
const dir = this.options.localDir;
|
|
241
|
+
const path = this.getLocalPath(subject.session);
|
|
242
|
+
const staged = join(dir, `.${name}.${randomUUID()}`);
|
|
243
|
+
|
|
244
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
245
|
+
await chmod(dir, 0o700);
|
|
246
|
+
await writeFile(staged, content, { mode: 0o444, flag: 'wx' });
|
|
247
|
+
await rename(staged, path);
|
|
248
|
+
|
|
249
|
+
return path;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// One command writes the copy beside its place and renames it there.
|
|
253
|
+
// A remote host runs it as the user its provider runs commands as,
|
|
254
|
+
// root on an imp, so the copy and its directory belong to that user.
|
|
255
|
+
const dir = `${subject.provider.guest?.dir ?? '/tmp/atc'}/records`;
|
|
256
|
+
|
|
257
|
+
const placed = await subject.provider.runCommand({
|
|
258
|
+
argv: ['sh', '-c', PLACE_COPY, 'sh', dir, name, `.${name}.${randomUUID()}`, content],
|
|
259
|
+
cwd: '/',
|
|
260
|
+
host: subject.host,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (placed.exitCode !== 0) {
|
|
264
|
+
throw new Error(`placing the session record in ${dir} failed: ${placed.stderr.trim()}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return `${dir}/${name}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -34,6 +34,7 @@ export const REQUEST_ACCESS_CLASSES: Readonly<Record<RequestMethod, RequestAcces
|
|
|
34
34
|
'session.auth.revoke': 'owner',
|
|
35
35
|
'session.auth.rebind': 'owner',
|
|
36
36
|
'session.update': 'principal',
|
|
37
|
+
'session.scope.add': 'principal',
|
|
37
38
|
'session.attach': 'principal',
|
|
38
39
|
'session.detach': 'principal',
|
|
39
40
|
'session.input': 'principal',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
|
|
2
|
+
import type { CommandResult, ExecutionProvider } from './execution-provider';
|
|
3
|
+
|
|
4
|
+
// The git options every check runs with: every directory counts as safe,
|
|
5
|
+
// since a host's files may belong to another user than the one the command
|
|
6
|
+
// runs as, and no fsmonitor starts.
|
|
7
|
+
const GIT_OPTIONS = ['-c', 'safe.directory=*', '-c', 'core.fsmonitor=false'];
|
|
8
|
+
|
|
9
|
+
// A remote host runs git without the variables that would point it at
|
|
10
|
+
// another repository than the directory it is given.
|
|
11
|
+
const CLEAN_ENV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs git against a directory on a session's host and returns what it
|
|
15
|
+
* printed. A provider on the daemon's own machine shares the daemon's
|
|
16
|
+
* filesystem, so the daemon runs git there itself; a remote host runs it
|
|
17
|
+
* through the provider.
|
|
18
|
+
*/
|
|
19
|
+
export async function runHostGit(
|
|
20
|
+
provider: ExecutionProvider,
|
|
21
|
+
host: string,
|
|
22
|
+
dir: string,
|
|
23
|
+
args: readonly string[],
|
|
24
|
+
): Promise<CommandResult> {
|
|
25
|
+
if (provider.remote) {
|
|
26
|
+
return provider.runCommand({
|
|
27
|
+
argv: [...CLEAN_ENV, 'git', ...GIT_OPTIONS, '-C', dir, ...args],
|
|
28
|
+
cwd: '/',
|
|
29
|
+
host,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const env = Object.fromEntries(
|
|
34
|
+
Object.entries(process.env).filter(([name]) => !REPOSITORY_ENV_VARS.has(name)),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const proc = Bun.spawn(['git', ...GIT_OPTIONS, '-C', dir, ...args], {
|
|
38
|
+
cwd: '/',
|
|
39
|
+
env,
|
|
40
|
+
stdin: 'ignore',
|
|
41
|
+
stdout: 'pipe',
|
|
42
|
+
stderr: 'pipe',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
46
|
+
new Response(proc.stdout).text(),
|
|
47
|
+
new Response(proc.stderr).text(),
|
|
48
|
+
proc.exited,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
return { exitCode, stdout, stderr };
|
|
52
|
+
}
|