@runuai/host 0.9.149 → 0.9.151

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. 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 };
@@ -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> {
@@ -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
- function planLabeledAccount(
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
- label: string,
852
- creds: { apiKey?: string; token?: string },
853
- scope: string,
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
- plan: {
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,
@@ -19,7 +19,9 @@ import {
19
19
  type EngineConnectEventFrame,
20
20
  type EngineConnectKind,
21
21
  } from "../src/protocol";
22
- import { addEngineAccount } from "./engine-accounts";
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
- void this.run(operation, secret, label, scope).catch((error: unknown) => {
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
+ }
@@ -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) this.defaultSlotOperations.set(engine, operation);
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
- for (const key of keys) {
93
- if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
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 current = readFileSync(file, "utf8");
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
- replaceEnvFileAtomic(file, kept.length === 0 ? "" : `${kept.join("\n")}\n`);
109
- return [...new Set(removed)];
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
+ }