@runuai/host 0.9.88 → 0.9.90

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/lib/codex-auth.ts CHANGED
@@ -3,13 +3,32 @@
3
3
  * in {@link ./github-tokens} (ADR-027/033 → host 0.8.1).
4
4
  *
5
5
  * The host owner's `~/.codex` is docker-cp'd into each task container ONCE, at
6
- * task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex —
7
- * the refresh token gets revoked (a login elsewhere) and `codex login` rewrites
8
- * `~/.codex/auth.json` — already-running containers keep their stale copy, so a
9
- * re-login otherwise only helps NEW tasks. The live sweep refreshes the
10
- * credential-bearing copy while deliberately preserving the container's
11
- * `config.toml`: browser/MCP setup owns that live file and serializes its
12
- * updates with agent spawning.
6
+ * task-up (`scripts/agent/task-up.sh`). When the owner re-logs into Codex,
7
+ * `codex login` revokes and rewrites `~/.codex/auth.json` on the HOST —
8
+ * already-running containers keep their stale copy, so a re-login otherwise
9
+ * only helps NEW tasks. The live sweep refreshes the credential-bearing copy
10
+ * while deliberately preserving the container's `config.toml`: browser/MCP
11
+ * setup owns that live file and serializes its updates with agent spawning.
12
+ *
13
+ * That revoke-then-rewrite is CLIENT-SIDE and scoped to the LOCAL codex_home —
14
+ * not a server-side "one session per account" policy. `codex login` calls
15
+ * `clear_existing_auth_before_login` (openai/codex `cli/src/login.rs`), which
16
+ * revokes only whatever token `revocable_token()` (`login/src/auth/revoke.rs`)
17
+ * finds in THIS SAME codex_home's `auth.json` via `logout_with_revoke`
18
+ * (`login/src/auth/manager.rs`) — it never looks at, let alone reaches, another
19
+ * login's session. Separate logins are independent authorization_code+PKCE
20
+ * grants with independent refresh lineages (source analysis corroborated by a
21
+ * community-verified workaround, not an empirical run — full writeup:
22
+ * `docs/pricing-economics.md` R8/R10).
23
+ *
24
+ * What DOES matter for concurrency: refresh rotation is single-use, and a
25
+ * reused refresh token comes back `refresh_token_reused` — a TERMINAL
26
+ * `Exhausted` failure the client never retries. This file's docker-cp pattern
27
+ * already gives every task container its own independent filesystem copy of
28
+ * ONE lineage with nothing to reconcile them, so the instant any one
29
+ * container's own `codex` process refreshes, every sibling copy is a stale
30
+ * member of a now-rotated family and its next refresh is terminal, not
31
+ * self-healing — containers must never refresh this credential themselves.
13
32
  *
14
33
  * Three triggers, covering every path:
15
34
  * - {@link watchCodexAuth} — fs.watch on `~/.codex`, for `codex login` run
@@ -403,10 +403,51 @@ export function createAwsMachineProvider(
403
403
  try {
404
404
  const existing = JSON.parse(found.stdout);
405
405
  if (typeof existing === "string" && existing.startsWith("sg-")) {
406
+ // The group admits only host addresses — and it may predate THIS
407
+ // host (a second managed host in the fleet account, or this host
408
+ // rebooting onto a new public IP). Ensure our egress /32 is
409
+ // present; a duplicate rule is AWS's problem to dedupe, not ours
410
+ // to pre-check. Without this, every machine of a newer host sat
411
+ // unreachable behind the first host's rule ("machine never became
412
+ // reachable over ssh", live 2026-08-31).
413
+ const ensureEgress = await hostEgressIp();
414
+ const ensured = await runner([
415
+ "ec2",
416
+ "authorize-security-group-ingress",
417
+ "--group-id",
418
+ existing,
419
+ "--ip-permissions",
420
+ JSON.stringify([
421
+ {
422
+ IpProtocol: "tcp",
423
+ FromPort: 0,
424
+ ToPort: 65535,
425
+ IpRanges: [
426
+ {
427
+ CidrIp: `${ensureEgress}/32`,
428
+ Description: "uai orchestrating host",
429
+ },
430
+ ],
431
+ },
432
+ ]),
433
+ ]);
434
+ if (
435
+ ensured.status !== 0 &&
436
+ !/InvalidPermission\.Duplicate/.test(ensured.stderr)
437
+ ) {
438
+ throw new Error(
439
+ `could not admit this host into ${MANAGED_SECURITY_GROUP}: ${
440
+ ensured.stderr.trim() || `exit ${ensured.status ?? "killed"}`
441
+ }`,
442
+ );
443
+ }
406
444
  resolvedSecurityGroup = existing;
407
445
  return existing;
408
446
  }
409
- } catch {
447
+ } catch (err) {
448
+ if (err instanceof Error && /could not admit this host/.test(err.message)) {
449
+ throw err;
450
+ }
410
451
  // fall through to creation
411
452
  }
412
453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.88",
3
+ "version": "0.9.90",
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/main.ts CHANGED
@@ -2614,7 +2614,11 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
2614
2614
  ) {
2615
2615
  return { kind: "mcp.op", opId: frame.opId, op: frame.op };
2616
2616
  }
2617
- console.warn("[host-agent] dropping unknown frame");
2617
+ // Names only — never payloads (frames can carry tokens).
2618
+ console.warn(
2619
+ `[host-agent] dropping unknown frame kind=${String(frame.kind)}` +
2620
+ (frame.kind === "command" ? ` command=${String(frame.command)}` : ""),
2621
+ );
2618
2622
  return null;
2619
2623
  }
2620
2624
 
@@ -2861,28 +2865,35 @@ function rawDataToBuffer(data: RawData): Buffer {
2861
2865
  return Buffer.from(data);
2862
2866
  }
2863
2867
 
2868
+ // Exhaustive BY TYPE: `satisfies Record<keyof HostCommands, true>` makes a
2869
+ // command missing from this map a compile error. The previous hand-written
2870
+ // array silently dropped any frame it lagged behind on — hostUpdate and
2871
+ // hostRestart shipped in 0.9.62 and every remote Update/Restart click since
2872
+ // died here as "dropping unknown frame" / a cloud-side command timeout.
2873
+ const HOST_COMMAND_NAMES = {
2874
+ hostUpdate: true,
2875
+ hostRestart: true,
2876
+ taskUp: true,
2877
+ taskDown: true,
2878
+ taskStatus: true,
2879
+ taskStop: true,
2880
+ channelEnsure: true,
2881
+ channelTeardown: true,
2882
+ channelDeliver: true,
2883
+ channelResolvePermission: true,
2884
+ cloneRepo: true,
2885
+ taskDiff: true,
2886
+ attachmentWrite: true,
2887
+ attachmentRead: true,
2888
+ filesOp: true,
2889
+ channelInterrupt: true,
2890
+ appendTranscript: true,
2891
+ previewEnsure: true,
2892
+ engineAccountRemove: true,
2893
+ } satisfies Record<keyof HostCommands, true>;
2894
+
2864
2895
  function isHostCommand(command: string): command is keyof HostCommands {
2865
- // ⚠️ Keep in sync with the HostCommands interface + dispatchCommand. This is a
2866
- // manual allow-list (TS can't derive it from the type), so a missing entry
2867
- // silently drops the frame and hangs the cloud command.
2868
- return [
2869
- "taskUp",
2870
- "taskDown",
2871
- "taskStatus",
2872
- "channelEnsure",
2873
- "channelTeardown",
2874
- "channelDeliver",
2875
- "channelResolvePermission",
2876
- "cloneRepo",
2877
- "taskDiff",
2878
- "attachmentWrite",
2879
- "attachmentRead",
2880
- "filesOp",
2881
- "channelInterrupt",
2882
- "appendTranscript",
2883
- "previewEnsure",
2884
- "engineAccountRemove",
2885
- ].includes(command);
2896
+ return Object.hasOwn(HOST_COMMAND_NAMES, command);
2886
2897
  }
2887
2898
 
2888
2899
  function expectString(args: unknown[], index: number): string {