@zgeoff/atc 2.17.0 → 2.20.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.17.0",
3
+ "version": "2.20.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",
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -205,6 +206,10 @@ export interface AgentAdapter {
205
206
  // Absent: the agent runs there as a local spawn plans it, with no files.
206
207
  readonly planGuestSpawn?: (opts: SpawnOptions, guest: GuestPaths) => GuestSpawnPlan | null;
207
208
 
209
+ // The refusal every start of this agent's harness gets, on any target,
210
+ // or null when it may start. Absent: no start is refused.
211
+ readonly findSpawnRefusal?: () => DaemonError | null;
212
+
208
213
  // The command that exits 0 inside a remote host when the agent there can
209
214
  // sign in without a person. Absent: atc runs no check.
210
215
  readonly planAuthCheck?: () => readonly string[];
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import { DaemonError } from '../protocol/daemon-error';
2
3
  import type { HookEvent } from '../protocol/hook-event';
3
4
  import type { AgentID } from '../shared/agent-id';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
@@ -39,7 +40,8 @@ export class GatewayAdapter implements AgentAdapter {
39
40
  readonly id: AgentID;
40
41
 
41
42
  // A headless turn carries the same settings file the terminal spawn does,
42
- // so it reaches this backend rather than the default one.
43
+ // so it reaches this backend rather than the default one. A gateway whose
44
+ // credential comes through impd's broker runs no headless turn.
43
45
  readonly headlessRunner: HeadlessRunner | null;
44
46
 
45
47
  // The CLI's hooks are authoritative; no screen heuristics needed.
@@ -90,7 +92,7 @@ export class GatewayAdapter implements AgentAdapter {
90
92
  this.claude = new ClaudeAdapter(config);
91
93
 
92
94
  this.headlessRunner =
93
- headlessRun === null
95
+ headlessRun === null || gateway.auth !== undefined
94
96
  ? null
95
97
  : makeClaudeHeadlessRunner(headlessRun, {
96
98
  claudeBin: gateway.bin,
@@ -100,6 +102,21 @@ export class GatewayAdapter implements AgentAdapter {
100
102
  });
101
103
  }
102
104
 
105
+ // A gateway whose credential comes through impd's broker never starts
106
+ // until the broker path exists: started without it, the CLI would send
107
+ // whatever credential it holds to the gateway's host.
108
+ findSpawnRefusal(): DaemonError | null {
109
+ if (this.gateway.auth === undefined) {
110
+ return null;
111
+ }
112
+
113
+ return new DaemonError(
114
+ 'auth_target_unsupported',
115
+ `gateway '${this.id}' takes its credential from impd's broker, and brokered credentials are not wired yet`,
116
+ { agent: this.id },
117
+ );
118
+ }
119
+
103
120
  planSpawn(opts: SpawnOptions): SpawnPlan {
104
121
  return {
105
122
  bin: this.gateway.bin,
@@ -142,7 +159,13 @@ export class GatewayAdapter implements AgentAdapter {
142
159
  // explicit flag, so that mode overrides the one the CLI would restore, and
143
160
  // the generated settings file, because
144
161
  // without it the CLI would resume the session against the default backend.
162
+ // A gateway whose credential comes through impd's broker has none, since
163
+ // outside atc the broker never reaches it.
145
164
  buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
165
+ if (this.gateway.auth !== undefined) {
166
+ return null;
167
+ }
168
+
146
169
  const args = [
147
170
  ...this.gateway.args,
148
171
  ...buildRestoreModeArgs(this.gateway.args, this.gateway.settings),
package/src/cli.ts CHANGED
@@ -182,7 +182,12 @@ const main = defineCommand({
182
182
  console.error(`atc daemon: config: ${line}`);
183
183
  }
184
184
 
185
- for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
185
+ for (const problem of [
186
+ ...cfg.principalErrors,
187
+ ...cfg.workspaceErrors,
188
+ ...cfg.authProfileErrors,
189
+ ...cfg.gatewayErrors,
190
+ ]) {
186
191
  console.error(`atc daemon: config: ${problem}`);
187
192
  }
188
193
 
@@ -0,0 +1,38 @@
1
+ interface DestinationParts {
2
+ // The root the checkout lands under, absolute on its target.
3
+ readonly root: string;
4
+
5
+ // The repository URL the checkout comes from, as the daemon resolved it.
6
+ readonly url: string;
7
+
8
+ // The branch or tag the commit was chosen by, or null for a bare commit.
9
+ readonly ref: string | null;
10
+ readonly sha: string;
11
+ }
12
+
13
+ /**
14
+ * The default directory a git workspace lands in: `<repo>-<ref>-<short
15
+ * sha>` under the root, with the ref left out for a bare commit. The repo
16
+ * is the URL's last path segment without `.git`, and each part keeps only
17
+ * `[A-Za-z0-9._-]`, with `/` turned into `-`, so the name is one path
18
+ * segment on any target.
19
+ */
20
+ export function buildWorkspaceDestination(parts: DestinationParts): string {
21
+ const repo =
22
+ parts.url
23
+ .replace(/\/+$/u, '')
24
+ .split(/[/:]/u)
25
+ .at(-1)
26
+ ?.replace(/\.git$/u, '') ?? '';
27
+
28
+ const name = [repo, ...(parts.ref === null ? [] : [parts.ref]), parts.sha.slice(0, 7)]
29
+ .map((part) => toSafeSegment(part))
30
+ .filter((part) => part !== '')
31
+ .join('-');
32
+
33
+ return `${parts.root.replace(/\/+$/u, '')}/${name}`;
34
+ }
35
+
36
+ function toSafeSegment(part: string): string {
37
+ return part.replaceAll('/', '-').replaceAll(/[^\w.-]/gu, '');
38
+ }
@@ -9,7 +9,8 @@ export interface AgentPick {
9
9
  /**
10
10
  * The agent choices the spawn and adopt flows offer, in menu order. An agent
11
11
  * whose configured binary does not resolve is left out, so every row in the
12
- * menu is a session that can start. Resolution follows the rule a spawn
12
+ * menu is a session that can start. A gateway with auth is left out too, since
13
+ * every start of one is refused. Resolution follows the rule a spawn
13
14
  * follows: a bare name comes off PATH, a name carrying a separator is taken
14
15
  * as a path, and either way it has to be executable.
15
16
  */
@@ -22,7 +23,9 @@ export function collectAgentPicks(config: Config): AgentPick[] {
22
23
  { agent: 'claude', label: 'Claude', bin: config.claudeBin },
23
24
  { agent: 'grok', label: 'Grok', bin: config.grokBin },
24
25
  { agent: 'codex', label: 'Codex', bin: config.codexBin },
25
- ...config.gateways.map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
+ ...config.gateways
27
+ .filter((g) => g.auth === undefined)
28
+ .map((g) => ({ agent: g.id, label: g.label, bin: g.bin })),
26
29
  ];
27
30
 
28
31
  return candidates
@@ -0,0 +1,29 @@
1
+ // The steps of the git spawn flow a refused spawn can return to.
2
+ type RefusalStep = 'confirm' | 'destination' | 'ref' | 'source';
3
+
4
+ const REFUSAL_STEPS: ReadonlyMap<string, RefusalStep> = new Map<string, RefusalStep>([
5
+ ['workspace_exists', 'destination'],
6
+ ['ref_not_found', 'ref'],
7
+ ['invalid_git_url', 'source'],
8
+ ['credential_in_url', 'source'],
9
+ ['credential_missing', 'source'],
10
+ ['clone_failed', 'source'],
11
+ ['has_submodules', 'confirm'],
12
+ ['lfs_unsupported', 'confirm'],
13
+ ['unreadable_tree', 'confirm'],
14
+ ['sanitize_failed', 'confirm'],
15
+ ['tar_failed', 'confirm'],
16
+ ['transfer_failed', 'confirm'],
17
+ ['workspace_mismatch', 'confirm'],
18
+ ]);
19
+
20
+ /**
21
+ * The step of the git spawn flow that can fix a workspace spawn's
22
+ * refusal: the destination for one that already exists, the ref for one
23
+ * the upstream lacks, the source step for a URL or clone failure, and the
24
+ * confirm screen for a refusal of the commit or the target's copy of it.
25
+ * Null for any other refusal, which stays on the step that sent the spawn.
26
+ */
27
+ export function pickRefusalStep(code: string): RefusalStep | null {
28
+ return REFUSAL_STEPS.get(code) ?? null;
29
+ }
@@ -0,0 +1,49 @@
1
+ import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
2
+ import { resolveHomeDir } from '../shared/resolve-home-dir';
3
+
4
+ type WorkspaceRoot =
5
+ | { readonly ok: true; readonly root: string }
6
+ | { readonly ok: false; readonly message: string };
7
+
8
+ interface RootTarget {
9
+ readonly id: string;
10
+
11
+ // Whether the target is the daemon's own machine, where `~` is this
12
+ // user's home.
13
+ readonly inPlace: boolean;
14
+ }
15
+
16
+ // Where checkouts land on the daemon's own machine without a configured root.
17
+ const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
18
+
19
+ /**
20
+ * The directory git workspaces land under on a target: the target's own
21
+ * root, else the global root, else the default. On the daemon's own
22
+ * machine a leading `~` expands to the home directory. On any other target
23
+ * the root must be absolute, since `~` there is a home this client cannot
24
+ * see, so a root that is not is refused with the config key to set.
25
+ */
26
+ export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarget): WorkspaceRoot {
27
+ const configured = config.targetRoots.get(target.id) ?? config.root;
28
+
29
+ if (target.inPlace) {
30
+ const root = expandHome(configured ?? DEFAULT_ROOT);
31
+
32
+ return root.startsWith('/')
33
+ ? { ok: true, root }
34
+ : { ok: false, message: `workspaces root '${root}' must be an absolute path` };
35
+ }
36
+
37
+ if (configured === null || !configured.startsWith('/')) {
38
+ return {
39
+ ok: false,
40
+ message: `set workspaces.targets.${target.id} in config.json to an absolute path on that target`,
41
+ };
42
+ }
43
+
44
+ return { ok: true, root: configured };
45
+ }
46
+
47
+ function expandHome(dir: string): string {
48
+ return dir === '~' || dir.startsWith('~/') ? `${resolveHomeDir()}${dir.slice(1)}` : dir;
49
+ }