@zgeoff/atc 2.26.7 → 2.28.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 +7 -2
- package/src/agents/gateway-adapter.ts +17 -0
- package/src/client/spawn-picker.ts +73 -3
- package/src/client/split-to-width.ts +47 -0
- package/src/client/ui.ts +9 -1
- package/src/daemon/daemon-connection.ts +6 -1
- package/src/daemon/materialize-workspace.ts +1 -1
- package/src/daemon/sessions.ts +78 -1
- package/src/mcp/mcp-tools.ts +5 -1
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +13 -1
- package/src/protocol/daemon-features.ts +3 -0
- package/src/protocol/request-param-schemas.ts +6 -2
- package/src/shared/collect-targets.ts +13 -0
- package/src/workspace/resolve-path-source.ts +21 -6
package/package.json
CHANGED
|
@@ -21,10 +21,11 @@ export interface SpawnOptions {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* The per-
|
|
25
|
-
* against what the agent advertises.
|
|
24
|
+
* The per-launch choices a spawn asks for. Model and effort are checked
|
|
25
|
+
* against what the agent advertises; workspace trust requires a verified clone.
|
|
26
26
|
*/
|
|
27
27
|
export interface SpawnOverrides {
|
|
28
|
+
readonly trustClonedWorkspace?: boolean;
|
|
28
29
|
readonly model?: string;
|
|
29
30
|
readonly effort?: string;
|
|
30
31
|
}
|
|
@@ -227,6 +228,10 @@ export interface AgentAdapter {
|
|
|
227
228
|
// broker is null.
|
|
228
229
|
readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
|
|
229
230
|
|
|
231
|
+
// Guest-relative seed files for the exact root of a verified clone.
|
|
232
|
+
// Absent or null means the adapter cannot accept workspace trust.
|
|
233
|
+
readonly planGuestWorkspaceTrust?: (root: string) => Readonly<Record<string, string>> | null;
|
|
234
|
+
|
|
230
235
|
// The credential this agent takes from impd's broker, or null when it
|
|
231
236
|
// takes none. Absent: it takes none.
|
|
232
237
|
readonly findAuthSelection?: () => AuthSelection | null;
|
|
@@ -241,6 +241,23 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
planGuestWorkspaceTrust(root: string): Readonly<Record<string, string>> | null {
|
|
245
|
+
if (this.gateway.auth === undefined) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
[CONFIG_SEED_FILE]: JSON.stringify(
|
|
251
|
+
{
|
|
252
|
+
...ONBOARDED_CONFIG,
|
|
253
|
+
projects: { [root]: { hasTrustDialogAccepted: true } },
|
|
254
|
+
},
|
|
255
|
+
null,
|
|
256
|
+
2,
|
|
257
|
+
),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
244
261
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
245
262
|
return this.claude.normalizeHook(e);
|
|
246
263
|
}
|
|
@@ -19,9 +19,19 @@ import { KEY, planTextEdit } from './keys';
|
|
|
19
19
|
import { pickRefusalStep } from './pick-refusal-step';
|
|
20
20
|
import { resolvePathInput } from './resolve-path-input';
|
|
21
21
|
import { resolveWorkspaceRoot } from './resolve-workspace-root';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
import { splitToWidth } from './split-to-width';
|
|
23
|
+
import { ansi, cols, drawPicker, getPickerWidth } from './ui';
|
|
24
|
+
|
|
25
|
+
type PickerStep =
|
|
26
|
+
| 'agent'
|
|
27
|
+
| 'dir'
|
|
28
|
+
| 'source'
|
|
29
|
+
| 'ref'
|
|
30
|
+
| 'target'
|
|
31
|
+
| 'confirm'
|
|
32
|
+
| 'name'
|
|
33
|
+
| 'prompt'
|
|
34
|
+
| 'spawned';
|
|
25
35
|
|
|
26
36
|
// What the flow borrows from the client that owns the screen, the daemon
|
|
27
37
|
// connection and the fleet mirror.
|
|
@@ -226,6 +236,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
226
236
|
|
|
227
237
|
private pending: PendingRequest | null = null;
|
|
228
238
|
|
|
239
|
+
// A session the daemon started with warnings, and those warnings, shown
|
|
240
|
+
// before the flow attaches it.
|
|
241
|
+
private spawned: TMirror | null = null;
|
|
242
|
+
|
|
243
|
+
private spawnWarnings: readonly string[] = [];
|
|
244
|
+
|
|
229
245
|
private requestSeq = 0;
|
|
230
246
|
|
|
231
247
|
// The flow's generation. Opening the flow and every way out of it moves
|
|
@@ -281,6 +297,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
281
297
|
applyKey(buf: Buffer) {
|
|
282
298
|
this.refusal = null;
|
|
283
299
|
|
|
300
|
+
if (this.step === 'spawned') {
|
|
301
|
+
this.applySpawnedKey(buf);
|
|
302
|
+
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
284
306
|
const edit = planTextEdit(buf, this.input, {
|
|
285
307
|
isLeaderKey: this.deps.isLeaderKey,
|
|
286
308
|
moves:
|
|
@@ -387,6 +409,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
387
409
|
this.renderTargetStep(verb);
|
|
388
410
|
} else if (this.step === 'confirm') {
|
|
389
411
|
this.renderConfirmStep();
|
|
412
|
+
} else if (this.step === 'spawned') {
|
|
413
|
+
drawPicker({
|
|
414
|
+
title: `${verb}: started`,
|
|
415
|
+
items: this.spawnWarnings.flatMap((w) => splitToWidth(w, getPickerWidth() - 4)),
|
|
416
|
+
selected: -1,
|
|
417
|
+
input: '',
|
|
418
|
+
hint: '⏎ attach · esc back',
|
|
419
|
+
});
|
|
390
420
|
} else if (this.step === 'name') {
|
|
391
421
|
const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
|
|
392
422
|
|
|
@@ -1714,9 +1744,49 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
1714
1744
|
this.stopFlow();
|
|
1715
1745
|
this.deps.upsertMirror(session);
|
|
1716
1746
|
|
|
1747
|
+
const warnings = spawned.answer['warnings'];
|
|
1748
|
+
|
|
1749
|
+
// A workspace that left changes behind says so before the session's
|
|
1750
|
+
// screen takes over.
|
|
1751
|
+
if (Array.isArray(warnings) && warnings.length > 0) {
|
|
1752
|
+
this.spawned = session;
|
|
1753
|
+
this.spawnWarnings = warnings.filter((w): w is string => typeof w === 'string');
|
|
1754
|
+
this.step = 'spawned';
|
|
1755
|
+
|
|
1756
|
+
process.stdout.write(ansi.clear);
|
|
1757
|
+
this.render();
|
|
1758
|
+
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1717
1762
|
await this.deps.attach(session.id);
|
|
1718
1763
|
}
|
|
1719
1764
|
|
|
1765
|
+
// The started step takes Enter to attach the session and Esc or the
|
|
1766
|
+
// leader to return to the screen the flow came from; the session runs
|
|
1767
|
+
// either way.
|
|
1768
|
+
private applySpawnedKey(buf: Buffer) {
|
|
1769
|
+
const edit = planTextEdit(buf, '', { isLeaderKey: this.deps.isLeaderKey, moves: false });
|
|
1770
|
+
|
|
1771
|
+
if (edit.kind !== 'submit' && edit.kind !== 'cancel' && edit.kind !== 'leader') {
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const session = this.spawned;
|
|
1776
|
+
|
|
1777
|
+
this.spawned = null;
|
|
1778
|
+
this.spawnWarnings = [];
|
|
1779
|
+
this.step = 'agent';
|
|
1780
|
+
|
|
1781
|
+
if (edit.kind === 'submit' && session !== null) {
|
|
1782
|
+
void this.deps.attach(session.id);
|
|
1783
|
+
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
this.deps.toBase();
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1720
1790
|
// A refused git workspace spawn returns to the step that can fix it. Any
|
|
1721
1791
|
// other refusal keeps the picker on the step that sent it, with the
|
|
1722
1792
|
// entered text back in the input, so the user can fix the cause and retry
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits text into rows of at most `width` characters, breaking at spaces
|
|
3
|
+
* and counting a grapheme cluster as one character. A word wider than a
|
|
4
|
+
* row breaks at the row's edge, never inside a character.
|
|
5
|
+
*/
|
|
6
|
+
export function splitToWidth(text: string, width: number): string[] {
|
|
7
|
+
if (width <= 0) {
|
|
8
|
+
return [text];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const segmenter = new Intl.Segmenter();
|
|
12
|
+
|
|
13
|
+
const rows: string[] = [];
|
|
14
|
+
let row: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const word of text.split(' ')) {
|
|
17
|
+
let rest = Array.from(segmenter.segment(word), (part) => part.segment);
|
|
18
|
+
|
|
19
|
+
while (rest.length > width) {
|
|
20
|
+
if (row.length > 0) {
|
|
21
|
+
rows.push(row.join(''));
|
|
22
|
+
|
|
23
|
+
row = [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
rows.push(rest.slice(0, width).join(''));
|
|
27
|
+
|
|
28
|
+
rest = rest.slice(width);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (row.length === 0) {
|
|
32
|
+
row = rest;
|
|
33
|
+
} else if (row.length + 1 + rest.length <= width) {
|
|
34
|
+
row = [...row, ' ', ...rest];
|
|
35
|
+
} else {
|
|
36
|
+
rows.push(row.join(''));
|
|
37
|
+
|
|
38
|
+
row = rest;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (row.length > 0) {
|
|
43
|
+
rows.push(row.join(''));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return rows;
|
|
47
|
+
}
|
package/src/client/ui.ts
CHANGED
|
@@ -65,6 +65,14 @@ export function cols(): number {
|
|
|
65
65
|
return process.stdout.columns || 80;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The width of the picker box, borders included. An item row holds 4
|
|
70
|
+
* columns fewer.
|
|
71
|
+
*/
|
|
72
|
+
export function getPickerWidth(): number {
|
|
73
|
+
return Math.min(cols() - 4, 90);
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
export function rows(): number {
|
|
69
77
|
return process.stdout.rows || 24;
|
|
70
78
|
}
|
|
@@ -393,7 +401,7 @@ export interface PickerView {
|
|
|
393
401
|
}
|
|
394
402
|
|
|
395
403
|
export function drawPicker(view: PickerView) {
|
|
396
|
-
const width =
|
|
404
|
+
const width = getPickerWidth();
|
|
397
405
|
const rowsList: Row[] = [boxTop(width, view.title)];
|
|
398
406
|
const shown = view.items.slice(0, 10);
|
|
399
407
|
|
|
@@ -1008,7 +1008,12 @@ export class DaemonConnection {
|
|
|
1008
1008
|
namedBy: data.name === '' ? 'auto' : 'user',
|
|
1009
1009
|
agent,
|
|
1010
1010
|
parent,
|
|
1011
|
-
overrides:
|
|
1011
|
+
overrides: {
|
|
1012
|
+
...overrides.overrides,
|
|
1013
|
+
...(data.trustClonedWorkspace === undefined
|
|
1014
|
+
? {}
|
|
1015
|
+
: { trustClonedWorkspace: data.trustClonedWorkspace }),
|
|
1016
|
+
},
|
|
1012
1017
|
target,
|
|
1013
1018
|
workspace: data.workspace ?? null,
|
|
1014
1019
|
};
|
|
@@ -326,7 +326,7 @@ async function resolveSource(
|
|
|
326
326
|
await requireNoOriginRewriteCredentials(source.path);
|
|
327
327
|
|
|
328
328
|
const resolved = await resolvePathSource(source.path, {
|
|
329
|
-
allowDirty: source.allowDirty
|
|
329
|
+
...(source.allowDirty === undefined ? {} : { allowDirty: source.allowDirty }),
|
|
330
330
|
transports,
|
|
331
331
|
});
|
|
332
332
|
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -893,6 +893,10 @@ export class SessionManager {
|
|
|
893
893
|
const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
|
|
894
894
|
const provider = execution.provider;
|
|
895
895
|
|
|
896
|
+
const trustClonedWorkspace =
|
|
897
|
+
overrides.trustClonedWorkspace ??
|
|
898
|
+
this.targets.get(target)?.options['trustClonedWorkspace'] === true;
|
|
899
|
+
|
|
896
900
|
// The repository root resolves before the process starts: resolving it
|
|
897
901
|
// can throw, and a spawn that throws must leave nothing running. A
|
|
898
902
|
// remote directory is not on the daemon's machine, so it is its own root.
|
|
@@ -900,6 +904,24 @@ export class SessionManager {
|
|
|
900
904
|
const hostKey = this.pickHostKey(id, parent, target, execution.identity);
|
|
901
905
|
const auth = this.resolveHarnessAuth(adapter, provider, target);
|
|
902
906
|
|
|
907
|
+
if (trustClonedWorkspace) {
|
|
908
|
+
if (materialize === null) {
|
|
909
|
+
throw new DaemonError('bad_args', 'trustClonedWorkspace requires a workspace source');
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (
|
|
913
|
+
provider.kind !== 'imp' ||
|
|
914
|
+
!provider.remote ||
|
|
915
|
+
auth === null ||
|
|
916
|
+
adapter.planGuestWorkspaceTrust === undefined
|
|
917
|
+
) {
|
|
918
|
+
throw new DaemonError(
|
|
919
|
+
'unsupported',
|
|
920
|
+
'trustClonedWorkspace requires a brokered Claude gateway on an imp target',
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
903
925
|
if (hostKey !== id) {
|
|
904
926
|
await this.requireSharedBinding(hostKey, auth?.binding ?? null);
|
|
905
927
|
}
|
|
@@ -938,6 +960,24 @@ export class SessionManager {
|
|
|
938
960
|
authSetup,
|
|
939
961
|
);
|
|
940
962
|
|
|
963
|
+
const trustWorkspace = trustClonedWorkspace
|
|
964
|
+
? async (root: string) => {
|
|
965
|
+
const planned = adapter.planGuestWorkspaceTrust?.(root);
|
|
966
|
+
|
|
967
|
+
if (planned === undefined || planned === null || provider.guest === undefined) {
|
|
968
|
+
throw new DaemonError('unsupported', 'this adapter cannot trust a cloned workspace');
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const files = Object.entries(planned).map(([path, content]) => ({ path, content }));
|
|
972
|
+
|
|
973
|
+
await provider.transferArchive(
|
|
974
|
+
buildTarArchive(files),
|
|
975
|
+
`${provider.guest.dir}/sessions/${id}`,
|
|
976
|
+
hostKey,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
: null;
|
|
980
|
+
|
|
941
981
|
// The host stays readying until its workspace is in place, so nothing
|
|
942
982
|
// gives its lease back or puts it to sleep in between.
|
|
943
983
|
const prepared = await this.withHostReadying(hostKey, async () => {
|
|
@@ -954,6 +994,7 @@ export class SessionManager {
|
|
|
954
994
|
execution.identity,
|
|
955
995
|
materialize,
|
|
956
996
|
setupHost,
|
|
997
|
+
trustWorkspace,
|
|
957
998
|
);
|
|
958
999
|
});
|
|
959
1000
|
|
|
@@ -1428,8 +1469,12 @@ export class SessionManager {
|
|
|
1428
1469
|
targetIdentity: string,
|
|
1429
1470
|
materialize: SpawnMaterializer,
|
|
1430
1471
|
setupHost: () => Promise<HarnessSetup>,
|
|
1472
|
+
trustWorkspace: ((root: string) => Promise<void>) | null,
|
|
1431
1473
|
): Promise<{ readonly setup: HarnessSetup; readonly materialized: MaterializedSpawn | null }> {
|
|
1432
|
-
const readied: { setup: HarnessSetup | null
|
|
1474
|
+
const readied: { setup: HarnessSetup | null; root: string | null } = {
|
|
1475
|
+
setup: null,
|
|
1476
|
+
root: null,
|
|
1477
|
+
};
|
|
1433
1478
|
|
|
1434
1479
|
try {
|
|
1435
1480
|
const materialized = await materialize(
|
|
@@ -1441,6 +1486,8 @@ export class SessionManager {
|
|
|
1441
1486
|
? await this.claimHostDir(provider, id, hostKey, target, dir)
|
|
1442
1487
|
: await this.resolveHostDir(provider, null, dir);
|
|
1443
1488
|
|
|
1489
|
+
readied.root = landing;
|
|
1490
|
+
|
|
1444
1491
|
return { host: hostKey, dir: landing };
|
|
1445
1492
|
},
|
|
1446
1493
|
removeClaim: (landing) => this.removeClaimedDir(provider, id, hostKey, target, landing),
|
|
@@ -1450,6 +1497,36 @@ export class SessionManager {
|
|
|
1450
1497
|
|
|
1451
1498
|
readied.setup ??= await setupHost();
|
|
1452
1499
|
|
|
1500
|
+
if (trustWorkspace !== null) {
|
|
1501
|
+
if (materialized === null || readied.root === null) {
|
|
1502
|
+
throw new DaemonError(
|
|
1503
|
+
'bad_args',
|
|
1504
|
+
'trustClonedWorkspace requires a successfully cloned workspace',
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
try {
|
|
1509
|
+
await trustWorkspace(readied.root);
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
let removed = false;
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
removed = await this.removeClaimedDir(provider, id, hostKey, target, readied.root);
|
|
1515
|
+
} catch {
|
|
1516
|
+
// A failed rollback retains the spawn's claim for reconciliation.
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (!removed) {
|
|
1520
|
+
throw new EffectRemainsError(
|
|
1521
|
+
'workspace trust setup failed and its clone could not be removed',
|
|
1522
|
+
{ cause: error },
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
throw error;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1453
1530
|
return { setup: readied.setup, materialized };
|
|
1454
1531
|
} catch (error) {
|
|
1455
1532
|
if (readied.setup !== null) {
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -58,7 +58,10 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
58
58
|
'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
|
|
59
59
|
),
|
|
60
60
|
workspace: SPAWN_SCHEMA.shape.workspace.describe(
|
|
61
|
-
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host,
|
|
61
|
+
"Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
|
|
62
|
+
),
|
|
63
|
+
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
64
|
+
'Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source, an imp target, and a brokered Claude gateway with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.',
|
|
62
65
|
),
|
|
63
66
|
detached: z
|
|
64
67
|
.boolean()
|
|
@@ -403,6 +406,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
403
406
|
idempotencyKey: 'spawn.idempotency',
|
|
404
407
|
target: 'spawn.target',
|
|
405
408
|
workspace: 'spawn.workspace',
|
|
409
|
+
trustClonedWorkspace: 'spawn.workspace.trust',
|
|
406
410
|
},
|
|
407
411
|
},
|
|
408
412
|
},
|
|
@@ -19,6 +19,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
19
19
|
'report.get': 'atc_report_get',
|
|
20
20
|
'request.principal': 'the target limits of a remote MCP client',
|
|
21
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
22
|
+
'spawn.workspace.trust': "atc_session_spawn's trustClonedWorkspace",
|
|
22
23
|
sources: 'sources.list and sources.interpret',
|
|
23
24
|
'git.probe': 'git.probe',
|
|
24
25
|
'transport.tcp': 'a TCP connection to the daemon',
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function runTool(
|
|
|
54
54
|
...(key === undefined ? {} : { idempotencyKey: key }),
|
|
55
55
|
...(args['target'] === undefined ? {} : { target: args['target'] }),
|
|
56
56
|
...(args['workspace'] === undefined ? {} : { workspace: args['workspace'] }),
|
|
57
|
+
...(args['trustClonedWorkspace'] === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { trustClonedWorkspace: args['trustClonedWorkspace'] }),
|
|
57
60
|
cols: 100,
|
|
58
61
|
rows: 30,
|
|
59
62
|
};
|
|
@@ -71,7 +74,16 @@ export function runTool(
|
|
|
71
74
|
const workspaceFeatures: readonly DaemonFeature[] =
|
|
72
75
|
args['workspace'] === undefined ? [] : ['spawn.workspace'];
|
|
73
76
|
|
|
74
|
-
const
|
|
77
|
+
const trustFeatures: readonly DaemonFeature[] =
|
|
78
|
+
args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
|
|
79
|
+
|
|
80
|
+
const required = [
|
|
81
|
+
...optionFeatures,
|
|
82
|
+
...keyFeatures,
|
|
83
|
+
...targetFeatures,
|
|
84
|
+
...workspaceFeatures,
|
|
85
|
+
...trustFeatures,
|
|
86
|
+
];
|
|
75
87
|
|
|
76
88
|
const ok =
|
|
77
89
|
nested && ctx.callerSessionID !== null
|
|
@@ -47,6 +47,9 @@ export const DAEMON_FEATURES = [
|
|
|
47
47
|
// `workspace` its checkout was materialized from.
|
|
48
48
|
'spawn.workspace',
|
|
49
49
|
|
|
50
|
+
// `session.spawn` takes an explicit trust decision for a cloned imp workspace.
|
|
51
|
+
'spawn.workspace.trust',
|
|
52
|
+
|
|
50
53
|
// `session.forget` exists, and a kill of a session asleep on a target that
|
|
51
54
|
// can destroy its host answers `confirmation_required`.
|
|
52
55
|
'session.forget',
|
|
@@ -54,8 +54,9 @@ const GIT_SHA = z.string().regex(COMMIT_ID, 'a git workspace sha is a full commi
|
|
|
54
54
|
* Where a spawn's working directory comes from, materialized as a clean
|
|
55
55
|
* checkout into the spawn's `cwd` on its execution target. A `path` source
|
|
56
56
|
* is a directory on the daemon's host, resolved to its origin URL and
|
|
57
|
-
* pushed HEAD
|
|
58
|
-
*
|
|
57
|
+
* pushed HEAD. A tree with uncommitted or untracked changes resolves to
|
|
58
|
+
* HEAD and leaves the changes behind with a warning, unless `allowDirty`
|
|
59
|
+
* is `'refuse'`, which refuses it. A `git`
|
|
59
60
|
* source is a repository URL with a branch or tag `ref`, a full commit
|
|
60
61
|
* `sha`, or both, and an optional `credentialRef` naming the daemon
|
|
61
62
|
* environment variable that holds its token. With both, the sha is the
|
|
@@ -185,6 +186,9 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
185
186
|
// session in cwd as it stands, and a source materializes a clean
|
|
186
187
|
// checkout into cwd first.
|
|
187
188
|
workspace: WORKSPACE_SOURCE.optional(),
|
|
189
|
+
trustClonedWorkspace: z
|
|
190
|
+
.boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
|
|
191
|
+
.optional(),
|
|
188
192
|
|
|
189
193
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
190
194
|
// top-level session.
|
|
@@ -135,6 +135,19 @@ function collectTargetEntries(raw: unknown): TargetEntries {
|
|
|
135
135
|
continue;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
if (
|
|
139
|
+
entry['trustClonedWorkspace'] !== undefined &&
|
|
140
|
+
typeof entry['trustClonedWorkspace'] !== 'boolean'
|
|
141
|
+
) {
|
|
142
|
+
errors.push({
|
|
143
|
+
scope: 'target',
|
|
144
|
+
target: id,
|
|
145
|
+
problem: `target ${JSON.stringify(id)} must give trustClonedWorkspace as a boolean`,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
const { provider: _, ...options } = entry;
|
|
139
152
|
|
|
140
153
|
targets.push({ id, provider, options });
|
|
@@ -39,8 +39,9 @@ interface PathSourceRefusal {
|
|
|
39
39
|
* can clone, so a workspace never carries files that exist only here. The
|
|
40
40
|
* commit is HEAD, and it must already be on origin: a remote-tracking ref
|
|
41
41
|
* under origin contains it, or origin advertises it as a ref tip. A tree
|
|
42
|
-
* with uncommitted or untracked changes
|
|
43
|
-
*
|
|
42
|
+
* with uncommitted or untracked changes resolves to HEAD and leaves those
|
|
43
|
+
* changes behind, with a warning that counts them; `allowDirty: 'refuse'`
|
|
44
|
+
* refuses it instead. The changes are never copied.
|
|
44
45
|
* Submodules are refused. The URL is origin's, with any credential stripped.
|
|
45
46
|
*/
|
|
46
47
|
export async function resolvePathSource(
|
|
@@ -81,7 +82,9 @@ export async function resolvePathSource(
|
|
|
81
82
|
return { ok: false, code: 'has_submodules', message: `${root} uses submodules` };
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
// Every untracked file is listed on its own, so an untracked directory
|
|
86
|
+
// counts each file inside it.
|
|
87
|
+
const status = await runGit(['status', '--porcelain', '--untracked-files=all'], { cwd: root });
|
|
85
88
|
|
|
86
89
|
if (status.exitCode !== 0) {
|
|
87
90
|
return {
|
|
@@ -91,10 +94,11 @@ export async function resolvePathSource(
|
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
const
|
|
97
|
+
const changed = countChangedPaths(status.stdout);
|
|
98
|
+
const dirty = changed > 0;
|
|
95
99
|
const warnings: string[] = [];
|
|
96
100
|
|
|
97
|
-
if (dirty && options.allowDirty
|
|
101
|
+
if (dirty && options.allowDirty === 'refuse') {
|
|
98
102
|
return {
|
|
99
103
|
ok: false,
|
|
100
104
|
code: 'workspace_dirty',
|
|
@@ -103,7 +107,10 @@ export async function resolvePathSource(
|
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
if (dirty) {
|
|
106
|
-
|
|
110
|
+
// The commit leads, so a display that cuts the note short keeps it.
|
|
111
|
+
warnings.push(
|
|
112
|
+
`cloned commit ${sha.slice(0, 12)}; left ${changed} uncommitted or untracked ${changed === 1 ? 'path' : 'paths'} behind in ${root}`,
|
|
113
|
+
);
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
const origin = await runGit(['ls-remote', '--get-url', 'origin'], { cwd: root });
|
|
@@ -150,6 +157,14 @@ function hasSubmodules(listing: string): boolean {
|
|
|
150
157
|
.some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'));
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
/**
|
|
161
|
+
* How many paths a porcelain status listing holds. Only the count leaves
|
|
162
|
+
* this module: a path's name can itself be sensitive.
|
|
163
|
+
*/
|
|
164
|
+
function countChangedPaths(porcelain: string): number {
|
|
165
|
+
return porcelain.split('\n').filter((line) => line.trim() !== '').length;
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
/**
|
|
154
169
|
* Whether origin already has the commit. A local remote-tracking ref proves
|
|
155
170
|
* it without network access; failing that, origin's advertised ref tips are
|