@runuai/host 0.9.150 → 0.9.152
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1837 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +650 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
package/lib/db.ts
CHANGED
|
@@ -64,6 +64,17 @@ export function getDb(): ReturnType<typeof drizzle> {
|
|
|
64
64
|
return cached;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Run `use` on the connection behind `getDb()`, for what drizzle does not
|
|
69
|
+
* express: a pragma, VACUUM, a WAL checkpoint. Never a second connection:
|
|
70
|
+
* `secure_delete` is a setting of the connection, so it has to be on the one
|
|
71
|
+
* every delete goes through. VACUUM and a checkpoint run outside any
|
|
72
|
+
* transaction.
|
|
73
|
+
*/
|
|
74
|
+
export function withHostSqlite<T>(use: (sqlite: Database.Database) => T): T {
|
|
75
|
+
return use(getDb().$client);
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
export type Db = ReturnType<typeof getDb>;
|
|
68
79
|
export type { HostTask } from "../db/schema";
|
|
69
80
|
export { schema };
|
package/lib/docker-exec.ts
CHANGED
|
@@ -39,7 +39,9 @@ export function dockerCli(
|
|
|
39
39
|
* each output stream. Use for legitimately verbose bounded operations
|
|
40
40
|
* whose complete compiler/install log must not accumulate in host RAM. */
|
|
41
41
|
tailOutputBytes?: number;
|
|
42
|
-
/** Selection probes can intentionally ignore this process's pinned host
|
|
42
|
+
/** Selection probes can intentionally ignore this process's pinned host,
|
|
43
|
+
* and an exec that names a credential bare carries its value here
|
|
44
|
+
* (cliExecEnv in task-environment/docker.ts). */
|
|
43
45
|
env?: NodeJS.ProcessEnv;
|
|
44
46
|
} = {},
|
|
45
47
|
): Promise<DockerResult> {
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type CredentialStore,
|
|
55
55
|
} from "./credential-store";
|
|
56
56
|
import { engineAccountListPaths } from "./credential-store-scope";
|
|
57
|
+
import type { EngineCaptureMaterial } from "./capture-flow";
|
|
57
58
|
import { getDb, schema } from "./db";
|
|
58
59
|
import { brokeredFilesFor } from "./token-broker";
|
|
59
60
|
import {
|
|
@@ -846,18 +847,16 @@ type LabeledAccountPlan =
|
|
|
846
847
|
|
|
847
848
|
type PlannedAccount = { ok: false; message: string } | { ok: true; plan: LabeledAccountPlan };
|
|
848
849
|
|
|
849
|
-
|
|
850
|
+
/**
|
|
851
|
+
* What a pasted token or API key becomes for an engine: env vars, or (Codex)
|
|
852
|
+
* an `auth.json` holding the key. Shared by local accounts and ADR-154
|
|
853
|
+
* capture flows, which carry no local scope.
|
|
854
|
+
*/
|
|
855
|
+
export function pastedSecretMaterial(
|
|
850
856
|
kind: string,
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
): PlannedAccount {
|
|
855
|
-
if (!isEngineAccountScope(scope)) {
|
|
856
|
-
return { ok: false, message: "The account scope is invalid." };
|
|
857
|
-
}
|
|
858
|
-
const trimmedLabel = label.trim();
|
|
859
|
-
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
860
|
-
const secret = (creds.token ?? creds.apiKey ?? "").trim();
|
|
857
|
+
rawSecret: string,
|
|
858
|
+
): { ok: true; material: EngineCaptureMaterial } | { ok: false; message: string } {
|
|
859
|
+
const secret = rawSecret.trim();
|
|
861
860
|
if (!secret) return { ok: false, message: "Paste a token or API key." };
|
|
862
861
|
if (/\s/.test(secret) || secret.length < 12) {
|
|
863
862
|
return { ok: false, message: "That doesn't look like a token. Paste just the value." };
|
|
@@ -866,36 +865,18 @@ function planLabeledAccount(
|
|
|
866
865
|
const envVar = secret.startsWith("sk-ant-api")
|
|
867
866
|
? "ANTHROPIC_API_KEY"
|
|
868
867
|
: "CLAUDE_CODE_OAUTH_TOKEN";
|
|
869
|
-
return {
|
|
870
|
-
ok: true,
|
|
871
|
-
plan: {
|
|
872
|
-
authKind: "env",
|
|
873
|
-
label: trimmedLabel,
|
|
874
|
-
env: { [envVar]: secret },
|
|
875
|
-
message: `Added Claude account "${trimmedLabel}".`,
|
|
876
|
-
},
|
|
877
|
-
};
|
|
868
|
+
return { ok: true, material: { authKind: "env", env: { [envVar]: secret } } };
|
|
878
869
|
}
|
|
879
870
|
const envKeyVar = API_KEY_ENV_VARS[kind];
|
|
880
871
|
if (envKeyVar) {
|
|
881
|
-
return {
|
|
882
|
-
ok: true,
|
|
883
|
-
plan: {
|
|
884
|
-
authKind: "env",
|
|
885
|
-
label: trimmedLabel,
|
|
886
|
-
env: { [envKeyVar]: secret },
|
|
887
|
-
message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
|
|
888
|
-
},
|
|
889
|
-
};
|
|
872
|
+
return { ok: true, material: { authKind: "env", env: { [envKeyVar]: secret } } };
|
|
890
873
|
}
|
|
891
874
|
if (kind === "codex") {
|
|
892
875
|
return {
|
|
893
876
|
ok: true,
|
|
894
|
-
|
|
877
|
+
material: {
|
|
895
878
|
authKind: "config-dir",
|
|
896
|
-
label: trimmedLabel,
|
|
897
879
|
files: { "auth.json": `${JSON.stringify({ OPENAI_API_KEY: secret }, null, 2)}\n` },
|
|
898
|
-
message: `Added Codex account "${trimmedLabel}".`,
|
|
899
880
|
},
|
|
900
881
|
};
|
|
901
882
|
}
|
|
@@ -905,6 +886,35 @@ function planLabeledAccount(
|
|
|
905
886
|
};
|
|
906
887
|
}
|
|
907
888
|
|
|
889
|
+
function planLabeledAccount(
|
|
890
|
+
kind: string,
|
|
891
|
+
label: string,
|
|
892
|
+
creds: { apiKey?: string; token?: string },
|
|
893
|
+
scope: string,
|
|
894
|
+
): PlannedAccount {
|
|
895
|
+
if (!isEngineAccountScope(scope)) {
|
|
896
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
897
|
+
}
|
|
898
|
+
const trimmedLabel = label.trim();
|
|
899
|
+
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
900
|
+
const pasted = pastedSecretMaterial(kind, creds.token ?? creds.apiKey ?? "");
|
|
901
|
+
if (!pasted.ok) return pasted;
|
|
902
|
+
const engineName =
|
|
903
|
+
kind === "claude"
|
|
904
|
+
? "Claude"
|
|
905
|
+
: kind === "codex"
|
|
906
|
+
? "Codex"
|
|
907
|
+
: (API_KEY_ENGINE_LABELS[kind] ?? kind);
|
|
908
|
+
const message = `Added ${engineName} account "${trimmedLabel}".`;
|
|
909
|
+
return {
|
|
910
|
+
ok: true,
|
|
911
|
+
plan:
|
|
912
|
+
pasted.material.authKind === "env"
|
|
913
|
+
? { authKind: "env", label: trimmedLabel, env: pasted.material.env, message }
|
|
914
|
+
: { authKind: "config-dir", label: trimmedLabel, files: pasted.material.files, message },
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
908
918
|
function planCapturedConfigDirAccount(
|
|
909
919
|
kind: "codex" | "grok",
|
|
910
920
|
label: string,
|
package/lib/engine-connect.ts
CHANGED
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
type EngineConnectEventFrame,
|
|
20
20
|
type EngineConnectKind,
|
|
21
21
|
} from "../src/protocol";
|
|
22
|
-
import {
|
|
22
|
+
import type { CaptureFlowWire, CaptureResultWire } from "../src/cloud-credentials";
|
|
23
|
+
import type { CaptureOutcome, EngineCaptureMaterial } from "./capture-flow";
|
|
24
|
+
import { addEngineAccount, pastedSecretMaterial } from "./engine-accounts";
|
|
23
25
|
import { connectEngine, type EngineKind } from "./engines";
|
|
24
26
|
|
|
25
27
|
const CONNECT_TIMEOUT_MS = 2 * 60_000;
|
|
@@ -54,6 +56,13 @@ export interface EngineConnectManagerOptions {
|
|
|
54
56
|
secret: string,
|
|
55
57
|
scope: string,
|
|
56
58
|
) => AddAccountOutcome | Promise<AddAccountOutcome>;
|
|
59
|
+
/** ADR-154: the sign-in box's sink for a capture flow. Absent elsewhere:
|
|
60
|
+
* only the box writes cloud credentials. */
|
|
61
|
+
captureAccount?: (
|
|
62
|
+
flow: CaptureFlowWire,
|
|
63
|
+
kind: EngineConnectKind,
|
|
64
|
+
material: EngineCaptureMaterial,
|
|
65
|
+
) => Promise<CaptureOutcome>;
|
|
57
66
|
timers?: {
|
|
58
67
|
setTimeout(handler: () => void, ms: number): NodeJS.Timeout;
|
|
59
68
|
clearTimeout(timer: NodeJS.Timeout): void;
|
|
@@ -70,6 +79,9 @@ interface ConnectOperation {
|
|
|
70
79
|
|
|
71
80
|
export class EngineConnectManager {
|
|
72
81
|
private readonly operations = new Map<string, ConnectOperation>();
|
|
82
|
+
/** Each connect's work, which goes on writing (a key, an account) after
|
|
83
|
+
* its operation is finished or stopped. */
|
|
84
|
+
private readonly runs = new Set<Promise<void>>();
|
|
73
85
|
|
|
74
86
|
constructor(private readonly options: EngineConnectManagerOptions) {}
|
|
75
87
|
|
|
@@ -80,6 +92,7 @@ export class EngineConnectManager {
|
|
|
80
92
|
label: string | undefined,
|
|
81
93
|
emit: EmitEngineConnectEvent,
|
|
82
94
|
scope: string | undefined = undefined,
|
|
95
|
+
flow: CaptureFlowWire | undefined = undefined,
|
|
83
96
|
): boolean {
|
|
84
97
|
if (!isHostOpId(opId) || this.operations.has(opId)) return false;
|
|
85
98
|
if (scope !== undefined && !isEngineAccountScope(scope)) return false;
|
|
@@ -117,16 +130,24 @@ export class EngineConnectManager {
|
|
|
117
130
|
this.finish(operation, false, "Engine connect timed out.");
|
|
118
131
|
}, CONNECT_TIMEOUT_MS);
|
|
119
132
|
operation.timer.unref?.();
|
|
120
|
-
|
|
133
|
+
const running = this.run(operation, secret, label, scope, flow).catch((error: unknown) => {
|
|
121
134
|
this.finish(
|
|
122
135
|
operation,
|
|
123
136
|
false,
|
|
124
137
|
error instanceof Error ? error.message : "Engine connect failed.",
|
|
125
138
|
);
|
|
126
139
|
});
|
|
140
|
+
this.runs.add(running);
|
|
141
|
+
void running.finally(() => this.runs.delete(running));
|
|
127
142
|
return true;
|
|
128
143
|
}
|
|
129
144
|
|
|
145
|
+
/** Resolves once no connect's work is under way, a stopped one's included:
|
|
146
|
+
* what it writes has been written (ADR-154 §13's leaving waits for it). */
|
|
147
|
+
async idle(): Promise<void> {
|
|
148
|
+
while (this.runs.size > 0) await Promise.allSettled([...this.runs]);
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
stop(opId: string): void {
|
|
131
152
|
const operation = this.operations.get(opId);
|
|
132
153
|
if (operation) this.finish(operation, false, "Cancelled.");
|
|
@@ -143,8 +164,30 @@ export class EngineConnectManager {
|
|
|
143
164
|
secret: string | undefined,
|
|
144
165
|
label: string | undefined,
|
|
145
166
|
scope: string | undefined,
|
|
167
|
+
flow: CaptureFlowWire | undefined,
|
|
146
168
|
): Promise<void> {
|
|
147
169
|
const { engine } = operation;
|
|
170
|
+
// ADR-154: a capture flow writes one revision of a cloud credential, on
|
|
171
|
+
// the sign-in box only, from the pasted secret alone.
|
|
172
|
+
if (flow !== undefined) {
|
|
173
|
+
if (!this.options.workspaceOnly || !this.options.captureAccount) {
|
|
174
|
+
this.finish(operation, false, "Only the Uai Cloud sign-in box writes cloud credentials.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (secret === undefined) {
|
|
178
|
+
this.finish(operation, false, "Paste a token or API key.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const pasted = pastedSecretMaterial(engine, secret);
|
|
182
|
+
if (!pasted.ok) {
|
|
183
|
+
this.finish(operation, false, pasted.message);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const outcome = await this.options.captureAccount(flow, engine, pasted.material);
|
|
187
|
+
if (operation.done) return;
|
|
188
|
+
this.finish(operation, outcome.ok, outcome.message, undefined, outcome.capture);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
148
191
|
const emitLine = (line: string): void => {
|
|
149
192
|
const bounded = line.trim().slice(0, MAX_ENGINE_CONNECT_LINE_CHARS);
|
|
150
193
|
if (!bounded || operation.done) return;
|
|
@@ -239,6 +282,7 @@ export class EngineConnectManager {
|
|
|
239
282
|
ok: boolean,
|
|
240
283
|
message: string,
|
|
241
284
|
accountId?: string,
|
|
285
|
+
capture?: CaptureResultWire,
|
|
242
286
|
): void {
|
|
243
287
|
if (operation.done) return;
|
|
244
288
|
operation.done = true;
|
|
@@ -254,6 +298,7 @@ export class EngineConnectManager {
|
|
|
254
298
|
ok,
|
|
255
299
|
message,
|
|
256
300
|
...(ok && accountId !== undefined ? { accountId } : {}),
|
|
301
|
+
...(ok && capture !== undefined ? { capture } : {}),
|
|
257
302
|
});
|
|
258
303
|
}
|
|
259
304
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
|
|
2
|
+
|
|
3
|
+
/** Every env-token engine's credential names, of any account kind. */
|
|
4
|
+
export const ENGINE_CREDENTIAL_ENV_NAMES: ReadonlySet<string> = new Set(
|
|
5
|
+
Object.values(ENGINE_ENV_CREDENTIAL_KEYS).flat(),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether an exec env entry carries an env-token engine's credential: a name
|
|
10
|
+
* some account kind may set (ENGINE_ENV_CREDENTIAL_KEYS is the one list).
|
|
11
|
+
* ADR-154 §6 never puts such a value on the runner's argv, which every local
|
|
12
|
+
* user can read with `ps`. The docker and Apple providers pass it by name,
|
|
13
|
+
* from the exec client's own environment (cliExecEnv in
|
|
14
|
+
* task-environment/docker.ts). The machine and self providers, whose
|
|
15
|
+
* request environment rides a command line (buildRemoteCommand in
|
|
16
|
+
* machine-exec.ts), carry a leased launch's values in its single-use
|
|
17
|
+
* admission instead (credential-consumers/engine-anchor.ts).
|
|
18
|
+
*/
|
|
19
|
+
export function isEngineCredentialEnvName(name: string): boolean {
|
|
20
|
+
return ENGINE_CREDENTIAL_ENV_NAMES.has(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An agent's task token (ADR-048: its own permissions on the task's API)
|
|
25
|
+
* and the Managed Engine's names for it (ADR-128: the gateway's bearer):
|
|
26
|
+
* bearer credentials as an engine's own are.
|
|
27
|
+
*/
|
|
28
|
+
export const TASK_SECRET_ENV_NAMES: ReadonlySet<string> = new Set([
|
|
29
|
+
"UAI_TASK_TOKEN",
|
|
30
|
+
"UAI_INFERENCE_TOKEN",
|
|
31
|
+
"OPENAI_API_KEY",
|
|
32
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Every name whose value never rides a runner's command line. */
|
|
36
|
+
export const LAUNCH_SECRET_ENV_NAMES: ReadonlySet<string> = new Set([
|
|
37
|
+
...ENGINE_CREDENTIAL_ENV_NAMES,
|
|
38
|
+
...TASK_SECRET_ENV_NAMES,
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether an exec env entry carries a launch's secret: an engine's
|
|
43
|
+
* credential, or an agent's task token under any of its names. Like an
|
|
44
|
+
* engine credential, such a value is passed by name on the docker and Apple
|
|
45
|
+
* providers, and carried in a leased launch's admission on the machine and
|
|
46
|
+
* self providers (ADR-154 §6).
|
|
47
|
+
*/
|
|
48
|
+
export function isLaunchSecretEnvName(name: string): boolean {
|
|
49
|
+
return LAUNCH_SECRET_ENV_NAMES.has(name);
|
|
50
|
+
}
|
package/lib/engine-login.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
type EngineLoginEventFrame,
|
|
41
41
|
type EngineLoginKind,
|
|
42
42
|
} from "../src/protocol";
|
|
43
|
+
import type { CaptureFlowWire } from "../src/cloud-credentials";
|
|
44
|
+
import type { CaptureOutcome, EngineCaptureMaterial } from "./capture-flow";
|
|
43
45
|
import {
|
|
44
46
|
addCodexAccountFromAuthJson,
|
|
45
47
|
addEngineAccount,
|
|
@@ -189,6 +191,12 @@ export interface EngineLoginSeams {
|
|
|
189
191
|
scope: string,
|
|
190
192
|
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
191
193
|
persistGrokAuth(sourceFile: string): Promise<void>;
|
|
194
|
+
/** ADR-154: the sign-in box's sink for a capture flow; absent elsewhere. */
|
|
195
|
+
captureEngineAccount?(
|
|
196
|
+
flow: CaptureFlowWire,
|
|
197
|
+
engine: EngineLoginKind,
|
|
198
|
+
material: EngineCaptureMaterial,
|
|
199
|
+
): Promise<CaptureOutcome>;
|
|
192
200
|
/** Optional engines ride the standard image only when configured; a fresh
|
|
193
201
|
* account may be the first configuration → rebuild (queue-deduped). */
|
|
194
202
|
ensureStandardImage(): Promise<void>;
|
|
@@ -256,6 +264,9 @@ interface LoginOperation {
|
|
|
256
264
|
callbackUsed: boolean;
|
|
257
265
|
pendingInput: string | null;
|
|
258
266
|
callbackTarget: CodexCallbackTarget | null;
|
|
267
|
+
/** ADR-154: when set, the capture is written as this revision of a cloud
|
|
268
|
+
* credential and nothing else happens to it. */
|
|
269
|
+
captureFlow: CaptureFlowWire | null;
|
|
259
270
|
/** ADR-116: when set, the captured credential lands in a labeled extra
|
|
260
271
|
* account instead of the default slot. */
|
|
261
272
|
accountLabel: string | null;
|
|
@@ -466,8 +477,21 @@ export class EngineLoginManager {
|
|
|
466
477
|
emit: EmitEngineLoginEvent,
|
|
467
478
|
label?: string,
|
|
468
479
|
scope?: string,
|
|
480
|
+
flow?: CaptureFlowWire,
|
|
469
481
|
): boolean {
|
|
470
482
|
if (!isHostOpId(opId)) return false;
|
|
483
|
+
// ADR-154: a capture flow names its own destination; it runs only on the
|
|
484
|
+
// sign-in box, which alone writes cloud credentials.
|
|
485
|
+
if (
|
|
486
|
+
flow !== undefined &&
|
|
487
|
+
(!this.workspaceOnly || this.seams.captureEngineAccount === undefined)
|
|
488
|
+
) {
|
|
489
|
+
safeEmit(
|
|
490
|
+
emit,
|
|
491
|
+
failedEvent(opId, engine, "start_failed", "Only the Uai Cloud sign-in box writes cloud credentials."),
|
|
492
|
+
);
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
471
495
|
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
472
496
|
safeEmit(
|
|
473
497
|
emit,
|
|
@@ -479,6 +503,7 @@ export class EngineLoginManager {
|
|
|
479
503
|
// host-scope account would belong to a runner that owns no credential.
|
|
480
504
|
if (
|
|
481
505
|
this.workspaceOnly &&
|
|
506
|
+
flow === undefined &&
|
|
482
507
|
(scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
|
|
483
508
|
) {
|
|
484
509
|
safeEmit(
|
|
@@ -560,12 +585,15 @@ export class EngineLoginManager {
|
|
|
560
585
|
callbackUsed: false,
|
|
561
586
|
pendingInput: null,
|
|
562
587
|
callbackTarget: null,
|
|
588
|
+
captureFlow: flow ?? null,
|
|
563
589
|
accountLabel: label ?? null,
|
|
564
590
|
accountScope: scope ?? HOST_ACCOUNT_SCOPE,
|
|
565
591
|
postInputOutputSeen: false,
|
|
566
592
|
};
|
|
567
593
|
this.operations.set(opId, operation);
|
|
568
|
-
if (label === undefined
|
|
594
|
+
if (label === undefined && flow === undefined) {
|
|
595
|
+
this.defaultSlotOperations.set(engine, operation);
|
|
596
|
+
}
|
|
569
597
|
operation.timer = this.seams.timers.setTimeout(() => {
|
|
570
598
|
void this.timeout(operation);
|
|
571
599
|
}, ENGINE_LOGIN_TIMEOUT_MS);
|
|
@@ -1088,6 +1116,13 @@ export class EngineLoginManager {
|
|
|
1088
1116
|
token: string,
|
|
1089
1117
|
): Promise<void> {
|
|
1090
1118
|
let terminal: EngineLoginEventFrame;
|
|
1119
|
+
if (operation.captureFlow !== null) {
|
|
1120
|
+
await this.finishCapture(operation, {
|
|
1121
|
+
authKind: "env",
|
|
1122
|
+
env: { CLAUDE_CODE_OAUTH_TOKEN: token },
|
|
1123
|
+
});
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1091
1126
|
if (operation.accountLabel !== null) {
|
|
1092
1127
|
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
1093
1128
|
// Extras are picked up at the next agent spawn / rotation, so no
|
|
@@ -1141,6 +1176,31 @@ export class EngineLoginManager {
|
|
|
1141
1176
|
await this.finishCommit(operation, terminal);
|
|
1142
1177
|
}
|
|
1143
1178
|
|
|
1179
|
+
/** ADR-154: hand a capture to the box's sink and report its revision. */
|
|
1180
|
+
private async finishCapture(
|
|
1181
|
+
operation: LoginOperation,
|
|
1182
|
+
material: EngineCaptureMaterial,
|
|
1183
|
+
): Promise<void> {
|
|
1184
|
+
const sink = this.seams.captureEngineAccount;
|
|
1185
|
+
const outcome =
|
|
1186
|
+
sink && operation.captureFlow
|
|
1187
|
+
? await sink(operation.captureFlow, operation.engine, material)
|
|
1188
|
+
: { ok: false, message: "Only the Uai Cloud sign-in box writes cloud credentials." };
|
|
1189
|
+
await this.finishCommit(
|
|
1190
|
+
operation,
|
|
1191
|
+
outcome.ok && outcome.capture
|
|
1192
|
+
? {
|
|
1193
|
+
kind: "engine.login.event",
|
|
1194
|
+
opId: operation.opId,
|
|
1195
|
+
engine: operation.engine,
|
|
1196
|
+
phase: "succeeded",
|
|
1197
|
+
message: outcome.message,
|
|
1198
|
+
capture: outcome.capture,
|
|
1199
|
+
}
|
|
1200
|
+
: failedEvent(operation.opId, operation.engine, "persist_failed", outcome.message),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1144
1204
|
private beginCodexFinish(operation: LoginOperation): void {
|
|
1145
1205
|
if (
|
|
1146
1206
|
!this.isCurrent(operation) ||
|
|
@@ -1164,6 +1224,28 @@ export class EngineLoginManager {
|
|
|
1164
1224
|
const authFile = join(tempDir, grok ? "grok" : "codex", "auth.json");
|
|
1165
1225
|
const label = grok ? "Grok" : "Codex";
|
|
1166
1226
|
let terminal: EngineLoginEventFrame;
|
|
1227
|
+
if (operation.captureFlow !== null) {
|
|
1228
|
+
let contents: string;
|
|
1229
|
+
try {
|
|
1230
|
+
contents = await readFile(authFile, "utf8");
|
|
1231
|
+
} catch {
|
|
1232
|
+
await this.finishCommit(
|
|
1233
|
+
operation,
|
|
1234
|
+
failedEvent(
|
|
1235
|
+
operation.opId,
|
|
1236
|
+
operation.engine,
|
|
1237
|
+
"persist_failed",
|
|
1238
|
+
`${label} authorized, but its credential could not be read.`,
|
|
1239
|
+
),
|
|
1240
|
+
);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
await this.finishCapture(operation, {
|
|
1244
|
+
authKind: "config-dir",
|
|
1245
|
+
files: { "auth.json": contents.endsWith("\n") ? contents : `${contents}\n` },
|
|
1246
|
+
});
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1167
1249
|
if (operation.accountLabel !== null) {
|
|
1168
1250
|
// ADR-116: labeled capture — the auth.json this login produced becomes
|
|
1169
1251
|
// an isolated extra account instead of overwriting ~/.codex.
|
package/lib/env-file.ts
CHANGED
|
@@ -89,13 +89,28 @@ export function removeEnvValuesAtomic(
|
|
|
89
89
|
keys: readonly string[],
|
|
90
90
|
): string[] {
|
|
91
91
|
if (keys.length === 0 || !existsSync(file)) return [];
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
assertEnvKeys(keys);
|
|
93
|
+
const { body, removed } = withoutEnvKeys(readFileSync(file, "utf8"), keys);
|
|
94
|
+
if (removed.length > 0) replaceEnvFileAtomic(file, body);
|
|
95
|
+
return removed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `body` without the lines that set any of `keys`, and the keys it dropped.
|
|
100
|
+
* A line sets a key as every writer here writes one: `KEY=value`, the key at
|
|
101
|
+
* the start of its own line. Pure, so that a caller that reads the file its
|
|
102
|
+
* own way (the cutover purge, which must not trust what it opens) drops
|
|
103
|
+
* exactly the lines `removeEnvValuesAtomic` would. With nothing dropped,
|
|
104
|
+
* `body` comes back as it was.
|
|
105
|
+
*/
|
|
106
|
+
export function withoutEnvKeys(
|
|
107
|
+
body: string,
|
|
108
|
+
keys: readonly string[],
|
|
109
|
+
): { body: string; removed: string[] } {
|
|
110
|
+
assertEnvKeys(keys);
|
|
95
111
|
const drop = new Set(keys);
|
|
96
112
|
const removed: string[] = [];
|
|
97
|
-
const
|
|
98
|
-
const kept = current.split(/\r?\n/).filter((line, index, lines) => {
|
|
113
|
+
const kept = body.split(/\r?\n/).filter((line, index, lines) => {
|
|
99
114
|
if (index === lines.length - 1 && line === "") return false;
|
|
100
115
|
const equals = line.indexOf("=");
|
|
101
116
|
if (equals <= 0) return true;
|
|
@@ -104,9 +119,48 @@ export function removeEnvValuesAtomic(
|
|
|
104
119
|
removed.push(key);
|
|
105
120
|
return false;
|
|
106
121
|
});
|
|
107
|
-
if (removed.length === 0) return [];
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
if (removed.length === 0) return { body, removed: [] };
|
|
123
|
+
return {
|
|
124
|
+
body: kept.length === 0 ? "" : `${kept.join("\n")}\n`,
|
|
125
|
+
removed: [...new Set(removed)],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The keys among `keys` whose line, as withoutEnvKeys finds it, opens a
|
|
131
|
+
* quoted value that it does not close: a value that runs on past its line,
|
|
132
|
+
* of which dropping that line would leave the rest. Read by this rule
|
|
133
|
+
* alone, not by any parser's notion of what a quoted value may hold, so it
|
|
134
|
+
* says the same under every Node: the value, its leading spaces aside,
|
|
135
|
+
* starts with `"`, `'` or a backtick, and no unescaped same quote follows on
|
|
136
|
+
* its line (a backslash takes the character after it).
|
|
137
|
+
*/
|
|
138
|
+
export function keysQuotedPastTheirLine(body: string, keys: readonly string[]): string[] {
|
|
139
|
+
assertEnvKeys(keys);
|
|
140
|
+
const wanted = new Set(keys);
|
|
141
|
+
const found = new Set<string>();
|
|
142
|
+
for (const line of body.split(/\r?\n/)) {
|
|
143
|
+
const equals = line.indexOf("=");
|
|
144
|
+
if (equals <= 0) continue;
|
|
145
|
+
const key = line.slice(0, equals);
|
|
146
|
+
if (!wanted.has(key)) continue;
|
|
147
|
+
const value = line.slice(equals + 1).trimStart();
|
|
148
|
+
const quote = value[0];
|
|
149
|
+
if (quote !== '"' && quote !== "'" && quote !== "`") continue;
|
|
150
|
+
let closed = false;
|
|
151
|
+
for (let index = 1; index < value.length && !closed; index += 1) {
|
|
152
|
+
if (value[index] === "\\") index += 1;
|
|
153
|
+
else closed = value[index] === quote;
|
|
154
|
+
}
|
|
155
|
+
if (!closed) found.add(key);
|
|
156
|
+
}
|
|
157
|
+
return [...found];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assertEnvKeys(keys: readonly string[]): void {
|
|
161
|
+
for (const key of keys) {
|
|
162
|
+
if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
|
|
163
|
+
}
|
|
110
164
|
}
|
|
111
165
|
|
|
112
166
|
/** Capture the exact bytes needed to roll back a multi-store config write. */
|
package/lib/env.ts
CHANGED
|
@@ -76,6 +76,11 @@ if (!parsed.success) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const raw = parsed.success ? parsed.data : schema.parse({});
|
|
79
|
+
// The PAT is validated above but kept nowhere here: its one reader takes it
|
|
80
|
+
// from process.env when it injects it (github-tokens.ts), and the cutover
|
|
81
|
+
// purge removes it from there (ADR-154 §13). A long-lived copy in this
|
|
82
|
+
// module would outlive that purge for the life of the process.
|
|
83
|
+
delete raw.UAI_GH_PAT_FALLBACK;
|
|
79
84
|
const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/lib
|
|
80
85
|
|
|
81
86
|
function expandHome(path: string): string {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §13: removing a credential by one name must not leave it under
|
|
3
|
+
* another. Shared by the cutover purge and the credential store's cleanup of
|
|
4
|
+
* staged writes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { lstatSync, readdirSync, readlinkSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Throws, naming each, while removing `path` would leave another name of
|
|
12
|
+
* what is in it: a link in it that leads out of it (what it leads to stays,
|
|
13
|
+
* and the runner's own writes, which follow links, may have filled it), and
|
|
14
|
+
* a file in it with a second name (a hard link: the other name keeps its
|
|
15
|
+
* bytes). A link that leads somewhere inside it goes with it. Walked with
|
|
16
|
+
* lstat, following nothing; a link's target is taken from its own
|
|
17
|
+
* directory, by name, which is how the kernel follows it: one that leads
|
|
18
|
+
* out through another link on the way is caught at that link. `path` itself
|
|
19
|
+
* is no link (the callers checked).
|
|
20
|
+
*/
|
|
21
|
+
export function assertNoAliases(path: string): void {
|
|
22
|
+
const found: string[] = [];
|
|
23
|
+
const visit = (at: string): void => {
|
|
24
|
+
const stats = lstatSync(at, { bigint: true });
|
|
25
|
+
if (stats.isSymbolicLink()) {
|
|
26
|
+
const to = resolve(dirname(at), readlinkSync(at));
|
|
27
|
+
if (!isWithin(path, to)) found.push(`${at} (a symbolic link to ${to})`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (stats.isDirectory()) {
|
|
31
|
+
for (const name of readdirSync(at)) visit(join(at, name));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (stats.nlink > 1n) found.push(`${at} (a file with ${stats.nlink} names)`);
|
|
35
|
+
};
|
|
36
|
+
visit(path);
|
|
37
|
+
if (found.length > 0) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`${found.join(", ")}: removing it would leave what the runner wrote through ` +
|
|
40
|
+
`${found.length === 1 ? "that" : "those"}; nothing here is removed until ` +
|
|
41
|
+
`${found.length === 1 ? "it is" : "they are"} gone`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isWithin(dir: string, path: string): boolean {
|
|
47
|
+
const rel = relative(dir, path);
|
|
48
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
49
|
+
}
|