@zgeoff/atc 2.26.7 → 2.27.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.26.7",
3
+ "version": "2.27.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -21,10 +21,11 @@ export interface SpawnOptions {
21
21
  }
22
22
 
23
23
  /**
24
- * The per-session model and effort a spawn asks for, already checked
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
  }
@@ -1008,7 +1008,12 @@ export class DaemonConnection {
1008
1008
  namedBy: data.name === '' ? 'auto' : 'user',
1009
1009
  agent,
1010
1010
  parent,
1011
- overrides: overrides.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
  };
@@ -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 } = { setup: 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) {
@@ -60,6 +60,9 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
60
60
  workspace: SPAWN_SCHEMA.shape.workspace.describe(
61
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, refusing uncommitted changes unless allowDirty is 'warn'; {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
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.',
65
+ ),
63
66
  detached: z
64
67
  .boolean()
65
68
  .optional()
@@ -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',
@@ -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 required = [...optionFeatures, ...keyFeatures, ...targetFeatures, ...workspaceFeatures];
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',
@@ -185,6 +185,9 @@ export const REQUEST_PARAM_SCHEMAS = {
185
185
  // session in cwd as it stands, and a source materializes a clean
186
186
  // checkout into cwd first.
187
187
  workspace: WORKSPACE_SOURCE.optional(),
188
+ trustClonedWorkspace: z
189
+ .boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
190
+ .optional(),
188
191
 
189
192
  // The session the new one is a sub-session of; absent or empty spawns a
190
193
  // 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 });