@zgeoff/atc 2.31.0 → 2.31.1

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.31.0",
3
+ "version": "2.31.1",
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",
@@ -232,6 +232,12 @@ export interface AgentAdapter {
232
232
  // Absent or null means the adapter cannot accept workspace trust.
233
233
  readonly planGuestWorkspaceTrust?: (root: string) => Readonly<Record<string, string>> | null;
234
234
 
235
+ // Accepts folder trust for the exact root of a verified clone on the
236
+ // daemon's machine, in the agent's own config, and resolves to a function
237
+ // that takes it back for a launch that fails before the agent starts.
238
+ // Absent means the adapter cannot accept workspace trust there.
239
+ readonly updateLocalWorkspaceTrust?: (root: string) => Promise<() => Promise<void>>;
240
+
235
241
  // The credential this agent takes from impd's broker, or null when it
236
242
  // takes none. Absent: it takes none.
237
243
  readonly findAuthSelection?: () => AuthSelection | null;
@@ -30,8 +30,10 @@ import { findFlagValue } from './find-flag-value';
30
30
  import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
31
31
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
32
32
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
33
- import { planTypedLineInput } from './plan-typed-line-input';
33
+ import { planPastedLineInput } from './plan-pasted-line-input';
34
+ import { resolveClaudeGlobalConfigPath } from './resolve-claude-global-config-path';
34
35
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
36
+ import { updateClaudeProjectTrust } from './update-claude-project-trust';
35
37
  import { writeATCBridge } from './write-atc-bridge';
36
38
  import { writeHookSettings } from './write-hook-settings';
37
39
 
@@ -62,8 +64,9 @@ export class ClaudeAdapter implements AgentAdapter {
62
64
 
63
65
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
64
66
 
65
- // Claude's TUI submits a line typed with its newline in one write.
66
- readonly planLineInput = planTypedLineInput;
67
+ // Claude's TUI takes a long burst of input as a paste and keeps its
68
+ // newline in the composer, so a line is pasted and then submitted.
69
+ readonly planLineInput = planPastedLineInput;
67
70
 
68
71
  readonly profile: AgentProfile;
69
72
 
@@ -153,6 +156,12 @@ export class ClaudeAdapter implements AgentAdapter {
153
156
  return this.bridgeDir;
154
157
  }
155
158
 
159
+ // A session on the daemon's machine reads the user's own Claude config,
160
+ // so trust for the clone is that config's entry for the clone alone.
161
+ updateLocalWorkspaceTrust(root: string): Promise<() => Promise<void>> {
162
+ return updateClaudeProjectTrust(resolveClaudeGlobalConfigPath(), root);
163
+ }
164
+
156
165
  normalizeHook(e: HookEvent): AdapterEvent {
157
166
  const parsed = CLAUDE_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
158
167
  const payload: ClaudeHookPayload = parsed.success ? parsed.data : {};
@@ -33,7 +33,7 @@ import { findFlagValue } from './find-flag-value';
33
33
  import { makeClaudeHeadlessRunner } from './make-claude-headless-runner';
34
34
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
35
35
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
36
- import { planTypedLineInput } from './plan-typed-line-input';
36
+ import { planPastedLineInput } from './plan-pasted-line-input';
37
37
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
38
38
  import { writeATCBridge } from './write-atc-bridge';
39
39
  import { writeHookSettings } from './write-hook-settings';
@@ -60,7 +60,7 @@ export class GatewayAdapter implements AgentAdapter {
60
60
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
61
61
 
62
62
  // The gateway runs the Claude CLI, whose TUI takes a line the same way.
63
- readonly planLineInput = planTypedLineInput;
63
+ readonly planLineInput = planPastedLineInput;
64
64
 
65
65
  readonly takesMessages = true;
66
66
 
@@ -13,9 +13,14 @@ const PASTE_END = '\u001B[201~';
13
13
  * arrive. Paste markers inside the text are dropped, so the text can neither
14
14
  * end the paste early nor start one of its own. A TUI that has not turned
15
15
  * bracketed paste on gets the text unmarked, and may still read both writes
16
- * as one burst.
16
+ * as one burst. Empty text is the carriage return alone, which submits what
17
+ * the composer holds and adds nothing to it.
17
18
  */
18
19
  export function planPastedLineInput(text: string, modes: TerminalInputModes): readonly string[] {
20
+ if (text === '') {
21
+ return ['\r'];
22
+ }
23
+
19
24
  const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
20
25
 
21
26
  return modes.bracketedPaste ? [`${PASTE_START}${unmarked}${PASTE_END}`, '\r'] : [unmarked, '\r'];
@@ -0,0 +1,18 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { resolveHomeDir } from '../shared/resolve-home-dir';
4
+
5
+ /**
6
+ * The file the Claude CLI on the daemon's machine keeps its global state
7
+ * in, folder trust included, resolved as the CLI resolves it: a legacy
8
+ * `.config.json` in its config folder when one exists, otherwise
9
+ * `.claude.json` in `$CLAUDE_CONFIG_DIR`, or in the user's home when that
10
+ * is unset or empty.
11
+ */
12
+ export function resolveClaudeGlobalConfigPath(): string {
13
+ const configDir = process.env['CLAUDE_CONFIG_DIR'];
14
+ const custom = configDir !== undefined && configDir !== '' ? configDir : null;
15
+ const legacy = join(custom ?? join(resolveHomeDir(), '.claude'), '.config.json');
16
+
17
+ return existsSync(legacy) ? legacy : join(custom ?? resolveHomeDir(), '.claude.json');
18
+ }
@@ -0,0 +1,119 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { isRecord } from '../shared/report';
5
+ import { withClaudeConfigLock } from './with-claude-config-lock';
6
+
7
+ /**
8
+ * Accepts the Claude CLI's folder trust for one exact directory in its
9
+ * global config, under the lock the CLI writes that config under. Only the
10
+ * directory's entry in `projects` changes: every other key keeps its value,
11
+ * and an entry already trusted leaves the file as it is. The write lands
12
+ * through a rename, so a reader sees the old file or the new one, never
13
+ * part of either.
14
+ *
15
+ * Resolves to a function that takes the trust back, for a launch that
16
+ * fails before the CLI starts: it puts back the entry as it was, unless
17
+ * something has changed the entry since, which leaves it as it stands.
18
+ */
19
+ export async function updateClaudeProjectTrust(
20
+ configPath: string,
21
+ root: string,
22
+ ): Promise<() => Promise<void>> {
23
+ const previous = await withClaudeConfigLock(configPath, async () => {
24
+ const config = await loadClaudeConfig(configPath);
25
+
26
+ const projects = isRecord(config['projects']) ? config['projects'] : {};
27
+ const entry = projects[root];
28
+
29
+ if (isRecord(entry) && entry['hasTrustDialogAccepted'] === true) {
30
+ return null;
31
+ }
32
+
33
+ const base = isRecord(entry) ? entry : {};
34
+
35
+ await writeClaudeConfig(configPath, {
36
+ ...config,
37
+ projects: { ...projects, [root]: { ...base, hasTrustDialogAccepted: true } },
38
+ });
39
+
40
+ return { entry: isRecord(entry) ? entry : null };
41
+ });
42
+
43
+ if (previous === null) {
44
+ return () => Promise.resolve();
45
+ }
46
+
47
+ const written = JSON.stringify({
48
+ ...previous.entry,
49
+ hasTrustDialogAccepted: true,
50
+ });
51
+
52
+ return () =>
53
+ withClaudeConfigLock(configPath, async () => {
54
+ const config = await loadClaudeConfig(configPath);
55
+
56
+ const projects = isRecord(config['projects']) ? config['projects'] : {};
57
+
58
+ if (JSON.stringify(projects[root]) !== written) {
59
+ return;
60
+ }
61
+
62
+ const { [root]: _trusted, ...others } = projects;
63
+
64
+ await writeClaudeConfig(configPath, {
65
+ ...config,
66
+ projects: previous.entry === null ? others : { ...others, [root]: previous.entry },
67
+ });
68
+ });
69
+ }
70
+
71
+ // The config as the CLI last wrote it; a missing file is an empty config,
72
+ // and one that does not parse to an object throws rather than be replaced.
73
+ async function loadClaudeConfig(configPath: string): Promise<Record<string, unknown>> {
74
+ let raw: string;
75
+
76
+ try {
77
+ raw = await readFile(configPath, 'utf8');
78
+ } catch (error) {
79
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
80
+ return {};
81
+ }
82
+
83
+ throw error;
84
+ }
85
+
86
+ const parsed: unknown = JSON.parse(raw);
87
+
88
+ if (!isRecord(parsed)) {
89
+ throw new Error(`${configPath} does not hold a JSON object`);
90
+ }
91
+
92
+ return parsed;
93
+ }
94
+
95
+ // Writes the config in the CLI's own layout, beside the file it replaces
96
+ // and with that file's mode, then renames it into place. A config path
97
+ // that is a symlink keeps the link and replaces the file it points at.
98
+ async function writeClaudeConfig(
99
+ configPath: string,
100
+ config: Readonly<Record<string, unknown>>,
101
+ ): Promise<void> {
102
+ const target = await realpath(configPath).catch(() => configPath);
103
+
104
+ const mode = await stat(target).then(
105
+ (stats) => stats.mode & 0o777,
106
+ () => 0o600,
107
+ );
108
+
109
+ const temp = join(dirname(target), `.atc-trust-${randomUUID()}.tmp`);
110
+
111
+ try {
112
+ await writeFile(temp, JSON.stringify(config, null, 2), { mode, flag: 'wx' });
113
+ await rename(temp, target);
114
+ } catch (error) {
115
+ await rm(temp, { force: true });
116
+
117
+ throw error;
118
+ }
119
+ }
@@ -0,0 +1,135 @@
1
+ import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+
4
+ // A lock older than this belongs to a holder that died: the Claude CLI's
5
+ // lock library refreshes a held lock's age well inside it.
6
+ const STALE_MS = 10_000;
7
+
8
+ // How often a held lock's age is refreshed, well inside the stale age.
9
+ const REFRESH_MS = 1000;
10
+
11
+ // How long a caller waits for the lock before it gives up.
12
+ const WAIT_MS = 5000;
13
+ const RETRY_MS = 50;
14
+
15
+ /**
16
+ * Runs the callback while holding the lock the Claude CLI takes before it
17
+ * writes its global config: a directory beside the file named for it with
18
+ * a `.lock` suffix, created with `mkdir` so only one holder succeeds. The
19
+ * config's folder is created first when it does not exist yet. A lock left
20
+ * by a dead holder is taken over once it goes stale, and a held lock's age
21
+ * is refreshed while the callback runs so no one takes it over. Release
22
+ * removes the lock only while it is still the directory this call created.
23
+ * Waiting longer than a few seconds throws without running the callback.
24
+ */
25
+ export async function withClaudeConfigLock<T>(
26
+ configPath: string,
27
+ run: () => Promise<T>,
28
+ ): Promise<T> {
29
+ const lockPath = `${configPath}.lock`;
30
+ const deadline = Date.now() + WAIT_MS;
31
+
32
+ await mkdir(dirname(configPath), { recursive: true });
33
+
34
+ let held = await tryCreateLockDir(lockPath);
35
+
36
+ while (held === null) {
37
+ if (Date.now() > deadline) {
38
+ throw new Error(`timed out waiting for the Claude config lock ${lockPath}`);
39
+ }
40
+
41
+ await Bun.sleep(RETRY_MS);
42
+
43
+ held = await tryCreateLockDir(lockPath);
44
+ }
45
+
46
+ let owned: OwnedLock = held;
47
+ let refreshing = Promise.resolve();
48
+
49
+ const timer = setInterval(() => {
50
+ refreshing = (async () => {
51
+ const refreshed = await refreshOwnedLock(lockPath, owned);
52
+
53
+ owned = refreshed ?? owned;
54
+ })();
55
+ }, REFRESH_MS);
56
+
57
+ try {
58
+ return await run();
59
+ } finally {
60
+ clearInterval(timer);
61
+
62
+ await refreshing;
63
+
64
+ const isOwned = await isOwnedLock(lockPath, owned);
65
+
66
+ if (isOwned) {
67
+ await rmdir(lockPath).catch(() => {});
68
+ }
69
+ }
70
+ }
71
+
72
+ // The lock directory a holder created, as its inode and the age it last
73
+ // gave it; a directory created in its place after a takeover can reuse
74
+ // the inode but not that age.
75
+ interface OwnedLock {
76
+ readonly ino: number;
77
+ readonly mtimeMs: number;
78
+ }
79
+
80
+ // Creates the lock directory and resolves to it while this call holds the
81
+ // lock, or null; a stale directory is removed so the next try can take it.
82
+ async function tryCreateLockDir(lockPath: string): Promise<OwnedLock | null> {
83
+ try {
84
+ await mkdir(lockPath);
85
+
86
+ const created = await stat(lockPath);
87
+
88
+ return { ino: created.ino, mtimeMs: created.mtimeMs };
89
+ } catch (error) {
90
+ if (!isExistsError(error)) {
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ const existing = await stat(lockPath).catch(() => null);
96
+
97
+ if (existing !== null && Date.now() - existing.mtimeMs > STALE_MS) {
98
+ await rmdir(lockPath).catch(() => {});
99
+ }
100
+
101
+ return null;
102
+ }
103
+
104
+ function isExistsError(error: unknown): boolean {
105
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST';
106
+ }
107
+
108
+ // Moves the lock's age forward while it is still the directory this call
109
+ // created, and resolves to the lock as it then stands; null leaves the
110
+ // lock to go stale, as a holder that stopped refreshing it does.
111
+ async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<OwnedLock | null> {
112
+ const isOwned = await isOwnedLock(lockPath, owned);
113
+
114
+ if (!isOwned) {
115
+ return null;
116
+ }
117
+
118
+ try {
119
+ const now = new Date();
120
+
121
+ await utimes(lockPath, now, now);
122
+
123
+ const refreshed = await stat(lockPath);
124
+
125
+ return { ino: refreshed.ino, mtimeMs: refreshed.mtimeMs };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ async function isOwnedLock(lockPath: string, owned: OwnedLock): Promise<boolean> {
132
+ const current = await stat(lockPath).catch(() => null);
133
+
134
+ return current !== null && current.ino === owned.ino && current.mtimeMs === owned.mtimeMs;
135
+ }
@@ -926,15 +926,19 @@ export class SessionManager {
926
926
  throw new DaemonError('bad_args', 'trustClonedWorkspace requires a workspace source');
927
927
  }
928
928
 
929
- if (
930
- provider.kind !== 'imp' ||
931
- !provider.remote ||
932
- auth === null ||
933
- adapter.planGuestWorkspaceTrust === undefined
934
- ) {
929
+ const isLocalTrust =
930
+ !provider.remote && auth === null && adapter.updateLocalWorkspaceTrust !== undefined;
931
+
932
+ const isGuestTrust =
933
+ provider.kind === 'imp' &&
934
+ provider.remote &&
935
+ auth !== null &&
936
+ adapter.planGuestWorkspaceTrust !== undefined;
937
+
938
+ if (!isLocalTrust && !isGuestTrust) {
935
939
  throw new DaemonError(
936
940
  'unsupported',
937
- 'trustClonedWorkspace requires a brokered Claude gateway on an imp target',
941
+ 'trustClonedWorkspace requires stock Claude on the local target or a brokered Claude gateway on an imp target',
938
942
  );
939
943
  }
940
944
  }
@@ -978,8 +982,24 @@ export class SessionManager {
978
982
  hostKey === id,
979
983
  );
980
984
 
985
+ // Takes back trust a spawn accepted in the user's own agent config when
986
+ // the spawn fails before its harness starts.
987
+ const localTrust: { remove: (() => Promise<void>) | null } = { remove: null };
988
+
981
989
  const trustWorkspace = trustClonedWorkspace
982
990
  ? async (root: string) => {
991
+ if (!provider.remote) {
992
+ const update = adapter.updateLocalWorkspaceTrust;
993
+
994
+ if (update === undefined) {
995
+ throw new DaemonError('unsupported', 'this adapter cannot trust a cloned workspace');
996
+ }
997
+
998
+ localTrust.remove = await update.call(adapter, root);
999
+
1000
+ return;
1001
+ }
1002
+
983
1003
  const planned = adapter.planGuestWorkspaceTrust?.(root);
984
1004
 
985
1005
  if (planned === undefined || planned === null || provider.guest === undefined) {
@@ -1045,6 +1065,7 @@ export class SessionManager {
1045
1065
  },
1046
1066
  });
1047
1067
  } catch (error) {
1068
+ await this.removeLocalTrust(localTrust.remove, id);
1048
1069
  await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
1049
1070
 
1050
1071
  throw error;
@@ -1621,6 +1642,28 @@ export class SessionManager {
1621
1642
  }
1622
1643
  }
1623
1644
 
1645
+ // Takes back the trust a failed spawn accepted in the user's own agent
1646
+ // config. A take-back that fails is logged and leaves the entry, so the
1647
+ // spawn's own failure is the one it reports.
1648
+ private async removeLocalTrust(
1649
+ remove: (() => Promise<void>) | null,
1650
+ id: SessionID,
1651
+ ): Promise<void> {
1652
+ if (remove === null) {
1653
+ return;
1654
+ }
1655
+
1656
+ try {
1657
+ await remove();
1658
+ } catch (error) {
1659
+ const reason = error instanceof Error ? error.message : String(error);
1660
+
1661
+ this.log(
1662
+ `atc: the spawn of session ${id} failed to start and taking back its workspace trust failed too: ${reason}`,
1663
+ );
1664
+ }
1665
+ }
1666
+
1624
1667
  // Takes back what a spawn readied when it fails once its host is ready,
1625
1668
  // before or after its session lists. An attempt that provisioned the
1626
1669
  // host takes back its imp and binding; a host of the spawn's own without
@@ -61,7 +61,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
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, 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
62
  ),
63
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.',
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 and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or a brokered Claude gateway with isolated guest config on an imp target; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
65
65
  ),
66
66
  detached: z
67
67
  .boolean()