@zgeoff/atc 2.30.1 → 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 +5 -2
- package/src/agents/agent-adapter.ts +6 -0
- package/src/agents/claude-adapter.ts +12 -3
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-pasted-line-input.ts +6 -1
- package/src/agents/resolve-claude-global-config-path.ts +18 -0
- package/src/agents/update-claude-project-trust.ts +119 -0
- package/src/agents/with-claude-config-lock.ts +135 -0
- package/src/daemon/sessions.ts +50 -7
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/shared/collect-auth-profiles.ts +34 -13
- package/src/shared/resolve-auth-profiles.ts +70 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -76,7 +76,10 @@
|
|
|
76
76
|
"hono": ">=4.13.5",
|
|
77
77
|
"ip-address": "10.7.2",
|
|
78
78
|
"js-yaml": ">=4.3.2 <5",
|
|
79
|
-
"
|
|
79
|
+
"proxy-addr": "2.0.8",
|
|
80
|
+
"qs": "6.16.0",
|
|
81
|
+
"smol-toml": "1.9.0",
|
|
82
|
+
"tinypool": "2.1.2"
|
|
80
83
|
},
|
|
81
84
|
"packageManager": "bun@1.3.10"
|
|
82
85
|
}
|
|
@@ -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 {
|
|
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
|
|
66
|
-
|
|
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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -926,15 +926,19 @@ export class SessionManager {
|
|
|
926
926
|
throw new DaemonError('bad_args', 'trustClonedWorkspace requires a workspace source');
|
|
927
927
|
}
|
|
928
928
|
|
|
929
|
-
|
|
930
|
-
provider.
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -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
|
-
|
|
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()
|
|
@@ -2,11 +2,14 @@ import { isRecord } from './report';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A named reference to a credential impd holds: the secret's name, never
|
|
5
|
-
* its value, and the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* its value, and the profiles a session selecting this one needs beside
|
|
6
|
+
* it. A `custom` profile holds the one rule impd applies when a request
|
|
7
|
+
* reaches its host, always a bearer header. A `github` profile holds no
|
|
8
|
+
* rule: impd's `github` kind fixes its hosts and headers.
|
|
8
9
|
*/
|
|
9
|
-
export
|
|
10
|
+
export type AuthProfile = CustomAuthProfile | GitHubAuthProfile;
|
|
11
|
+
|
|
12
|
+
interface CustomAuthProfile {
|
|
10
13
|
readonly name: string;
|
|
11
14
|
readonly secret: string;
|
|
12
15
|
readonly kind: 'custom';
|
|
@@ -16,6 +19,13 @@ export interface AuthProfile {
|
|
|
16
19
|
readonly dependencies: readonly string[];
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
interface GitHubAuthProfile {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly secret: string;
|
|
25
|
+
readonly kind: 'github';
|
|
26
|
+
readonly dependencies: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
interface AuthProfiles {
|
|
20
30
|
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
21
31
|
readonly errors: readonly string[];
|
|
@@ -78,15 +88,33 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
|
78
88
|
const header = entry['header'];
|
|
79
89
|
const scheme = entry['scheme'];
|
|
80
90
|
const dependencies = entry['dependencies'];
|
|
91
|
+
const kind = entry['kind'] ?? 'custom';
|
|
81
92
|
|
|
82
|
-
if (
|
|
83
|
-
return 'kind must be custom, the
|
|
93
|
+
if (kind !== 'custom' && kind !== 'github') {
|
|
94
|
+
return 'kind must be custom or github, the kinds atc binds';
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
|
|
87
98
|
return 'secret must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens';
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
if (
|
|
102
|
+
dependencies !== undefined &&
|
|
103
|
+
(!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
|
|
104
|
+
) {
|
|
105
|
+
return 'dependencies must be an array of profile names';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (kind === 'github') {
|
|
109
|
+
const extra = ['host', 'header', 'scheme', 'user'].find((key) => entry[key] !== undefined);
|
|
110
|
+
|
|
111
|
+
if (extra !== undefined) {
|
|
112
|
+
return `${extra} cannot be set on a github profile, whose hosts and headers impd's github kind fixes`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { name, secret, kind, dependencies: dependencies ?? [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
|
|
91
119
|
return 'host must be a lowercase hostname such as api.example.com';
|
|
92
120
|
}
|
|
@@ -103,13 +131,6 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
|
103
131
|
return 'user pairs only with the basic scheme, which atc does not bind';
|
|
104
132
|
}
|
|
105
133
|
|
|
106
|
-
if (
|
|
107
|
-
dependencies !== undefined &&
|
|
108
|
-
(!Array.isArray(dependencies) || !dependencies.every((dep) => typeof dep === 'string'))
|
|
109
|
-
) {
|
|
110
|
-
return 'dependencies must be an array of profile names';
|
|
111
|
-
}
|
|
112
|
-
|
|
113
134
|
return {
|
|
114
135
|
name,
|
|
115
136
|
secret,
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { AuthProfile } from './collect-auth-profiles';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* How impd applies one secret to requests for one host.
|
|
4
|
+
* How impd applies one secret to requests for one host. Only a basic rule
|
|
5
|
+
* holds a user.
|
|
5
6
|
*/
|
|
6
7
|
interface AuthRule {
|
|
7
8
|
readonly host: string;
|
|
8
9
|
readonly header: string;
|
|
9
|
-
readonly scheme: 'bearer';
|
|
10
|
+
readonly scheme: 'basic' | 'bearer';
|
|
11
|
+
readonly user?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -15,7 +17,7 @@ interface AuthRule {
|
|
|
15
17
|
*/
|
|
16
18
|
export interface ResolvedAuthSecret {
|
|
17
19
|
readonly secret: string;
|
|
18
|
-
readonly kind: '
|
|
20
|
+
readonly kind: AuthProfile['kind'];
|
|
19
21
|
readonly rules: readonly AuthRule[];
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -49,11 +51,18 @@ export type AuthProfileResolution =
|
|
|
49
51
|
| { readonly resolved: ResolvedAuthProfiles }
|
|
50
52
|
| { readonly problem: AuthProfileProblem };
|
|
51
53
|
|
|
54
|
+
// One rule of a profile, with the profile it came from.
|
|
55
|
+
interface ProfileRule {
|
|
56
|
+
readonly profile: AuthProfile;
|
|
57
|
+
readonly rule: AuthRule;
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/**
|
|
53
61
|
* Expands the selected profiles through their dependencies, then checks
|
|
54
62
|
* the whole expanded set: each host must get exactly one rule, so a
|
|
55
|
-
* credential is never picked by order
|
|
56
|
-
* secret and rule for a host merge into one
|
|
63
|
+
* credential is never picked by order, and each secret one kind. Two
|
|
64
|
+
* profiles holding the identical secret and rule for a host merge into one
|
|
65
|
+
* rule.
|
|
57
66
|
*/
|
|
58
67
|
export function resolveAuthProfiles(
|
|
59
68
|
profiles: ReadonlyMap<string, AuthProfile>,
|
|
@@ -73,21 +82,37 @@ export function resolveAuthProfiles(
|
|
|
73
82
|
|
|
74
83
|
const ordered = [...reached.values()].toSorted((a, b) => (a.name < b.name ? -1 : 1));
|
|
75
84
|
|
|
76
|
-
const byHost = new Map<string,
|
|
85
|
+
const byHost = new Map<string, ProfileRule>();
|
|
86
|
+
const kinds = new Map<string, AuthProfile>();
|
|
77
87
|
|
|
78
88
|
for (const profile of ordered) {
|
|
79
|
-
const
|
|
89
|
+
const kindOwner = kinds.get(profile.secret);
|
|
80
90
|
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
} else if (
|
|
91
|
+
if (kindOwner === undefined) {
|
|
92
|
+
kinds.set(profile.secret, profile);
|
|
93
|
+
} else if (kindOwner.kind !== profile.kind) {
|
|
84
94
|
return {
|
|
85
95
|
problem: {
|
|
86
96
|
code: 'auth_collision',
|
|
87
|
-
message: `profiles ${
|
|
97
|
+
message: `profiles ${kindOwner.name} and ${profile.name} bind secret ${profile.secret} as different kinds`,
|
|
88
98
|
},
|
|
89
99
|
};
|
|
90
100
|
}
|
|
101
|
+
|
|
102
|
+
for (const rule of getProfileRules(profile)) {
|
|
103
|
+
const other = byHost.get(rule.host);
|
|
104
|
+
|
|
105
|
+
if (other === undefined) {
|
|
106
|
+
byHost.set(rule.host, { profile, rule });
|
|
107
|
+
} else if (!hasSameRule(other, { profile, rule })) {
|
|
108
|
+
return {
|
|
109
|
+
problem: {
|
|
110
|
+
code: 'auth_collision',
|
|
111
|
+
message: `profiles ${other.profile.name} and ${profile.name} both send a credential to ${rule.host}`,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
return {
|
|
@@ -156,31 +181,50 @@ function isAuthProfileProblem(
|
|
|
156
181
|
return 'code' in value;
|
|
157
182
|
}
|
|
158
183
|
|
|
159
|
-
|
|
184
|
+
// The rules impd's github kind applies: git over HTTPS on github.com takes
|
|
185
|
+
// Basic auth for x-access-token, and the REST and upload APIs take a bearer
|
|
186
|
+
// token.
|
|
187
|
+
const GITHUB_RULES: readonly AuthRule[] = [
|
|
188
|
+
{ host: 'api.github.com', header: 'authorization', scheme: 'bearer' },
|
|
189
|
+
{ host: 'github.com', header: 'authorization', scheme: 'basic', user: 'x-access-token' },
|
|
190
|
+
{ host: 'uploads.github.com', header: 'authorization', scheme: 'bearer' },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
// The rules impd holds for a profile's secret on the hosts it covers.
|
|
194
|
+
function getProfileRules(profile: AuthProfile): readonly AuthRule[] {
|
|
195
|
+
if (profile.kind === 'github') {
|
|
196
|
+
return GITHUB_RULES;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [{ host: profile.host, header: profile.header, scheme: profile.scheme }];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hasSameRule(left: ProfileRule, right: ProfileRule): boolean {
|
|
160
203
|
return (
|
|
161
|
-
left.secret === right.secret &&
|
|
162
|
-
left.
|
|
163
|
-
left.
|
|
164
|
-
left.
|
|
204
|
+
left.profile.secret === right.profile.secret &&
|
|
205
|
+
left.profile.kind === right.profile.kind &&
|
|
206
|
+
left.rule.header === right.rule.header &&
|
|
207
|
+
left.rule.scheme === right.rule.scheme &&
|
|
208
|
+
left.rule.user === right.rule.user
|
|
165
209
|
);
|
|
166
210
|
}
|
|
167
211
|
|
|
168
212
|
// One entry per secret, sorted by secret, each with its rules sorted by host.
|
|
169
|
-
function buildSecrets(
|
|
170
|
-
const
|
|
213
|
+
function buildSecrets(entries: readonly ProfileRule[]): ResolvedAuthSecret[] {
|
|
214
|
+
const bySecret = new Map<string, { kind: AuthProfile['kind']; rules: AuthRule[] }>();
|
|
171
215
|
|
|
172
|
-
for (const
|
|
173
|
-
const
|
|
216
|
+
for (const item of entries) {
|
|
217
|
+
const entry = bySecret.get(item.profile.secret) ?? { kind: item.profile.kind, rules: [] };
|
|
174
218
|
|
|
175
|
-
|
|
176
|
-
|
|
219
|
+
entry.rules.push(item.rule);
|
|
220
|
+
bySecret.set(item.profile.secret, entry);
|
|
177
221
|
}
|
|
178
222
|
|
|
179
|
-
return [...
|
|
223
|
+
return [...bySecret.entries()]
|
|
180
224
|
.toSorted(([a], [b]) => (a < b ? -1 : 1))
|
|
181
|
-
.map(([secret,
|
|
225
|
+
.map(([secret, entry]) => ({
|
|
182
226
|
secret,
|
|
183
|
-
kind:
|
|
184
|
-
rules:
|
|
227
|
+
kind: entry.kind,
|
|
228
|
+
rules: entry.rules.toSorted((a, b) => (a.host < b.host ? -1 : 1)),
|
|
185
229
|
}));
|
|
186
230
|
}
|