@zgeoff/atc 2.16.0 → 2.17.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/cli.ts +24 -1
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/spawn-picker.ts +132 -6
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +105 -0
- package/src/shared/config.ts +14 -0
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -161,6 +161,10 @@ const main = defineCommand({
|
|
|
161
161
|
const gateway = await import('./agents/gateway-adapter');
|
|
162
162
|
const headless = await import('./agents/start-claude-headless-run');
|
|
163
163
|
const targets = await import('./daemon/build-execution-targets');
|
|
164
|
+
const sourceOrder = await import('./sources/build-sources');
|
|
165
|
+
const builtinSources = await import('./sources/collect-builtin-sources');
|
|
166
|
+
const zoxide = await import('./shared/collect-zoxide-dirs');
|
|
167
|
+
const home = await import('./shared/resolve-home-dir');
|
|
164
168
|
|
|
165
169
|
// Test harnesses shrink the outbound queue to force overflow
|
|
166
170
|
// deterministically; unset means the production default.
|
|
@@ -178,10 +182,27 @@ const main = defineCommand({
|
|
|
178
182
|
console.error(`atc daemon: config: ${line}`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
for (const problem of cfg.principalErrors) {
|
|
185
|
+
for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
|
|
182
186
|
console.error(`atc daemon: config: ${problem}`);
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
const sources = sourceOrder.buildSources(
|
|
190
|
+
builtinSources.collectBuiltinSources({
|
|
191
|
+
roots: cfg.dirs.roots,
|
|
192
|
+
githubOwner: cfg.workspaces.githubOwner,
|
|
193
|
+
ghBin: 'gh',
|
|
194
|
+
homeDir: home.resolveHomeDir(),
|
|
195
|
+
collectZoxideDirs: zoxide.collectZoxideDirs,
|
|
196
|
+
}),
|
|
197
|
+
cfg.workspaces.sources,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
for (const id of sources.missing) {
|
|
201
|
+
console.error(
|
|
202
|
+
`atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
// Cap on how long a fleet restore waits for one revived session to
|
|
186
207
|
// report it has booted before moving to the next. Tests pin it to
|
|
187
208
|
// keep timing deterministic; unset means the production default.
|
|
@@ -220,6 +241,8 @@ const main = defineCommand({
|
|
|
220
241
|
defaultTarget: cfg.defaultTarget,
|
|
221
242
|
targetErrors,
|
|
222
243
|
principals: cfg.principals,
|
|
244
|
+
sources: sources.sources,
|
|
245
|
+
gitTransports: cfg.workspaces.gitTransports,
|
|
223
246
|
restoreBootTimeoutMs,
|
|
224
247
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
225
248
|
? { tapGraceMs: graceOverride }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
export interface TargetPick {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
readonly available: boolean;
|
|
7
|
+
readonly isDefault: boolean;
|
|
8
|
+
|
|
9
|
+
// Whether a workspace can be materialized there: the daemon has the
|
|
10
|
+
// provider, and it can both transfer an archive and run a command.
|
|
11
|
+
readonly takesWorkspace: boolean;
|
|
12
|
+
|
|
13
|
+
// Whether a session there runs on the daemon's own machine, where a
|
|
14
|
+
// local directory runs in place.
|
|
15
|
+
readonly inPlace: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The execution targets an `agents.list` answer holds, in config order. An
|
|
20
|
+
* answer from a daemon without targets, or a malformed entry, yields none.
|
|
21
|
+
*/
|
|
22
|
+
export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
|
|
23
|
+
const targets = answer['targets'];
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(targets)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return targets.flatMap((entry: unknown): TargetPick[] => {
|
|
30
|
+
if (
|
|
31
|
+
!isRecord(entry) ||
|
|
32
|
+
typeof entry['id'] !== 'string' ||
|
|
33
|
+
typeof entry['provider'] !== 'string'
|
|
34
|
+
) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
|
|
39
|
+
const available = entry['available'] === true;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
id: entry['id'],
|
|
44
|
+
provider: entry['provider'],
|
|
45
|
+
available,
|
|
46
|
+
isDefault: entry['default'] === true,
|
|
47
|
+
takesWorkspace:
|
|
48
|
+
available && capabilities['transfer'] === true && capabilities['run'] === true,
|
|
49
|
+
inPlace: entry['provider'] === 'local-pty',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/client/dirs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
|
-
import { collectRootDirs } from '
|
|
4
|
+
import { collectRootDirs } from '../shared/collect-root-dirs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Every source the picker lists, merged in the order the reader expects
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
3
|
import type { AgentID } from '../shared/agent-id';
|
|
4
|
+
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
4
5
|
import { loadConfig } from '../shared/config';
|
|
5
6
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
6
7
|
import type { AgentPick } from './collect-agent-picks';
|
|
7
8
|
import { collectPathCompletions } from './collect-path-completions';
|
|
8
|
-
import {
|
|
9
|
+
import { collectTargetPicks } from './collect-target-picks';
|
|
10
|
+
import type { TargetPick } from './collect-target-picks';
|
|
9
11
|
import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
|
|
10
12
|
import { planTextEdit } from './keys';
|
|
11
13
|
import { resolvePathInput } from './resolve-path-input';
|
|
12
14
|
import { ansi, cols, drawPicker } from './ui';
|
|
13
15
|
|
|
14
|
-
type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
|
|
16
|
+
type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
|
|
15
17
|
|
|
16
18
|
// What the flow borrows from the client that owns the screen, the daemon
|
|
17
19
|
// connection and the fleet mirror.
|
|
@@ -31,8 +33,9 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* The modal flow behind n and r: agent, then directory, then
|
|
35
|
-
*
|
|
36
|
+
* The modal flow behind n and r: agent, then directory, then execution
|
|
37
|
+
* target when the daemon has more than one, then name, then an optional
|
|
38
|
+
* first prompt. Every path out of it either attaches the new
|
|
36
39
|
* session or returns the client to the screen it came from. A spawn the
|
|
37
40
|
* daemon refuses is no path out: the flow stays open and shows why.
|
|
38
41
|
*/
|
|
@@ -56,6 +59,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
56
59
|
|
|
57
60
|
private dir = '';
|
|
58
61
|
|
|
62
|
+
// The daemon's execution targets, read when the directory step opens,
|
|
63
|
+
// and the one chosen; null spawns on the daemon's default target.
|
|
64
|
+
private targets: TargetPick[] = [];
|
|
65
|
+
|
|
66
|
+
private target: TargetPick | null = null;
|
|
67
|
+
|
|
59
68
|
private name = '';
|
|
60
69
|
|
|
61
70
|
private resume = false;
|
|
@@ -72,6 +81,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
72
81
|
|
|
73
82
|
open(resume = false) {
|
|
74
83
|
this.resume = resume;
|
|
84
|
+
this.target = null;
|
|
75
85
|
|
|
76
86
|
const config = loadConfig();
|
|
77
87
|
|
|
@@ -99,7 +109,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
99
109
|
|
|
100
110
|
const edit = planTextEdit(buf, this.input, {
|
|
101
111
|
isLeaderKey: this.deps.isLeaderKey,
|
|
102
|
-
moves: this.step === 'agent' || this.step === 'dir',
|
|
112
|
+
moves: this.step === 'agent' || this.step === 'dir' || this.step === 'target',
|
|
103
113
|
});
|
|
104
114
|
|
|
105
115
|
switch (edit.kind) {
|
|
@@ -131,6 +141,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
131
141
|
return;
|
|
132
142
|
}
|
|
133
143
|
case 'input': {
|
|
144
|
+
// The target step takes no text: its list is short and fixed.
|
|
145
|
+
if (this.step === 'target') {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
134
149
|
this.input = edit.value;
|
|
135
150
|
|
|
136
151
|
this.render();
|
|
@@ -170,6 +185,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
170
185
|
input: this.input,
|
|
171
186
|
hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
|
|
172
187
|
});
|
|
188
|
+
} else if (this.step === 'target') {
|
|
189
|
+
this.selected = Math.min(this.selected, this.targets.length - 1);
|
|
190
|
+
|
|
191
|
+
drawPicker({
|
|
192
|
+
title: `${verb}: target`,
|
|
193
|
+
items: this.targets.map((t) => formatTargetPick(t)),
|
|
194
|
+
selected: this.selected,
|
|
195
|
+
input: '',
|
|
196
|
+
hint: this.refusal ?? `where ${formatDir(this.dir)} runs · ↑↓ move · ⏎ select · esc back`,
|
|
197
|
+
dimmed: new Set(
|
|
198
|
+
this.targets.flatMap((t, i) =>
|
|
199
|
+
t.takesWorkspace || (t.available && t.inPlace) ? [] : [i],
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
});
|
|
173
203
|
} else if (this.step === 'name') {
|
|
174
204
|
drawPicker({
|
|
175
205
|
title: `${verb}: name`,
|
|
@@ -209,8 +239,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
209
239
|
);
|
|
210
240
|
|
|
211
241
|
this.step = 'agent';
|
|
212
|
-
} else if (this.step === '
|
|
242
|
+
} else if (this.step === 'target') {
|
|
213
243
|
this.step = 'dir';
|
|
244
|
+
} else if (this.step === 'name') {
|
|
245
|
+
this.openTargetOrDir('dir');
|
|
214
246
|
} else {
|
|
215
247
|
this.step = 'name';
|
|
216
248
|
}
|
|
@@ -246,6 +278,27 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
246
278
|
|
|
247
279
|
this.dir = chosen;
|
|
248
280
|
this.input = '';
|
|
281
|
+
|
|
282
|
+
this.openTargetOrDir('name');
|
|
283
|
+
} else if (this.step === 'target') {
|
|
284
|
+
const pick = this.targets[this.selected];
|
|
285
|
+
|
|
286
|
+
if (pick === undefined) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const refusal = findTargetRefusal(pick);
|
|
291
|
+
|
|
292
|
+
if (refusal !== null) {
|
|
293
|
+
this.refusal = refusal;
|
|
294
|
+
|
|
295
|
+
this.render();
|
|
296
|
+
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.target = pick;
|
|
301
|
+
this.input = '';
|
|
249
302
|
this.step = 'name';
|
|
250
303
|
} else if (this.step === 'name') {
|
|
251
304
|
this.name = this.input.trim();
|
|
@@ -280,9 +333,44 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
280
333
|
: collectPathCompletions(this.input, process.cwd(), homedir());
|
|
281
334
|
}
|
|
282
335
|
|
|
336
|
+
// Opens the target step when the daemon has more than one target, with
|
|
337
|
+
// the chosen or default target selected, and the fallback step otherwise.
|
|
338
|
+
private openTargetOrDir(fallback: 'dir' | 'name') {
|
|
339
|
+
if (this.targets.length < 2) {
|
|
340
|
+
// The one target is the spawn's, default or not, so the spawn names it
|
|
341
|
+
// and its provider decides whether a directory runs in place.
|
|
342
|
+
this.target = this.targets[0] ?? null;
|
|
343
|
+
this.step = fallback;
|
|
344
|
+
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const preferred = this.target?.id;
|
|
349
|
+
|
|
350
|
+
this.selected = Math.max(
|
|
351
|
+
0,
|
|
352
|
+
this.targets.findIndex((t) => (preferred === undefined ? t.isDefault : t.id === preferred)),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
this.step = 'target';
|
|
356
|
+
}
|
|
357
|
+
|
|
283
358
|
private async openDirStep() {
|
|
284
359
|
let recent: string[] = [];
|
|
285
360
|
|
|
361
|
+
try {
|
|
362
|
+
const listed = await this.deps.sendRequest('agents.list');
|
|
363
|
+
|
|
364
|
+
const picks = collectTargetPicks(listed);
|
|
365
|
+
|
|
366
|
+
// An adopt resumes a session from its history on this host, which a
|
|
367
|
+
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
368
|
+
// targets that run here.
|
|
369
|
+
this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
|
|
370
|
+
} catch {
|
|
371
|
+
this.targets = [];
|
|
372
|
+
}
|
|
373
|
+
|
|
286
374
|
try {
|
|
287
375
|
const answer = await this.deps.sendRequest('dirs.list');
|
|
288
376
|
|
|
@@ -318,6 +406,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
318
406
|
rows: this.deps.ptyRows(),
|
|
319
407
|
...(this.resume ? { resume: true } : {}),
|
|
320
408
|
agent: this.agent,
|
|
409
|
+
...this.buildTargetParams(),
|
|
321
410
|
});
|
|
322
411
|
|
|
323
412
|
const spawned = this.deps.toMirrorSession(ok['session']);
|
|
@@ -347,4 +436,41 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
347
436
|
|
|
348
437
|
this.deps.toBase();
|
|
349
438
|
}
|
|
439
|
+
|
|
440
|
+
// A chosen target goes with the spawn. A local directory runs in place on
|
|
441
|
+
// a target on the daemon's own machine, and is materialized at the same
|
|
442
|
+
// path from its pushed HEAD on any other.
|
|
443
|
+
private buildTargetParams(): Readonly<Record<string, unknown>> {
|
|
444
|
+
if (this.target === null) {
|
|
445
|
+
return {};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return this.target.inPlace
|
|
449
|
+
? { target: this.target.id }
|
|
450
|
+
: { target: this.target.id, workspace: { kind: 'path', path: this.dir } };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function formatTargetPick(pick: TargetPick): string {
|
|
455
|
+
const notes = [
|
|
456
|
+
pick.provider,
|
|
457
|
+
...(pick.isDefault ? ['default'] : []),
|
|
458
|
+
...(pick.available ? [] : ['unavailable']),
|
|
459
|
+
...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
return `${pick.id} ${notes.join(' · ')}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Why a local directory cannot run on a target, or null when it can.
|
|
466
|
+
function findTargetRefusal(pick: TargetPick): string | null {
|
|
467
|
+
if (!pick.available) {
|
|
468
|
+
return `target '${pick.id}' is unavailable on this daemon · esc back`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!pick.inPlace && !pick.takesWorkspace) {
|
|
472
|
+
return `target '${pick.id}' cannot take a workspace · esc back`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return null;
|
|
350
476
|
}
|
package/src/client/ui.ts
CHANGED
|
@@ -387,6 +387,9 @@ export interface PickerView {
|
|
|
387
387
|
input: string;
|
|
388
388
|
placeholder?: string;
|
|
389
389
|
hint: string;
|
|
390
|
+
|
|
391
|
+
// The indexes of items drawn dim: listed, but not a fit for the flow.
|
|
392
|
+
dimmed?: ReadonlySet<number>;
|
|
390
393
|
}
|
|
391
394
|
|
|
392
395
|
export function drawPicker(view: PickerView) {
|
|
@@ -397,7 +400,9 @@ export function drawPicker(view: PickerView) {
|
|
|
397
400
|
for (const [i, item] of shown.entries()) {
|
|
398
401
|
const sel = i === view.selected;
|
|
399
402
|
const t = truncate(item, width - 4);
|
|
400
|
-
const
|
|
403
|
+
const dim = view.dimmed?.has(i) === true;
|
|
404
|
+
const inverse = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
|
|
405
|
+
const styled = dim ? `${ESC}[90m${inverse}${ESC}[0m` : inverse;
|
|
401
406
|
const plainLen = sel ? width - 4 : t.length;
|
|
402
407
|
|
|
403
408
|
rowsList.push(boxRow(width, styled, plainLen));
|
|
@@ -19,6 +19,7 @@ import type { AgentID } from '../shared/agent-id';
|
|
|
19
19
|
import { isRecord } from '../shared/report';
|
|
20
20
|
import type { SessionID } from '../shared/session-id';
|
|
21
21
|
import { toSessionID } from '../shared/to-session-id';
|
|
22
|
+
import type { SourceProvider, SourceRequest } from '../sources/types';
|
|
22
23
|
import { buildPayloadHash } from './build-payload-hash';
|
|
23
24
|
import { buildScopedContext } from './build-scoped-context';
|
|
24
25
|
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
@@ -43,6 +44,18 @@ interface ViewUpdate {
|
|
|
43
44
|
readonly withdrawn: readonly SessionID[];
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// What a source request holds: the source's id and the target it is for.
|
|
48
|
+
interface SourceParams {
|
|
49
|
+
readonly source: string;
|
|
50
|
+
readonly target?: string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The source a request asks for, and the request the source serves.
|
|
54
|
+
interface ResolvedSourceRequest {
|
|
55
|
+
readonly source: SourceProvider;
|
|
56
|
+
readonly request: SourceRequest;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
interface PeerSocket extends SocketWriter {
|
|
47
60
|
readonly end: () => void;
|
|
48
61
|
}
|
|
@@ -409,6 +422,21 @@ export class DaemonConnection {
|
|
|
409
422
|
|
|
410
423
|
return;
|
|
411
424
|
}
|
|
425
|
+
case 'sources.list': {
|
|
426
|
+
await this.applySourcesList(req, ctx);
|
|
427
|
+
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
case 'sources.interpret': {
|
|
431
|
+
await this.applySourcesInterpret(req, ctx);
|
|
432
|
+
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case 'git.probe': {
|
|
436
|
+
await this.applyGitProbe(req, ctx);
|
|
437
|
+
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
412
440
|
case 'session.spawn': {
|
|
413
441
|
await this.applySpawn(req, ctx);
|
|
414
442
|
|
|
@@ -794,6 +822,107 @@ export class DaemonConnection {
|
|
|
794
822
|
this.sendOk(req.id, spawned);
|
|
795
823
|
}
|
|
796
824
|
|
|
825
|
+
// Lists one source's candidates for a spawn to the request's target.
|
|
826
|
+
private async applySourcesList(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
827
|
+
const parsed = parseRequestParams('sources.list', req.p);
|
|
828
|
+
|
|
829
|
+
if (!parsed.ok) {
|
|
830
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
831
|
+
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const resolved = this.resolveSourceRequest(parsed.data, ctx);
|
|
836
|
+
const source = resolved.source;
|
|
837
|
+
|
|
838
|
+
const listed = await source.list(
|
|
839
|
+
{
|
|
840
|
+
...(parsed.data.scope === undefined ? {} : { scope: parsed.data.scope }),
|
|
841
|
+
...(parsed.data.text === undefined ? {} : { text: parsed.data.text }),
|
|
842
|
+
},
|
|
843
|
+
resolved.request,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
this.sendOk(req.id, { source: source.id, scope: listed.scope, candidates: listed.candidates });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Reads typed input through one source for a spawn to the request's
|
|
850
|
+
// target.
|
|
851
|
+
private async applySourcesInterpret(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
852
|
+
const parsed = parseRequestParams('sources.interpret', req.p);
|
|
853
|
+
|
|
854
|
+
if (!parsed.ok) {
|
|
855
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
856
|
+
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const resolved = this.resolveSourceRequest(parsed.data, ctx);
|
|
861
|
+
|
|
862
|
+
const interpretation = await resolved.source.interpret(parsed.data.input, resolved.request);
|
|
863
|
+
|
|
864
|
+
this.sendOk(req.id, { ...interpretation });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// The source a request asks for and the request it serves, so a principal
|
|
868
|
+
// reaches a source only for a target it may spawn on, and a git source
|
|
869
|
+
// only for a target it may spawn a workspace on.
|
|
870
|
+
private resolveSourceRequest(params: SourceParams, ctx: DaemonContext): ResolvedSourceRequest {
|
|
871
|
+
const source = ctx.findSource(params.source);
|
|
872
|
+
|
|
873
|
+
if (source === null) {
|
|
874
|
+
throw new DaemonError('unsupported', `this daemon offers no source '${params.source}'`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const target = ctx.resolveSpawnTarget(params.target);
|
|
878
|
+
|
|
879
|
+
if (source.kind === 'git') {
|
|
880
|
+
this.ctx.requireWorkspaceTarget(target);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// The spawn history is read through the request's own view, so a
|
|
884
|
+
// principal lists only directories spawned on targets it may use.
|
|
885
|
+
return { source, request: { target, collectSpawnDirs: () => ctx.collectSpawnDirs(null) } };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Probes a git workspace source for a spawn to the request's target, so
|
|
889
|
+
// a principal probes only for a target it may spawn a workspace on. A
|
|
890
|
+
// refusal holds the repository's other URLs that the offered sources know.
|
|
891
|
+
private async applyGitProbe(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
892
|
+
const parsed = parseRequestParams('git.probe', req.p);
|
|
893
|
+
|
|
894
|
+
if (!parsed.ok) {
|
|
895
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
896
|
+
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const data = parsed.data;
|
|
901
|
+
|
|
902
|
+
this.ctx.requireWorkspaceTarget(ctx.resolveSpawnTarget(data.target));
|
|
903
|
+
|
|
904
|
+
const access = await ctx.checkRepositoryAccess({
|
|
905
|
+
url: data.url,
|
|
906
|
+
ref: data.ref,
|
|
907
|
+
sha: data.sha,
|
|
908
|
+
credential: data.credentialRef,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (!access.ok) {
|
|
912
|
+
const alternates = ctx.collectAlternateGitURLs(data.url);
|
|
913
|
+
const detail = alternates.length === 0 ? undefined : { alternates };
|
|
914
|
+
|
|
915
|
+
throw new DaemonError(access.code, access.message, detail);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
this.sendOk(req.id, {
|
|
919
|
+
url: access.url,
|
|
920
|
+
head: access.head,
|
|
921
|
+
refs: access.refs,
|
|
922
|
+
resolved: access.resolved,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
797
926
|
private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
|
|
798
927
|
const parsed = parseRequestParams('session.attach', req.p);
|
|
799
928
|
|
|
@@ -6,9 +6,11 @@ import type { TargetConfigError } from '../shared/collect-targets';
|
|
|
6
6
|
import type { DaemonID } from '../shared/daemon-id';
|
|
7
7
|
import type { MessageID } from '../shared/message-id';
|
|
8
8
|
import type { SessionID } from '../shared/session-id';
|
|
9
|
+
import type { SourceKind, SourceProvider } from '../sources/types';
|
|
9
10
|
import type { FleetEntry } from '../store/fleet-entry';
|
|
10
11
|
import type { MessageRecord } from '../store/message-record';
|
|
11
12
|
import type { TurnSibling } from '../store/state-store';
|
|
13
|
+
import type { checkRepositoryAccess } from '../workspace/check-repository-access';
|
|
12
14
|
import type { Dims } from './attach-registry';
|
|
13
15
|
import type { AgentEntry } from './build-agent-list';
|
|
14
16
|
import type { FleetEvent } from './build-fleet-events';
|
|
@@ -79,6 +81,13 @@ interface AgentList {
|
|
|
79
81
|
readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
|
|
80
82
|
readonly configRevision: string;
|
|
81
83
|
readonly targetErrors: readonly TargetConfigError[];
|
|
84
|
+
|
|
85
|
+
// The sources the spawn picker offers, in order.
|
|
86
|
+
readonly sources: readonly {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly label: string;
|
|
89
|
+
readonly kind: SourceKind;
|
|
90
|
+
}[];
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
// The slice of a connection the attach bookkeeping needs: identity plus the
|
|
@@ -163,6 +172,20 @@ export interface DaemonContext {
|
|
|
163
172
|
// one whose provider cannot both transfer an archive and run a command.
|
|
164
173
|
readonly requireWorkspaceTarget: (target: string) => void;
|
|
165
174
|
|
|
175
|
+
// The source the daemon offers the spawn picker under an id, or null when
|
|
176
|
+
// it offers none under it.
|
|
177
|
+
readonly findSource: (id: string) => SourceProvider | null;
|
|
178
|
+
|
|
179
|
+
// The other URLs the offered sources know for a git URL the daemon's host
|
|
180
|
+
// could not read, each once.
|
|
181
|
+
readonly collectAlternateGitURLs: (url: string) => readonly string[];
|
|
182
|
+
|
|
183
|
+
// Checks that the daemon's host can read a git workspace source, and
|
|
184
|
+
// resolves its ref, the way a workspace spawn from it would.
|
|
185
|
+
readonly checkRepositoryAccess: (
|
|
186
|
+
request: Omit<Parameters<typeof checkRepositoryAccess>[0], 'transports'>,
|
|
187
|
+
) => ReturnType<typeof checkRepositoryAccess>;
|
|
188
|
+
|
|
166
189
|
// Runs the plan, which throws the refusal for a spawn it refuses, then
|
|
167
190
|
// spawns. Answers with the `session.spawn` ok payload, which a keyed
|
|
168
191
|
// retry replays as the first spawn answered it while the access, when
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -13,12 +13,15 @@ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
|
13
13
|
import type { SessionState } from '../protocol/session-state';
|
|
14
14
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
15
15
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
16
|
+
import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
|
|
17
|
+
import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
|
|
16
18
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
17
19
|
import type { MessageID } from '../shared/message-id';
|
|
18
20
|
import { isRecord } from '../shared/report';
|
|
19
21
|
import type { SessionID } from '../shared/session-id';
|
|
20
22
|
import { toMessageID } from '../shared/to-message-id';
|
|
21
23
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
24
|
+
import type { SourceProvider } from '../sources/types';
|
|
22
25
|
import type { IdempotencyRecord } from '../store/idempotency-record';
|
|
23
26
|
import type { MessageOwner } from '../store/message-owner';
|
|
24
27
|
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
@@ -26,6 +29,7 @@ import { StateStore } from '../store/state-store';
|
|
|
26
29
|
import type { EventScope } from '../store/state-store';
|
|
27
30
|
import type { TrailEntry } from '../store/trail-entry';
|
|
28
31
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
32
|
+
import { checkRepositoryAccess } from '../workspace/check-repository-access';
|
|
29
33
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
30
34
|
import { AttachRegistry } from './attach-registry';
|
|
31
35
|
import { buildAgentList } from './build-agent-list';
|
|
@@ -68,6 +72,7 @@ import { mintMessageID } from './mint-message-id';
|
|
|
68
72
|
import { mintSessionID } from './mint-session-id';
|
|
69
73
|
import { parseReport } from './parse-report';
|
|
70
74
|
import { PermissionRegistry } from './permission-registry';
|
|
75
|
+
import { requireGitTransports } from './require-git-transports';
|
|
71
76
|
import { restoreFleet } from './restore-fleet';
|
|
72
77
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
73
78
|
import { ScreenModel } from './screen-model';
|
|
@@ -153,6 +158,15 @@ export interface DaemonOptions {
|
|
|
153
158
|
// Where background failures are reported, one line at a time; stderr
|
|
154
159
|
// when unset.
|
|
155
160
|
readonly log?: (line: string) => void;
|
|
161
|
+
|
|
162
|
+
// The sources the spawn picker offers, in order, each built with the
|
|
163
|
+
// services it uses; none when unset.
|
|
164
|
+
readonly sources?: readonly SourceProvider[];
|
|
165
|
+
|
|
166
|
+
// The transports a git workspace source may use and git may fetch over,
|
|
167
|
+
// or the invalid list the config holds, which refuses every git
|
|
168
|
+
// operation; https and ssh when unset.
|
|
169
|
+
readonly gitTransports?: readonly string[] | InvalidGitTransports;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
export interface DaemonHandle {
|
|
@@ -236,6 +250,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
236
250
|
|
|
237
251
|
const targetErrors = opts.targetErrors ?? [];
|
|
238
252
|
const principals = opts.principals ?? null;
|
|
253
|
+
const gitTransports = opts.gitTransports ?? DEFAULT_GIT_TRANSPORTS;
|
|
239
254
|
|
|
240
255
|
const targetsByID = new Map(targets.map((target) => [target.id, target]));
|
|
241
256
|
|
|
@@ -911,6 +926,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
911
926
|
mgr.log(line);
|
|
912
927
|
},
|
|
913
928
|
stagingRoot: tmpdir(),
|
|
929
|
+
gitTransports,
|
|
914
930
|
},
|
|
915
931
|
);
|
|
916
932
|
};
|
|
@@ -1192,6 +1208,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1192
1208
|
}
|
|
1193
1209
|
};
|
|
1194
1210
|
|
|
1211
|
+
const sources = opts.sources ?? [];
|
|
1212
|
+
|
|
1195
1213
|
const ctx: DaemonContext = {
|
|
1196
1214
|
build: opts.build,
|
|
1197
1215
|
daemonID: store.daemonID,
|
|
@@ -1215,6 +1233,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1215
1233
|
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
1216
1234
|
configRevision,
|
|
1217
1235
|
targetErrors,
|
|
1236
|
+
sources: sources.map((source) => ({
|
|
1237
|
+
id: source.id,
|
|
1238
|
+
label: source.label,
|
|
1239
|
+
kind: source.kind,
|
|
1240
|
+
})),
|
|
1218
1241
|
}),
|
|
1219
1242
|
collectFleet: () => store.loadFleet(),
|
|
1220
1243
|
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
@@ -1256,6 +1279,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1256
1279
|
mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
|
|
1257
1280
|
mgr.requireExecution({ target, targetIdentity: null }, 'run');
|
|
1258
1281
|
},
|
|
1282
|
+
findSource: (id) => sources.find((source) => source.id === id) ?? null,
|
|
1283
|
+
collectAlternateGitURLs: (url) => [
|
|
1284
|
+
...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
|
|
1285
|
+
],
|
|
1286
|
+
checkRepositoryAccess: (request) =>
|
|
1287
|
+
checkRepositoryAccess({ ...request, transports: requireGitTransports(gitTransports) }),
|
|
1259
1288
|
spawnSession: (plan, keyed, access) => {
|
|
1260
1289
|
// Under an access, a spawn under a parent whose tree leaves the access
|
|
1261
1290
|
// before the harness starts is refused as a spawn under an unknown
|