@zgeoff/atc 3.4.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/agents/gateway-adapter.ts +38 -20
  6. package/src/build-migrated-config.ts +1 -0
  7. package/src/client/daemon-client.ts +6 -1
  8. package/src/collect-restart-env.ts +6 -3
  9. package/src/daemon/build-scoped-context.ts +2 -0
  10. package/src/daemon/check-session-scope.ts +235 -0
  11. package/src/daemon/daemon-connection.ts +74 -0
  12. package/src/daemon/daemon-context.ts +21 -0
  13. package/src/daemon/daemon.ts +21 -0
  14. package/src/daemon/find-host-branch.ts +28 -0
  15. package/src/daemon/materialize-workspace.ts +6 -1
  16. package/src/daemon/merge-recorded-scope.ts +50 -0
  17. package/src/daemon/published-records.ts +269 -0
  18. package/src/daemon/request-access-classes.ts +1 -0
  19. package/src/daemon/run-host-git.ts +52 -0
  20. package/src/daemon/sessions.ts +185 -53
  21. package/src/daemon/start-headless-turn.ts +3 -0
  22. package/src/federation/id-rules.ts +11 -2
  23. package/src/mcp/mcp-tools.ts +50 -0
  24. package/src/mcp/require-daemon-features.ts +1 -0
  25. package/src/mcp/run-tool.ts +14 -0
  26. package/src/protocol/daemon-features.ts +5 -0
  27. package/src/protocol/parse-declared-scope.ts +158 -0
  28. package/src/protocol/protocol.ts +1 -0
  29. package/src/protocol/published-record.ts +62 -0
  30. package/src/protocol/request-param-schemas.ts +16 -0
  31. package/src/shared/check-gateway-auth.ts +16 -10
  32. package/src/shared/collect-agents.ts +0 -1
  33. package/src/shared/collect-gateways.ts +4 -2
  34. package/src/store/parse-published-record.ts +59 -0
  35. package/src/store/run-migrations.ts +23 -0
  36. package/src/store/state-store.ts +32 -0
  37. package/src/test-utils/build-mock-published-record.ts +39 -0
  38. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  39. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  40. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  41. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  42. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  43. package/src/test-utils/build-stub-host-hold.ts +7 -6
  44. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  45. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  46. package/src/test-utils/build-stub-terminal.ts +3 -3
  47. package/src/test-utils/create-git-fixture.ts +2 -2
  48. package/src/test-utils/create-stub-imp-port.ts +37 -37
  49. package/src/test-utils/create-stub-systemd.ts +6 -6
  50. package/src/test-utils/isolate-home.ts +8 -5
  51. package/src/test-utils/read-query-plan.ts +10 -6
  52. package/src/test-utils/register-test-cleanup.ts +10 -10
  53. package/src/test-utils/setup-mcp-home.ts +7 -6
  54. package/src/test-utils/setup-mcp-http.ts +6 -6
  55. package/src/test-utils/setup-temp-dir.ts +4 -10
  56. package/src/test-utils/start-cut-proxy.ts +2 -4
  57. package/src/test-utils/start-daemon-process.ts +13 -13
  58. package/src/test-utils/start-git-http-server.ts +2 -4
  59. package/src/test-utils/start-mcp-stdio.ts +5 -5
  60. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  61. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  62. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  63. package/src/test-utils/start-stub-impd-info.ts +3 -7
  64. package/src/test-utils/start-stub-impd.ts +3 -5
  65. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  66. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  67. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  68. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  69. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  70. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  71. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  72. package/src/test-utils/start-stub-silent-server.ts +3 -6
  73. package/src/test-utils/start-stub-source-daemon.ts +2 -2
  74. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  75. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  76. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  77. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  78. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  79. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  80. package/src/test-utils/start-test-daemon.ts +62 -18
  81. package/src/test-utils/start-tui-harness.ts +17 -10
  82. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  83. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
@@ -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
+ }