@runuai/host 0.9.59 → 0.9.61

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.
@@ -169,7 +169,11 @@ export interface EngineLoginManagerOptions {
169
169
  seams?: Partial<EngineLoginSeams>;
170
170
  }
171
171
 
172
- type OperationStage = "starting" | "awaiting_input" | "awaiting_callback";
172
+ type OperationStage =
173
+ | "starting"
174
+ | "awaiting_input"
175
+ | "awaiting_callback"
176
+ | "awaiting_confirmation";
173
177
 
174
178
  interface CodexCallbackTarget {
175
179
  readonly origin: string;
@@ -555,20 +559,34 @@ export class EngineLoginManager {
555
559
  /** Validate and replay a Codex callback only to this operation's listener. */
556
560
  async callback(opId: string, value: string): Promise<boolean> {
557
561
  const operation = this.operations.get(opId);
558
- if (
559
- !operation ||
560
- !this.isCurrent(operation) ||
561
- operation.engine !== "codex" ||
562
- operation.stage !== "awaiting_callback" ||
563
- operation.callbackUsed ||
564
- operation.finishing ||
565
- !operation.process ||
566
- !operation.callbackTarget
567
- ) {
562
+ // Same named-rejection contract as input() (0.9.51): every silent exit
563
+ // here left an operator staring at "Waiting for the host…" with zero
564
+ // evidence on either side (live 2026-08-21 — the callback path was the
565
+ // last breadcrumb-free room in the login pipeline).
566
+ const refuse = (reason: string): false => {
567
+ console.log(
568
+ `[engine-login] callback refused for ${opId}: ${reason}`,
569
+ );
568
570
  return false;
571
+ };
572
+ if (!operation) return refuse("unknown opId");
573
+ if (!this.isCurrent(operation)) return refuse("superseded operation");
574
+ if (operation.engine !== "codex") return refuse("wrong engine");
575
+ if (operation.stage !== "awaiting_callback") {
576
+ return refuse(`wrong stage (${operation.stage})`);
569
577
  }
578
+ if (operation.callbackUsed) return refuse("callback already used");
579
+ if (operation.finishing) return refuse("operation finishing");
580
+ if (!operation.process) return refuse("no login process");
581
+ if (!operation.callbackTarget) return refuse("no callback target");
582
+ console.log(
583
+ `[engine-login] callback accepted for ${opId} (${value.length} chars); validating`,
584
+ );
570
585
  const query = validateCodexCallback(value, operation.callbackTarget);
571
586
  if (!query) {
587
+ console.warn(
588
+ `[engine-login] callback failed validation for ${opId} (state/shape mismatch with this attempt's authorize URL)`,
589
+ );
572
590
  await this.fail(
573
591
  operation,
574
592
  "invalid_callback",
@@ -576,6 +594,9 @@ export class EngineLoginManager {
576
594
  );
577
595
  return false;
578
596
  }
597
+ console.log(
598
+ `[engine-login] callback validated for ${opId}; replaying into the login container`,
599
+ );
579
600
  operation.callbackUsed = true;
580
601
  const callbackUrl = callbackReplayUrl(operation.callbackTarget, query);
581
602
  const replay = (async () => {
@@ -584,6 +605,9 @@ export class EngineLoginManager {
584
605
  operation.process!.containerName,
585
606
  callbackUrl,
586
607
  );
608
+ console.log(
609
+ `[engine-login] callback replay delivered for ${operation.opId}; waiting for the CLI to finish`,
610
+ );
587
611
  } catch {
588
612
  if (this.isCurrent(operation)) {
589
613
  await this.fail(
@@ -799,6 +823,25 @@ export class EngineLoginManager {
799
823
  }
800
824
 
801
825
  if (operation.stage === "starting") {
826
+ const device = findCodexDeviceAuthorization(operation.outputTail);
827
+ if (device) {
828
+ console.log(
829
+ `[engine-login] codex device authorization found for ${operation.opId}; awaiting operator confirmation`,
830
+ );
831
+ operation.stage = "awaiting_confirmation";
832
+ safeEmit(
833
+ operation.emit,
834
+ authorizeEvent(operation, device.verificationUrl),
835
+ );
836
+ safeEmit(operation.emit, {
837
+ kind: "engine.login.event",
838
+ opId: operation.opId,
839
+ engine: operation.engine,
840
+ phase: "awaiting_confirmation",
841
+ message: `Enter code ${device.userCode} on the verification page. Nothing to paste here — this completes on its own.`,
842
+ });
843
+ return;
844
+ }
802
845
  const authorization = findCodexAuthorization(operation.outputTail);
803
846
  if (authorization) {
804
847
  // Symmetric with the claude branch: this breadcrumb's ABSENCE was
@@ -1469,6 +1512,24 @@ export function findSafeHttpsUrl(value: string): string | null {
1469
1512
  return null;
1470
1513
  }
1471
1514
 
1515
+ /**
1516
+ * Codex `--device-auth` banner: a verification URL plus a short one-time
1517
+ * code ("AC57-MDV9G" shape). Both the phrase gate and the code shape are
1518
+ * required so container names, hashes, or URL fragments cannot fake a
1519
+ * device prompt. Reads the merged tail — codex prints to stderr.
1520
+ */
1521
+ export function findCodexDeviceAuthorization(
1522
+ value: string,
1523
+ ): { verificationUrl: string; userCode: string } | null {
1524
+ const plain = stripTerminalControl(value);
1525
+ if (!/one-time code|device code/i.test(plain)) return null;
1526
+ const verificationUrl = findSafeHttpsUrl(plain);
1527
+ if (!verificationUrl) return null;
1528
+ const code = /(?:^|\s)([A-Z0-9]{4,8}-[A-Z0-9]{4,10})(?:\s|$)/m.exec(plain);
1529
+ if (!code) return null;
1530
+ return { verificationUrl, userCode: code[1]! };
1531
+ }
1532
+
1472
1533
  export function extractClaudeOAuthToken(value: string): string | null {
1473
1534
  const plain = stripTerminalControl(value);
1474
1535
  // The FULL `sk-ant-oat01-` prefix and a realistic minimum length are both
@@ -1901,7 +1962,12 @@ export function engineLoginContainerArgs(
1901
1962
  // because the typescript went to /dev/null.
1902
1963
  `${LOGIN_MOUNT}/typescript`,
1903
1964
  ]
1904
- : ["codex", "login"];
1965
+ : // Device-code flow: no localhost callback, no paste — the CLI prints
1966
+ // a verification URL + one-time code, the operator confirms on the
1967
+ // provider's page, and the CLI polls to completion (ADR-116 v2,
1968
+ // 2026-08-24). The callback machinery below stays as the fallback
1969
+ // for CLIs that still print the localhost authorize URL.
1970
+ ["codex", "login", "--device-auth"];
1905
1971
  if (engineLoginBackend().apple) {
1906
1972
  // Same containment contract, Apple dialect: read-only root, all caps
1907
1973
  // dropped, a plain tmpfs at /tmp (the Apple CLI takes no mount options —
@@ -2036,6 +2102,13 @@ export function codexCallbackReplayDockerRequest(
2036
2102
  "--fail",
2037
2103
  "--silent",
2038
2104
  "--show-error",
2105
+ // Follow the callback's redirect to the CLI's /success page: codex
2106
+ // writes auth.json on the callback but EXITS only after serving that
2107
+ // page — a real browser follows; a replay that stops at the redirect
2108
+ // leaves a completed login waiting forever (live 2026-08-24: the
2109
+ // first-ever completed pane codex login needed a manual /success
2110
+ // fetch to let the CLI exit).
2111
+ "--location",
2039
2112
  "--max-time",
2040
2113
  "10",
2041
2114
  "--output",
@@ -73,21 +73,25 @@ function gatewayPort(value: string | undefined): number {
73
73
  export const MCP_GATEWAY_PORT = gatewayPort(
74
74
  process.env.UAI_MCP_GATEWAY_PORT,
75
75
  );
76
- /** Loopback for Docker (Desktop/OrbStack forward host.docker.internal to it).
77
- * Apple vmnet guests cannot reach host loopback at all, so the apple backend
78
- * defaults to all interfaces the unguessable per-task path token remains
79
- * the auth (same posture the operator override has always allowed). Resolved
80
- * per call: the runtime pin lands before the production listener starts, and
81
- * a pin never changes within a process. */
76
+ /** Loopback works only where the container runtime forwards
77
+ * host.docker.internal INTO the host loopback macOS Docker
78
+ * (Desktop/OrbStack). Native Linux docker resolves host.docker.internal to
79
+ * the bridge gateway IP, where a loopback bind is unreachable: every task's
80
+ * MCP config carried a dead URL and the gateway showed connection-refused
81
+ * from inside containers (live 2026-08-24, first Linux host with an MCP
82
+ * connection). Apple vmnet guests cannot reach host loopback either. Both
83
+ * non-mac-docker cases therefore bind all interfaces — the unguessable
84
+ * per-task path token remains the auth (same posture the operator override
85
+ * has always allowed). Resolved per call: the runtime pin lands before the
86
+ * production listener starts, and a pin never changes within a process. */
82
87
  function gatewayBindAddress(): string {
83
88
  const configured = process.env.UAI_MCP_GATEWAY_BIND;
84
89
  if (configured) return configured;
85
90
  // The pin, not the ready-gated identity: the production listener starts
86
91
  // during boot while activation still reports `checking`, and a loopback
87
92
  // bind chosen then would strand every vmnet guest.
88
- return pinnedContainerRuntimeProvider() === "apple-container"
89
- ? "0.0.0.0"
90
- : "127.0.0.1";
93
+ if (pinnedContainerRuntimeProvider() === "apple-container") return "0.0.0.0";
94
+ return process.platform === "darwin" ? "127.0.0.1" : "0.0.0.0";
91
95
  }
92
96
 
93
97
  /** Host address a task container dials to reach this gateway. Docker guests
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.59",
3
+ "version": "0.9.61",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/protocol.ts CHANGED
@@ -345,6 +345,9 @@ export type EngineLoginEventFrame =
345
345
  | "starting"
346
346
  | "awaiting_input"
347
347
  | "awaiting_callback"
348
+ // Device-code flow (codex `--device-auth`): the operator confirms a
349
+ // short one-time code on the provider's page; nothing is pasted back.
350
+ | "awaiting_confirmation"
348
351
  | "succeeded"
349
352
  | "cancelled";
350
353
  url?: never;
@@ -787,6 +790,7 @@ export function isEngineLoginEventFrame(
787
790
  (frame.phase === "starting" ||
788
791
  frame.phase === "awaiting_input" ||
789
792
  frame.phase === "awaiting_callback" ||
793
+ frame.phase === "awaiting_confirmation" ||
790
794
  frame.phase === "succeeded" ||
791
795
  frame.phase === "cancelled") &&
792
796
  !Object.hasOwn(frame, "errorCode")