@zgeoff/atc 2.15.2 → 2.17.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 +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +39 -5
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/spawn-picker.ts +132 -6
- package/src/client/ui.ts +6 -1
- package/src/daemon/broker-authority-error.ts +41 -0
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +46 -1
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/hook-event.ts +6 -2
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +105 -0
- package/src/shared/config.ts +14 -0
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/statusline.ts +8 -6
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zgeoff/atc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.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",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@better-auth/oauth-provider": "1.7.6",
|
|
44
44
|
"@xterm/addon-serialize": "0.14.0",
|
|
45
45
|
"@xterm/headless": "6.0.0",
|
|
46
|
-
"@zgeoff/imp-client": "0.
|
|
46
|
+
"@zgeoff/imp-client": "0.27.0",
|
|
47
47
|
"better-auth": "1.7.6",
|
|
48
48
|
"bun-pty": "0.4.10",
|
|
49
49
|
"citty": "0.2.2",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { AgentID } from '../shared/agent-id';
|
|
3
|
+
import { toShellArg } from '../shared/to-shell-arg';
|
|
3
4
|
import { buildCLICommand } from './build-cli-command';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -20,7 +21,9 @@ export interface HookSettingsProfile {
|
|
|
20
21
|
* The settings object injected into wrangled sessions via
|
|
21
22
|
* `claude --settings`. The user's own settings are untouched; these hooks
|
|
22
23
|
* only exist in sessions atc spawns, and identify themselves via
|
|
23
|
-
* ATC_SESSION_ID in the env.
|
|
24
|
+
* ATC_SESSION_ID in the env and the agent id on their command line. A
|
|
25
|
+
* nested harness inherits the env but not the command line, so the agent id
|
|
26
|
+
* keeps its reports from passing as the session's own.
|
|
24
27
|
*
|
|
25
28
|
* A settings-file env block outranks a shell export of the same variable, so
|
|
26
29
|
* a session's backend is decided here rather than by whatever the terminal
|
|
@@ -37,7 +40,13 @@ export function buildHookSettings(
|
|
|
37
40
|
): Record<string, unknown> {
|
|
38
41
|
const entry = [
|
|
39
42
|
{
|
|
40
|
-
hooks: [
|
|
43
|
+
hooks: [
|
|
44
|
+
{
|
|
45
|
+
type: 'command',
|
|
46
|
+
command: buildCLICommand(`hook-report --agent ${toShellArg(profile.id)}`, cliArgv),
|
|
47
|
+
timeout: 5,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
41
50
|
},
|
|
42
51
|
];
|
|
43
52
|
|
|
@@ -61,7 +70,7 @@ export function buildHookSettings(
|
|
|
61
70
|
// padding.
|
|
62
71
|
statusLine: {
|
|
63
72
|
type: 'command',
|
|
64
|
-
command: buildCLICommand(
|
|
73
|
+
command: buildCLICommand(`statusline --agent ${toShellArg(profile.id)}`, cliArgv),
|
|
65
74
|
padding: statuslinePadding,
|
|
66
75
|
},
|
|
67
76
|
...(profile.env === undefined || Object.keys(profile.env).length === 0
|
|
@@ -17,7 +17,7 @@ export function printCodexHookFile(): void {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function buildCodexHookFile(): string {
|
|
20
|
-
const cmd = buildCLICommand('hook-report');
|
|
20
|
+
const cmd = buildCLICommand('hook-report --agent codex');
|
|
21
21
|
const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
|
|
22
22
|
const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
|
|
23
23
|
|
|
@@ -19,7 +19,7 @@ export function printGrokHookFile(): void {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function buildGrokHookFile(): string {
|
|
22
|
-
const cmd = buildCLICommand('hook-report');
|
|
22
|
+
const cmd = buildCLICommand('hook-report --agent grok');
|
|
23
23
|
const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
|
|
24
24
|
const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
|
|
25
25
|
|
package/src/cli.ts
CHANGED
|
@@ -161,6 +161,10 @@ const main = defineCommand({
|
|
|
161
161
|
const gateway = await import('./agents/gateway-adapter');
|
|
162
162
|
const headless = await import('./agents/start-claude-headless-run');
|
|
163
163
|
const targets = await import('./daemon/build-execution-targets');
|
|
164
|
+
const sourceOrder = await import('./sources/build-sources');
|
|
165
|
+
const builtinSources = await import('./sources/collect-builtin-sources');
|
|
166
|
+
const zoxide = await import('./shared/collect-zoxide-dirs');
|
|
167
|
+
const home = await import('./shared/resolve-home-dir');
|
|
164
168
|
|
|
165
169
|
// Test harnesses shrink the outbound queue to force overflow
|
|
166
170
|
// deterministically; unset means the production default.
|
|
@@ -178,10 +182,27 @@ const main = defineCommand({
|
|
|
178
182
|
console.error(`atc daemon: config: ${line}`);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
for (const problem of cfg.principalErrors) {
|
|
185
|
+
for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
|
|
182
186
|
console.error(`atc daemon: config: ${problem}`);
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
const sources = sourceOrder.buildSources(
|
|
190
|
+
builtinSources.collectBuiltinSources({
|
|
191
|
+
roots: cfg.dirs.roots,
|
|
192
|
+
githubOwner: cfg.workspaces.githubOwner,
|
|
193
|
+
ghBin: 'gh',
|
|
194
|
+
homeDir: home.resolveHomeDir(),
|
|
195
|
+
collectZoxideDirs: zoxide.collectZoxideDirs,
|
|
196
|
+
}),
|
|
197
|
+
cfg.workspaces.sources,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
for (const id of sources.missing) {
|
|
201
|
+
console.error(
|
|
202
|
+
`atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
// Cap on how long a fleet restore waits for one revived session to
|
|
186
207
|
// report it has booted before moving to the next. Tests pin it to
|
|
187
208
|
// keep timing deterministic; unset means the production default.
|
|
@@ -220,6 +241,8 @@ const main = defineCommand({
|
|
|
220
241
|
defaultTarget: cfg.defaultTarget,
|
|
221
242
|
targetErrors,
|
|
222
243
|
principals: cfg.principals,
|
|
244
|
+
sources: sources.sources,
|
|
245
|
+
gitTransports: cfg.workspaces.gitTransports,
|
|
223
246
|
restoreBootTimeoutMs,
|
|
224
247
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
225
248
|
? { tapGraceMs: graceOverride }
|
|
@@ -291,10 +314,16 @@ const main = defineCommand({
|
|
|
291
314
|
description: 'Forward a hook event from a wrangled session to the atc socket',
|
|
292
315
|
hidden: true,
|
|
293
316
|
},
|
|
294
|
-
|
|
317
|
+
|
|
318
|
+
// No arg is required: a citty usage error exits nonzero, and
|
|
319
|
+
// reporters must always exit 0.
|
|
320
|
+
args: {
|
|
321
|
+
agent: { type: 'string', default: '' },
|
|
322
|
+
},
|
|
323
|
+
async run(ctx) {
|
|
295
324
|
const reporter = await import('./hook-report');
|
|
296
325
|
|
|
297
|
-
await reporter.runHookReport();
|
|
326
|
+
await reporter.runHookReport(ctx.args.agent);
|
|
298
327
|
},
|
|
299
328
|
}),
|
|
300
329
|
tap: () =>
|
|
@@ -352,10 +381,15 @@ const main = defineCommand({
|
|
|
352
381
|
description: 'Render the chained statusline for a wrangled session',
|
|
353
382
|
hidden: true,
|
|
354
383
|
},
|
|
355
|
-
|
|
384
|
+
|
|
385
|
+
// A statusline command must always exit 0 too, so no arg is required.
|
|
386
|
+
args: {
|
|
387
|
+
agent: { type: 'string', default: '' },
|
|
388
|
+
},
|
|
389
|
+
async run(ctx) {
|
|
356
390
|
const statusline = await import('./statusline');
|
|
357
391
|
|
|
358
|
-
await statusline.runStatusline();
|
|
392
|
+
await statusline.runStatusline(ctx.args.agent);
|
|
359
393
|
},
|
|
360
394
|
}),
|
|
361
395
|
},
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
export interface TargetPick {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
readonly available: boolean;
|
|
7
|
+
readonly isDefault: boolean;
|
|
8
|
+
|
|
9
|
+
// Whether a workspace can be materialized there: the daemon has the
|
|
10
|
+
// provider, and it can both transfer an archive and run a command.
|
|
11
|
+
readonly takesWorkspace: boolean;
|
|
12
|
+
|
|
13
|
+
// Whether a session there runs on the daemon's own machine, where a
|
|
14
|
+
// local directory runs in place.
|
|
15
|
+
readonly inPlace: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The execution targets an `agents.list` answer holds, in config order. An
|
|
20
|
+
* answer from a daemon without targets, or a malformed entry, yields none.
|
|
21
|
+
*/
|
|
22
|
+
export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
|
|
23
|
+
const targets = answer['targets'];
|
|
24
|
+
|
|
25
|
+
if (!Array.isArray(targets)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return targets.flatMap((entry: unknown): TargetPick[] => {
|
|
30
|
+
if (
|
|
31
|
+
!isRecord(entry) ||
|
|
32
|
+
typeof entry['id'] !== 'string' ||
|
|
33
|
+
typeof entry['provider'] !== 'string'
|
|
34
|
+
) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
|
|
39
|
+
const available = entry['available'] === true;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
id: entry['id'],
|
|
44
|
+
provider: entry['provider'],
|
|
45
|
+
available,
|
|
46
|
+
isDefault: entry['default'] === true,
|
|
47
|
+
takesWorkspace:
|
|
48
|
+
available && capabilities['transfer'] === true && capabilities['run'] === true,
|
|
49
|
+
inPlace: entry['provider'] === 'local-pty',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/client/dirs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename } from 'node:path';
|
|
4
|
-
import { collectRootDirs } from '
|
|
4
|
+
import { collectRootDirs } from '../shared/collect-root-dirs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Every source the picker lists, merged in the order the reader expects
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { DaemonError } from '../protocol/daemon-error';
|
|
3
3
|
import type { AgentID } from '../shared/agent-id';
|
|
4
|
+
import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
|
|
4
5
|
import { loadConfig } from '../shared/config';
|
|
5
6
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
6
7
|
import type { AgentPick } from './collect-agent-picks';
|
|
7
8
|
import { collectPathCompletions } from './collect-path-completions';
|
|
8
|
-
import {
|
|
9
|
+
import { collectTargetPicks } from './collect-target-picks';
|
|
10
|
+
import type { TargetPick } from './collect-target-picks';
|
|
9
11
|
import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
|
|
10
12
|
import { planTextEdit } from './keys';
|
|
11
13
|
import { resolvePathInput } from './resolve-path-input';
|
|
12
14
|
import { ansi, cols, drawPicker } from './ui';
|
|
13
15
|
|
|
14
|
-
type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
|
|
16
|
+
type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
|
|
15
17
|
|
|
16
18
|
// What the flow borrows from the client that owns the screen, the daemon
|
|
17
19
|
// connection and the fleet mirror.
|
|
@@ -31,8 +33,9 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* The modal flow behind n and r: agent, then directory, then
|
|
35
|
-
*
|
|
36
|
+
* The modal flow behind n and r: agent, then directory, then execution
|
|
37
|
+
* target when the daemon has more than one, then name, then an optional
|
|
38
|
+
* first prompt. Every path out of it either attaches the new
|
|
36
39
|
* session or returns the client to the screen it came from. A spawn the
|
|
37
40
|
* daemon refuses is no path out: the flow stays open and shows why.
|
|
38
41
|
*/
|
|
@@ -56,6 +59,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
56
59
|
|
|
57
60
|
private dir = '';
|
|
58
61
|
|
|
62
|
+
// The daemon's execution targets, read when the directory step opens,
|
|
63
|
+
// and the one chosen; null spawns on the daemon's default target.
|
|
64
|
+
private targets: TargetPick[] = [];
|
|
65
|
+
|
|
66
|
+
private target: TargetPick | null = null;
|
|
67
|
+
|
|
59
68
|
private name = '';
|
|
60
69
|
|
|
61
70
|
private resume = false;
|
|
@@ -72,6 +81,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
72
81
|
|
|
73
82
|
open(resume = false) {
|
|
74
83
|
this.resume = resume;
|
|
84
|
+
this.target = null;
|
|
75
85
|
|
|
76
86
|
const config = loadConfig();
|
|
77
87
|
|
|
@@ -99,7 +109,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
99
109
|
|
|
100
110
|
const edit = planTextEdit(buf, this.input, {
|
|
101
111
|
isLeaderKey: this.deps.isLeaderKey,
|
|
102
|
-
moves: this.step === 'agent' || this.step === 'dir',
|
|
112
|
+
moves: this.step === 'agent' || this.step === 'dir' || this.step === 'target',
|
|
103
113
|
});
|
|
104
114
|
|
|
105
115
|
switch (edit.kind) {
|
|
@@ -131,6 +141,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
131
141
|
return;
|
|
132
142
|
}
|
|
133
143
|
case 'input': {
|
|
144
|
+
// The target step takes no text: its list is short and fixed.
|
|
145
|
+
if (this.step === 'target') {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
134
149
|
this.input = edit.value;
|
|
135
150
|
|
|
136
151
|
this.render();
|
|
@@ -170,6 +185,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
170
185
|
input: this.input,
|
|
171
186
|
hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
|
|
172
187
|
});
|
|
188
|
+
} else if (this.step === 'target') {
|
|
189
|
+
this.selected = Math.min(this.selected, this.targets.length - 1);
|
|
190
|
+
|
|
191
|
+
drawPicker({
|
|
192
|
+
title: `${verb}: target`,
|
|
193
|
+
items: this.targets.map((t) => formatTargetPick(t)),
|
|
194
|
+
selected: this.selected,
|
|
195
|
+
input: '',
|
|
196
|
+
hint: this.refusal ?? `where ${formatDir(this.dir)} runs · ↑↓ move · ⏎ select · esc back`,
|
|
197
|
+
dimmed: new Set(
|
|
198
|
+
this.targets.flatMap((t, i) =>
|
|
199
|
+
t.takesWorkspace || (t.available && t.inPlace) ? [] : [i],
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
});
|
|
173
203
|
} else if (this.step === 'name') {
|
|
174
204
|
drawPicker({
|
|
175
205
|
title: `${verb}: name`,
|
|
@@ -209,8 +239,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
209
239
|
);
|
|
210
240
|
|
|
211
241
|
this.step = 'agent';
|
|
212
|
-
} else if (this.step === '
|
|
242
|
+
} else if (this.step === 'target') {
|
|
213
243
|
this.step = 'dir';
|
|
244
|
+
} else if (this.step === 'name') {
|
|
245
|
+
this.openTargetOrDir('dir');
|
|
214
246
|
} else {
|
|
215
247
|
this.step = 'name';
|
|
216
248
|
}
|
|
@@ -246,6 +278,27 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
246
278
|
|
|
247
279
|
this.dir = chosen;
|
|
248
280
|
this.input = '';
|
|
281
|
+
|
|
282
|
+
this.openTargetOrDir('name');
|
|
283
|
+
} else if (this.step === 'target') {
|
|
284
|
+
const pick = this.targets[this.selected];
|
|
285
|
+
|
|
286
|
+
if (pick === undefined) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const refusal = findTargetRefusal(pick);
|
|
291
|
+
|
|
292
|
+
if (refusal !== null) {
|
|
293
|
+
this.refusal = refusal;
|
|
294
|
+
|
|
295
|
+
this.render();
|
|
296
|
+
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.target = pick;
|
|
301
|
+
this.input = '';
|
|
249
302
|
this.step = 'name';
|
|
250
303
|
} else if (this.step === 'name') {
|
|
251
304
|
this.name = this.input.trim();
|
|
@@ -280,9 +333,44 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
280
333
|
: collectPathCompletions(this.input, process.cwd(), homedir());
|
|
281
334
|
}
|
|
282
335
|
|
|
336
|
+
// Opens the target step when the daemon has more than one target, with
|
|
337
|
+
// the chosen or default target selected, and the fallback step otherwise.
|
|
338
|
+
private openTargetOrDir(fallback: 'dir' | 'name') {
|
|
339
|
+
if (this.targets.length < 2) {
|
|
340
|
+
// The one target is the spawn's, default or not, so the spawn names it
|
|
341
|
+
// and its provider decides whether a directory runs in place.
|
|
342
|
+
this.target = this.targets[0] ?? null;
|
|
343
|
+
this.step = fallback;
|
|
344
|
+
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const preferred = this.target?.id;
|
|
349
|
+
|
|
350
|
+
this.selected = Math.max(
|
|
351
|
+
0,
|
|
352
|
+
this.targets.findIndex((t) => (preferred === undefined ? t.isDefault : t.id === preferred)),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
this.step = 'target';
|
|
356
|
+
}
|
|
357
|
+
|
|
283
358
|
private async openDirStep() {
|
|
284
359
|
let recent: string[] = [];
|
|
285
360
|
|
|
361
|
+
try {
|
|
362
|
+
const listed = await this.deps.sendRequest('agents.list');
|
|
363
|
+
|
|
364
|
+
const picks = collectTargetPicks(listed);
|
|
365
|
+
|
|
366
|
+
// An adopt resumes a session from its history on this host, which a
|
|
367
|
+
// fresh checkout elsewhere does not hold, so it is offered only the
|
|
368
|
+
// targets that run here.
|
|
369
|
+
this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
|
|
370
|
+
} catch {
|
|
371
|
+
this.targets = [];
|
|
372
|
+
}
|
|
373
|
+
|
|
286
374
|
try {
|
|
287
375
|
const answer = await this.deps.sendRequest('dirs.list');
|
|
288
376
|
|
|
@@ -318,6 +406,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
318
406
|
rows: this.deps.ptyRows(),
|
|
319
407
|
...(this.resume ? { resume: true } : {}),
|
|
320
408
|
agent: this.agent,
|
|
409
|
+
...this.buildTargetParams(),
|
|
321
410
|
});
|
|
322
411
|
|
|
323
412
|
const spawned = this.deps.toMirrorSession(ok['session']);
|
|
@@ -347,4 +436,41 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
347
436
|
|
|
348
437
|
this.deps.toBase();
|
|
349
438
|
}
|
|
439
|
+
|
|
440
|
+
// A chosen target goes with the spawn. A local directory runs in place on
|
|
441
|
+
// a target on the daemon's own machine, and is materialized at the same
|
|
442
|
+
// path from its pushed HEAD on any other.
|
|
443
|
+
private buildTargetParams(): Readonly<Record<string, unknown>> {
|
|
444
|
+
if (this.target === null) {
|
|
445
|
+
return {};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return this.target.inPlace
|
|
449
|
+
? { target: this.target.id }
|
|
450
|
+
: { target: this.target.id, workspace: { kind: 'path', path: this.dir } };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function formatTargetPick(pick: TargetPick): string {
|
|
455
|
+
const notes = [
|
|
456
|
+
pick.provider,
|
|
457
|
+
...(pick.isDefault ? ['default'] : []),
|
|
458
|
+
...(pick.available ? [] : ['unavailable']),
|
|
459
|
+
...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
return `${pick.id} ${notes.join(' · ')}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Why a local directory cannot run on a target, or null when it can.
|
|
466
|
+
function findTargetRefusal(pick: TargetPick): string | null {
|
|
467
|
+
if (!pick.available) {
|
|
468
|
+
return `target '${pick.id}' is unavailable on this daemon · esc back`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!pick.inPlace && !pick.takesWorkspace) {
|
|
472
|
+
return `target '${pick.id}' cannot take a workspace · esc back`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return null;
|
|
350
476
|
}
|
package/src/client/ui.ts
CHANGED
|
@@ -387,6 +387,9 @@ export interface PickerView {
|
|
|
387
387
|
input: string;
|
|
388
388
|
placeholder?: string;
|
|
389
389
|
hint: string;
|
|
390
|
+
|
|
391
|
+
// The indexes of items drawn dim: listed, but not a fit for the flow.
|
|
392
|
+
dimmed?: ReadonlySet<number>;
|
|
390
393
|
}
|
|
391
394
|
|
|
392
395
|
export function drawPicker(view: PickerView) {
|
|
@@ -397,7 +400,9 @@ export function drawPicker(view: PickerView) {
|
|
|
397
400
|
for (const [i, item] of shown.entries()) {
|
|
398
401
|
const sel = i === view.selected;
|
|
399
402
|
const t = truncate(item, width - 4);
|
|
400
|
-
const
|
|
403
|
+
const dim = view.dimmed?.has(i) === true;
|
|
404
|
+
const inverse = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
|
|
405
|
+
const styled = dim ? `${ESC}[90m${inverse}${ESC}[0m` : inverse;
|
|
401
406
|
const plainLen = sel ? width - 4 : t.length;
|
|
402
407
|
|
|
403
408
|
rowsList.push(boxRow(width, styled, plainLen));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why impd's credential broker may not be used for a session, or why atc
|
|
3
|
+
* may not clean up after one:
|
|
4
|
+
*
|
|
5
|
+
* - `auth_impd_too_old`: impd lacks grantable tokens or secret rebinds.
|
|
6
|
+
* - `auth_token_scope`: the token's scope is below `manage`.
|
|
7
|
+
* - `auth_token_too_broad`: the token can reach imps outside atc's
|
|
8
|
+
* namespace, through no imp patterns or a pattern whose literal text
|
|
9
|
+
* before its first `*` does not start with the namespace prefix.
|
|
10
|
+
* - `auth_imp_out_of_scope`: an imp the call touches is outside the
|
|
11
|
+
* token's patterns.
|
|
12
|
+
* - `auth_secret_not_grantable`: a bound secret is not on the token's
|
|
13
|
+
* grantable list.
|
|
14
|
+
* - `auth_runtime_mismatch`: the imp under the recorded name has another
|
|
15
|
+
* id than the one recorded.
|
|
16
|
+
*/
|
|
17
|
+
export type BrokerAuthorityCode =
|
|
18
|
+
| 'auth_impd_too_old'
|
|
19
|
+
| 'auth_token_scope'
|
|
20
|
+
| 'auth_token_too_broad'
|
|
21
|
+
| 'auth_imp_out_of_scope'
|
|
22
|
+
| 'auth_secret_not_grantable'
|
|
23
|
+
| 'auth_runtime_mismatch';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A refusal to provision through impd's credential broker or to clean up
|
|
27
|
+
* after it, carrying its code and the detail the code defines.
|
|
28
|
+
*/
|
|
29
|
+
export class BrokerAuthorityError extends Error {
|
|
30
|
+
readonly code: BrokerAuthorityCode;
|
|
31
|
+
|
|
32
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
33
|
+
|
|
34
|
+
constructor(code: BrokerAuthorityCode, message: string, data: Readonly<Record<string, unknown>>) {
|
|
35
|
+
super(message);
|
|
36
|
+
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.data = data;
|
|
39
|
+
this.name = 'BrokerAuthorityError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ImpSecret, ImpSecretRule } from './imp-port';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One secret as a resolved binding expects impd to hold it: its kind and
|
|
5
|
+
* the complete set of rules, one per host.
|
|
6
|
+
*/
|
|
7
|
+
export interface ExpectedSecret {
|
|
8
|
+
readonly secret: string;
|
|
9
|
+
readonly kind: ImpSecret['kind'];
|
|
10
|
+
readonly rules: readonly ImpSecretRule[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SecretRuleMismatch =
|
|
14
|
+
| { readonly secret: string; readonly reason: 'missing' }
|
|
15
|
+
| {
|
|
16
|
+
readonly secret: string;
|
|
17
|
+
readonly reason: 'kind';
|
|
18
|
+
readonly expected: ImpSecret['kind'];
|
|
19
|
+
readonly actual: ImpSecret['kind'];
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly secret: string;
|
|
23
|
+
readonly reason: 'rules';
|
|
24
|
+
readonly expected: readonly ImpSecretRule[];
|
|
25
|
+
readonly actual: readonly ImpSecretRule[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compares the secrets a resolved binding expects with the secrets impd
|
|
30
|
+
* lists, secret by secret. Each expected secret must exist with the same
|
|
31
|
+
* kind and exactly the same set of rules, in any order: a rule impd holds
|
|
32
|
+
* that the binding lacks is a mismatch as much as a missing one, since it
|
|
33
|
+
* sends the credential to a host the binding never approved. An empty
|
|
34
|
+
* result means every expected secret matches; secrets the binding does not
|
|
35
|
+
* expect are ignored.
|
|
36
|
+
*/
|
|
37
|
+
export function collectSecretRuleMismatches(
|
|
38
|
+
expected: readonly ExpectedSecret[],
|
|
39
|
+
secrets: readonly ImpSecret[],
|
|
40
|
+
): SecretRuleMismatch[] {
|
|
41
|
+
const held = new Map(secrets.map((secret) => [secret.name, secret]));
|
|
42
|
+
|
|
43
|
+
const mismatches: SecretRuleMismatch[] = [];
|
|
44
|
+
|
|
45
|
+
for (const want of expected) {
|
|
46
|
+
const actual = held.get(want.secret);
|
|
47
|
+
|
|
48
|
+
if (actual === undefined) {
|
|
49
|
+
mismatches.push({ secret: want.secret, reason: 'missing' });
|
|
50
|
+
} else if (actual.kind !== want.kind) {
|
|
51
|
+
mismatches.push({
|
|
52
|
+
secret: want.secret,
|
|
53
|
+
reason: 'kind',
|
|
54
|
+
expected: want.kind,
|
|
55
|
+
actual: actual.kind,
|
|
56
|
+
});
|
|
57
|
+
} else if (!hasSameRules(want.rules, actual.rules)) {
|
|
58
|
+
mismatches.push({
|
|
59
|
+
secret: want.secret,
|
|
60
|
+
reason: 'rules',
|
|
61
|
+
expected: want.rules,
|
|
62
|
+
actual: actual.rules,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return mismatches;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasSameRules(left: readonly ImpSecretRule[], right: readonly ImpSecretRule[]): boolean {
|
|
71
|
+
const leftKeys = left.map((rule) => toRuleKey(rule)).toSorted();
|
|
72
|
+
const rightKeys = right.map((rule) => toRuleKey(rule)).toSorted();
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A rule as one comparable string; JSON keeps a missing user apart from
|
|
80
|
+
// any user a basic rule can hold.
|
|
81
|
+
function toRuleKey(rule: ImpSecretRule): string {
|
|
82
|
+
return JSON.stringify([rule.host, rule.header, rule.scheme, rule.user ?? null]);
|
|
83
|
+
}
|