@zgeoff/atc 2.19.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 +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +6 -1
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/daemon-connection.ts +7 -0
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +186 -0
package/package.json
CHANGED
|
@@ -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 [
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
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
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
2
2
|
|
|
3
3
|
interface AgentCapabilities {
|
|
4
|
-
// Only an installed agent can start a
|
|
4
|
+
// Only an installed agent whose starts are not all refused can start a
|
|
5
|
+
// session.
|
|
5
6
|
readonly spawn: boolean;
|
|
6
7
|
readonly readTranscript: boolean;
|
|
7
8
|
|
|
@@ -51,6 +52,7 @@ export function buildAgentList(
|
|
|
51
52
|
return adapters.map((adapter) => {
|
|
52
53
|
const profile = adapter.profile;
|
|
53
54
|
const installed = profile === undefined ? false : isInstalled(profile.bin);
|
|
55
|
+
const spawnable = installed && (adapter.findSpawnRefusal?.() ?? null) === null;
|
|
54
56
|
|
|
55
57
|
return {
|
|
56
58
|
id: adapter.id,
|
|
@@ -58,7 +60,7 @@ export function buildAgentList(
|
|
|
58
60
|
kind: profile?.kind ?? adapter.id,
|
|
59
61
|
installed,
|
|
60
62
|
capabilities: {
|
|
61
|
-
spawn:
|
|
63
|
+
spawn: spawnable,
|
|
62
64
|
readTranscript: adapter.parseTranscriptLine !== undefined,
|
|
63
65
|
message: adapter.takesMessages,
|
|
64
66
|
attach: true,
|
|
@@ -67,8 +69,8 @@ export function buildAgentList(
|
|
|
67
69
|
},
|
|
68
70
|
models: profile?.models ?? null,
|
|
69
71
|
spawnOptions: {
|
|
70
|
-
model: buildSpawnOptionEntry(profile?.spawnOptions.model,
|
|
71
|
-
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort,
|
|
72
|
+
model: buildSpawnOptionEntry(profile?.spawnOptions.model, spawnable),
|
|
73
|
+
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, spawnable),
|
|
72
74
|
},
|
|
73
75
|
};
|
|
74
76
|
});
|
|
@@ -86,9 +88,9 @@ const NO_SPAWN_OPTION: SpawnOptionSpec = {
|
|
|
86
88
|
|
|
87
89
|
function buildSpawnOptionEntry(
|
|
88
90
|
spec: SpawnOptionSpec | undefined,
|
|
89
|
-
|
|
91
|
+
spawnable: boolean,
|
|
90
92
|
): SpawnOptionEntry {
|
|
91
93
|
const resolved = spec ?? NO_SPAWN_OPTION;
|
|
92
94
|
|
|
93
|
-
return { ...resolved, available:
|
|
95
|
+
return { ...resolved, available: spawnable && resolved.supported };
|
|
94
96
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AgentID } from '../shared/agent-id';
|
|
3
|
+
import type { AuthProfile } from '../shared/collect-auth-profiles';
|
|
4
|
+
import type { GatewayAuth, GatewayConfig } from '../shared/collect-gateways';
|
|
5
|
+
import { resolveAuthProfiles } from '../shared/resolve-auth-profiles';
|
|
6
|
+
import type { AuthProfileProblem, ResolvedAuthSecret } from '../shared/resolve-auth-profiles';
|
|
7
|
+
import { sortJSONKeys } from '../shared/sort-json-keys';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a session's runtime is bound to through impd's broker: every
|
|
11
|
+
* profile its gateway's selection reaches, the secrets they need impd to
|
|
12
|
+
* hold with exactly these rules, the placeholder variables in place of a
|
|
13
|
+
* credential, and the endpoint. `hash` covers the secrets and their rules
|
|
14
|
+
* alone, so a change to a profile the binding does not reach leaves it as
|
|
15
|
+
* it was, and a change to one it reaches gives another hash.
|
|
16
|
+
*/
|
|
17
|
+
export interface AuthBinding {
|
|
18
|
+
readonly agent: AgentID;
|
|
19
|
+
readonly baseURL: string;
|
|
20
|
+
readonly profiles: readonly string[];
|
|
21
|
+
readonly secrets: readonly ResolvedAuthSecret[];
|
|
22
|
+
readonly placeholderEnv: Readonly<Record<string, string>>;
|
|
23
|
+
readonly hash: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: GatewayAuth };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plans the binding a gateway's auth asks for against the current auth
|
|
30
|
+
* profiles, or the problem that refuses it. The hash is the SHA-256 of the
|
|
31
|
+
* resolved secrets as canonical JSON, with each list sorted, so the order
|
|
32
|
+
* of the selection or of the config never changes it.
|
|
33
|
+
*/
|
|
34
|
+
export function buildAuthBinding(
|
|
35
|
+
gateway: AuthGateway,
|
|
36
|
+
profiles: ReadonlyMap<string, AuthProfile>,
|
|
37
|
+
): { readonly binding: AuthBinding } | { readonly problem: AuthProfileProblem } {
|
|
38
|
+
const resolution = resolveAuthProfiles(profiles, gateway.auth.profiles);
|
|
39
|
+
|
|
40
|
+
if ('problem' in resolution) {
|
|
41
|
+
return resolution;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const secrets = resolution.resolved.secrets;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
binding: {
|
|
48
|
+
agent: gateway.id,
|
|
49
|
+
baseURL: gateway.baseURL,
|
|
50
|
+
profiles: resolution.resolved.profiles,
|
|
51
|
+
secrets,
|
|
52
|
+
placeholderEnv: gateway.auth.placeholderEnv,
|
|
53
|
+
hash: createHash('sha256')
|
|
54
|
+
.update(JSON.stringify(sortJSONKeys({ secrets })))
|
|
55
|
+
.digest('hex'),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The imp a host key runs on: the target's imp name prefix and the first
|
|
3
|
+
* 20 letters and digits of the key, which is the atc session id of the
|
|
4
|
+
* session that owns the host.
|
|
5
|
+
*/
|
|
6
|
+
export function buildImpName(impPrefix: string, hostKey: string): string {
|
|
7
|
+
return `${impPrefix}${hostKey
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replaceAll(/[^a-z0-9]/g, '')
|
|
10
|
+
.slice(0, 20)}`;
|
|
11
|
+
}
|
|
@@ -17,8 +17,12 @@ export interface ImpProviderBuild {
|
|
|
17
17
|
* so no credential sits in the config. `tokenEnv` holds the name of the
|
|
18
18
|
* daemon's environment variable that carries the token, read once here.
|
|
19
19
|
* `tokenFile` holds the path of a file that carries it, read here and again
|
|
20
|
-
* before each impd call and connection. `
|
|
21
|
-
*
|
|
20
|
+
* before each impd call and connection. `impPrefix` starts every imp name
|
|
21
|
+
* the target builds, `atc-` when absent, and must be a lowercase letter
|
|
22
|
+
* followed by up to 10 lowercase letters, digits or hyphens, so the name
|
|
23
|
+
* it builds with a host key's 20 characters stays within impd's 31. `image`,
|
|
24
|
+
* `memoryMib`, `guestDir`, and `guestATC` pass through. No provider when
|
|
25
|
+
* `url` is missing, so the
|
|
22
26
|
* target lists and refuses every spawn. Both token options, either one not
|
|
23
27
|
* a non-empty string, an unset or empty variable, or an empty or unreadable
|
|
24
28
|
* file is a problem and leaves no provider; a target without either calls
|
|
@@ -41,7 +45,17 @@ export function buildImpProvider(
|
|
|
41
45
|
return { provider: null, problem: source };
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
const impPrefix = options['impPrefix'];
|
|
49
|
+
|
|
50
|
+
if (impPrefix !== undefined && !isImpPrefix(impPrefix)) {
|
|
51
|
+
return {
|
|
52
|
+
provider: null,
|
|
53
|
+
problem: `target ${JSON.stringify(id)} must give impPrefix as a lowercase letter followed by up to 10 lowercase letters, digits or hyphens, so every imp name it builds is one impd accepts`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
|
|
58
|
+
...(impPrefix === undefined ? {} : { impPrefix }),
|
|
45
59
|
...pickString(options, 'image'),
|
|
46
60
|
...pickString(options, 'guestDir'),
|
|
47
61
|
...pickString(options, 'guestATC'),
|
|
@@ -105,6 +119,15 @@ function loadTokenSource(
|
|
|
105
119
|
return { readToken: () => token };
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
// impd's imp name rule, a lowercase letter then up to 30 lowercase letters,
|
|
123
|
+
// digits or hyphens, with room left for the 20 characters of a host key.
|
|
124
|
+
// It holds no `*`, so the prefix never reads as an imp pattern.
|
|
125
|
+
const IMP_PREFIX = /^[a-z][a-z0-9-]{0,10}$/;
|
|
126
|
+
|
|
127
|
+
function isImpPrefix(value: unknown): value is string {
|
|
128
|
+
return typeof value === 'string' && IMP_PREFIX.test(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
function pickString(
|
|
109
132
|
options: Readonly<Record<string, unknown>>,
|
|
110
133
|
key: 'image' | 'guestDir' | 'guestATC',
|
|
@@ -751,6 +751,13 @@ export class DaemonConnection {
|
|
|
751
751
|
);
|
|
752
752
|
}
|
|
753
753
|
|
|
754
|
+
// Checked before any workspace is materialized for the spawn.
|
|
755
|
+
const refusal = adapter.findSpawnRefusal?.() ?? null;
|
|
756
|
+
|
|
757
|
+
if (refusal !== null) {
|
|
758
|
+
throw refusal;
|
|
759
|
+
}
|
|
760
|
+
|
|
754
761
|
const overrides = parseSpawnOverrides(entry, { model: data.model, effort: data.effort });
|
|
755
762
|
|
|
756
763
|
if (!overrides.ok) {
|
package/src/daemon/imp-port.ts
CHANGED
|
@@ -19,8 +19,10 @@ export interface ImpPort {
|
|
|
19
19
|
// The names of the secrets granted to an imp.
|
|
20
20
|
readonly readGrants: (name: string) => Promise<readonly string[]>;
|
|
21
21
|
|
|
22
|
-
// Grants a secret to an imp
|
|
23
|
-
// nothing, and
|
|
22
|
+
// Grants a secret to an imp. impd's grant add is idempotent: granting one
|
|
23
|
+
// the imp already holds changes nothing, and impd returns no creation or
|
|
24
|
+
// ownership receipt, so nothing in the result tells a new grant from an
|
|
25
|
+
// existing one or shows who added it.
|
|
24
26
|
readonly createGrant: (name: string, secret: string) => Promise<void>;
|
|
25
27
|
|
|
26
28
|
// Revokes a secret from an imp, and reports whether impd held the grant.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import { LineDecoder } from '../protocol/line-decoder';
|
|
3
3
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
4
|
+
import { buildImpName } from './build-imp-name';
|
|
4
5
|
import { buildTarArchive } from './build-tar-archive';
|
|
5
6
|
import type {
|
|
6
7
|
CommandResult,
|
|
@@ -20,10 +21,12 @@ import { ImpPortError } from './imp-port-error';
|
|
|
20
21
|
/**
|
|
21
22
|
* The options an `imp` target takes beside its provider: the image a new
|
|
22
23
|
* imp boots, the memory it gets, the folder inside each imp that atc's
|
|
23
|
-
* files go under,
|
|
24
|
-
* image
|
|
24
|
+
* files go under, the path of an atc binary already installed in the
|
|
25
|
+
* image, and the prefix every imp name the target builds starts with.
|
|
26
|
+
* None holds a credential.
|
|
25
27
|
*/
|
|
26
28
|
export interface ImpTargetOptions {
|
|
29
|
+
readonly impPrefix?: string;
|
|
27
30
|
readonly image?: string;
|
|
28
31
|
readonly memoryMib?: number;
|
|
29
32
|
readonly guestDir?: string;
|
|
@@ -48,6 +51,9 @@ interface ImpProviderOptions {
|
|
|
48
51
|
// none.
|
|
49
52
|
const GUEST_DIR = '/tmp/atc';
|
|
50
53
|
|
|
54
|
+
// The start of every imp name when the target sets no prefix.
|
|
55
|
+
const IMP_PREFIX = 'atc-';
|
|
56
|
+
|
|
51
57
|
// impd takes a lease of 10 to 3600 seconds; the daemon renews at a third of it.
|
|
52
58
|
const LEASE_SECONDS = 600;
|
|
53
59
|
|
|
@@ -86,6 +92,10 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
86
92
|
|
|
87
93
|
readonly guest: GuestLayout;
|
|
88
94
|
|
|
95
|
+
// The literal start of every imp name this provider builds: the runtime
|
|
96
|
+
// namespace an impd token's imp patterns are checked against.
|
|
97
|
+
readonly impPrefix: string;
|
|
98
|
+
|
|
89
99
|
private readonly port: ImpPort;
|
|
90
100
|
|
|
91
101
|
private readonly target: ImpTargetOptions;
|
|
@@ -109,6 +119,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
109
119
|
constructor(port: ImpPort, target: ImpTargetOptions, options: ImpProviderOptions = {}) {
|
|
110
120
|
this.port = port;
|
|
111
121
|
this.target = target;
|
|
122
|
+
this.impPrefix = target.impPrefix ?? IMP_PREFIX;
|
|
112
123
|
this.leaseSeconds = options.leaseSeconds ?? LEASE_SECONDS;
|
|
113
124
|
this.reconnectDelaysMs = options.reconnectDelaysMs ?? RECONNECT_DELAYS_MS;
|
|
114
125
|
|
|
@@ -123,10 +134,15 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
123
134
|
};
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
// The imp a host key runs on, under this provider's prefix.
|
|
138
|
+
getImpName(hostKey: string): string {
|
|
139
|
+
return buildImpName(this.impPrefix, hostKey);
|
|
140
|
+
}
|
|
141
|
+
|
|
126
142
|
// Creates the host's imp when impd holds none, then takes the daemon's
|
|
127
143
|
// lease, which boots or wakes the imp.
|
|
128
144
|
readonly prepareHost = async (request: HostRequest): Promise<void> => {
|
|
129
|
-
const name =
|
|
145
|
+
const name = this.getImpName(request.host);
|
|
130
146
|
const label = `atc-${request.daemonID}`;
|
|
131
147
|
|
|
132
148
|
this.label = label;
|
|
@@ -181,7 +197,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
181
197
|
|
|
182
198
|
readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
|
|
183
199
|
const host = this.hosts.get(spec.host) ?? {
|
|
184
|
-
name:
|
|
200
|
+
name: this.getImpName(spec.host),
|
|
185
201
|
harnesses: 0,
|
|
186
202
|
renewTimer: null,
|
|
187
203
|
suspending: false,
|
|
@@ -257,7 +273,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
257
273
|
// carries what impd showed of the other leases.
|
|
258
274
|
readonly suspendHost = async (hostKey: string): Promise<void> => {
|
|
259
275
|
const host = this.hosts.get(hostKey) ?? {
|
|
260
|
-
name:
|
|
276
|
+
name: this.getImpName(hostKey),
|
|
261
277
|
harnesses: 0,
|
|
262
278
|
renewTimer: null,
|
|
263
279
|
suspending: false,
|
|
@@ -290,7 +306,7 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
290
306
|
// A destroy ends every lease on the imp with it.
|
|
291
307
|
readonly destroyHost = async (hostKey: string): Promise<void> => {
|
|
292
308
|
const host = this.hosts.get(hostKey);
|
|
293
|
-
const name =
|
|
309
|
+
const name = this.getImpName(hostKey);
|
|
294
310
|
|
|
295
311
|
if (host !== undefined) {
|
|
296
312
|
this.stopRenewal(host);
|
|
@@ -405,9 +421,9 @@ export class ImpProvider implements ExecutionProvider {
|
|
|
405
421
|
}
|
|
406
422
|
|
|
407
423
|
try {
|
|
408
|
-
return await this.port.runCommand(
|
|
424
|
+
return await this.port.runCommand(this.getImpName(hostKey), command);
|
|
409
425
|
} catch (error) {
|
|
410
|
-
throw toHostRefusal(error,
|
|
426
|
+
throw toHostRefusal(error, this.getImpName(hostKey));
|
|
411
427
|
}
|
|
412
428
|
}
|
|
413
429
|
|
|
@@ -496,17 +512,6 @@ interface ImpHost {
|
|
|
496
512
|
suspending: boolean;
|
|
497
513
|
}
|
|
498
514
|
|
|
499
|
-
/**
|
|
500
|
-
* The imp a host key runs on: `atc-` and the first 20 letters and digits of
|
|
501
|
-
* the key, which is the atc session id of the session that owns the host.
|
|
502
|
-
*/
|
|
503
|
-
function buildImpName(hostKey: string): string {
|
|
504
|
-
return `atc-${hostKey
|
|
505
|
-
.toLowerCase()
|
|
506
|
-
.replaceAll(/[^a-z0-9]/g, '')
|
|
507
|
-
.slice(0, 20)}`;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
515
|
function buildImpSessionName(sessionID: string): string {
|
|
511
516
|
return `atc-${sessionID
|
|
512
517
|
.toLowerCase()
|
|
@@ -185,7 +185,7 @@ function isSameSession(s: Session, entry: FleetEntry): boolean {
|
|
|
185
185
|
|
|
186
186
|
// The refusals that leave one session without a terminal: its target is
|
|
187
187
|
// misconfigured, gone from the config, changed, has no provider here, or
|
|
188
|
-
// cannot start a terminal.
|
|
188
|
+
// cannot start a terminal, or its agent refuses every start.
|
|
189
189
|
const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
|
|
190
190
|
'unsupported_operation',
|
|
191
191
|
'unknown_target',
|
|
@@ -194,6 +194,7 @@ const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
|
|
|
194
194
|
'target_config_invalid',
|
|
195
195
|
'host_unavailable',
|
|
196
196
|
'auth_not_configured',
|
|
197
|
+
'auth_target_unsupported',
|
|
197
198
|
]);
|
|
198
199
|
|
|
199
200
|
async function tryAdoptTerminal(
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -857,6 +857,12 @@ export class SessionManager {
|
|
|
857
857
|
target: string,
|
|
858
858
|
options: SpawnOptions,
|
|
859
859
|
): Promise<SpawnPlan> {
|
|
860
|
+
const refusal = adapter.findSpawnRefusal?.() ?? null;
|
|
861
|
+
|
|
862
|
+
if (refusal !== null) {
|
|
863
|
+
throw refusal;
|
|
864
|
+
}
|
|
865
|
+
|
|
860
866
|
if (!provider.remote) {
|
|
861
867
|
await provider.prepareHost({ host: hostKey, daemonID: this.store.daemonID });
|
|
862
868
|
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isRecord } from './report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A named reference to a credential impd holds: the secret's name, never
|
|
5
|
+
* its value, and the rule impd applies when a request reaches the host,
|
|
6
|
+
* plus the profiles a session selecting this one needs beside it. Only
|
|
7
|
+
* the `custom` kind and the `bearer` scheme are bound.
|
|
8
|
+
*/
|
|
9
|
+
export interface AuthProfile {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly secret: string;
|
|
12
|
+
readonly kind: 'custom';
|
|
13
|
+
readonly host: string;
|
|
14
|
+
readonly header: string;
|
|
15
|
+
readonly scheme: 'bearer';
|
|
16
|
+
readonly dependencies: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AuthProfiles {
|
|
20
|
+
readonly profiles: ReadonlyMap<string, AuthProfile>;
|
|
21
|
+
readonly errors: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads the `authProfiles` map under impd's own rules for secret names,
|
|
26
|
+
* broker hosts and header names. A profile that breaks a rule is left out
|
|
27
|
+
* with an error, so a gateway that selects it is refused rather than bound
|
|
28
|
+
* to a rule impd would reject or apply differently. Dependencies are
|
|
29
|
+
* names only here; whether they resolve is a property of each selection.
|
|
30
|
+
*/
|
|
31
|
+
export function collectAuthProfiles(raw: unknown): AuthProfiles {
|
|
32
|
+
if (raw === undefined) {
|
|
33
|
+
return { profiles: new Map(), errors: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
37
|
+
return { profiles: new Map(), errors: ['authProfiles must be an object of named profiles'] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const profiles = new Map<string, AuthProfile>();
|
|
41
|
+
|
|
42
|
+
const errors: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const [name, entry] of Object.entries(raw)) {
|
|
45
|
+
const parsed = parseAuthProfile(name, entry);
|
|
46
|
+
|
|
47
|
+
if (typeof parsed === 'string') {
|
|
48
|
+
errors.push(`authProfiles.${name}: ${parsed}`);
|
|
49
|
+
} else {
|
|
50
|
+
profiles.set(name, parsed);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { profiles, errors };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// impd's secret name rule: an imp name's form, so a secret name is never a
|
|
58
|
+
// path or a flag.
|
|
59
|
+
const SECRET_NAME = /^[a-z][a-z0-9-]{0,30}$/;
|
|
60
|
+
|
|
61
|
+
// impd's broker host rule: a lowercase hostname as a CONNECT carries it, with
|
|
62
|
+
// no port and no wildcard, whose last label starts with a letter so an IP
|
|
63
|
+
// address never matches.
|
|
64
|
+
const BROKER_HOST = /^(?:[a-z0-9][a-z0-9-]{0,62}\.)+[a-z][a-z0-9-]{0,62}$/;
|
|
65
|
+
const BROKER_HOST_MAX = 253;
|
|
66
|
+
|
|
67
|
+
// impd's header name rule.
|
|
68
|
+
const HEADER_NAME = /^[a-z0-9-]{1,64}$/;
|
|
69
|
+
|
|
70
|
+
// The profile an entry holds, or the first rule it breaks.
|
|
71
|
+
function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
|
|
72
|
+
if (!isRecord(entry) || Array.isArray(entry)) {
|
|
73
|
+
return 'a profile must be an object';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const secret = entry['secret'];
|
|
77
|
+
const host = entry['host'];
|
|
78
|
+
const header = entry['header'];
|
|
79
|
+
const scheme = entry['scheme'];
|
|
80
|
+
const dependencies = entry['dependencies'];
|
|
81
|
+
|
|
82
|
+
if (entry['kind'] !== undefined && entry['kind'] !== 'custom') {
|
|
83
|
+
return 'kind must be custom, the one kind atc binds';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof secret !== 'string' || !SECRET_NAME.test(secret)) {
|
|
87
|
+
return 'secret must be a lowercase letter followed by up to 30 lowercase letters, digits or hyphens';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
|
|
91
|
+
return 'host must be a lowercase hostname such as api.example.com';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof header !== 'string' || !HEADER_NAME.test(header)) {
|
|
95
|
+
return 'header must be a lowercase header name such as authorization';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (scheme !== 'bearer') {
|
|
99
|
+
return 'scheme must be bearer, the one scheme atc binds';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (entry['user'] !== undefined) {
|
|
103
|
+
return 'user pairs only with the basic scheme, which atc does not bind';
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
return {
|
|
114
|
+
name,
|
|
115
|
+
secret,
|
|
116
|
+
kind: 'custom',
|
|
117
|
+
host,
|
|
118
|
+
header,
|
|
119
|
+
scheme,
|
|
120
|
+
dependencies: dependencies ?? [],
|
|
121
|
+
};
|
|
122
|
+
}
|