@runuai/host 0.9.42 → 0.9.44

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.
@@ -1286,7 +1286,13 @@ export function engineLoginContainerArgs(
1286
1286
  ? [
1287
1287
  "/usr/bin/script",
1288
1288
  "-qefc",
1289
- "exec claude setup-token",
1289
+ // A wide PTY first: `script` allocates 80×24 when stdin is no
1290
+ // terminal, the CLI hard-wraps the OAuth URL at that width, and
1291
+ // the streamed capture then sees only the first fragment — the
1292
+ // operator lands on claude.ai with "Missing redirect_uri" and half
1293
+ // a client id (live 2026-08-18). stty targets the PTY `script`
1294
+ // just allocated.
1295
+ "stty cols 500 rows 50 2>/dev/null; exec claude setup-token",
1290
1296
  "/dev/null",
1291
1297
  ]
1292
1298
  : ["codex", "login"];
@@ -806,12 +806,62 @@ export function managedRuntimeActivationPending(home?: string): boolean {
806
806
  * taskUp and must be repaired by a safe uninstall retry.
807
807
  */
808
808
  export function managedRuntimeAdmissionPending(home?: string): boolean {
809
+ return managedRuntimeAdmissionBlocker(home) !== null;
810
+ }
811
+
812
+ /** The exact file closing task admission, or null. Refusals must NAME their
813
+ * gate: three stacked silent refusals cost a live operator hours
814
+ * (2026-08-18) because nothing anywhere said which file to look at. */
815
+ export function managedRuntimeAdmissionBlocker(home?: string): string | null {
809
816
  const paths = managedRuntimePaths(home);
810
- return (
811
- pathExistsSync(paths.activationPending) ||
812
- pathExistsSync(paths.maintenancePending) ||
813
- pathExistsSync(paths.installIntent)
814
- );
817
+ for (const marker of [
818
+ paths.activationPending,
819
+ paths.maintenancePending,
820
+ paths.installIntent,
821
+ ]) {
822
+ if (pathExistsSync(marker)) return marker;
823
+ }
824
+ return null;
825
+ }
826
+
827
+ /**
828
+ * Boot-time self-heal for the recurring silent-gate incident (live
829
+ * 2026-08-11 and twice on 2026-08-18): an uninstall/update helper that died
830
+ * at phase "checking" leaves its admission gate behind forever, and every
831
+ * taskUp / channelEnsure then bounces retryably with no feedback anywhere.
832
+ *
833
+ * A "checking" marker is reclaimable by the flow's OWN contract — no
834
+ * teardown has begun at that phase (removeManagedRuntime reopens admission
835
+ * itself on an ordinary busy refusal) — and a marker observed while holding
836
+ * the operation lock cannot belong to a live operation. Later phases
837
+ * (teardown/cleanup) and unreadable markers stay fail-closed: state may be
838
+ * half-removed and only an uninstall retry may touch them.
839
+ */
840
+ export async function reclaimAbandonedMaintenanceCheck(
841
+ home?: string,
842
+ ): Promise<"cleared" | "kept" | "none"> {
843
+ const paths = managedRuntimePaths(home);
844
+ if (!pathExistsSync(paths.maintenancePending)) return "none";
845
+ let release: () => Promise<void>;
846
+ try {
847
+ release = await acquireManagedOperationLock(paths);
848
+ } catch {
849
+ // A live install/update/uninstall owns the gate; leave it alone.
850
+ return "kept";
851
+ }
852
+ try {
853
+ let marker: MaintenanceMarker | null;
854
+ try {
855
+ marker = await readMaintenanceMarker(paths);
856
+ } catch {
857
+ return "kept";
858
+ }
859
+ if (!marker || marker.phase !== "checking") return "kept";
860
+ await removeDurably(paths.maintenancePending);
861
+ return "cleared";
862
+ } finally {
863
+ await release();
864
+ }
815
865
  }
816
866
 
817
867
  /**
@@ -1448,7 +1498,21 @@ export async function removeManagedRuntime(
1448
1498
  // before inspecting SQLite; taskUp writes `starting` before its final gate
1449
1499
  // check. Whichever arrives second observes the first, so teardown cannot
1450
1500
  // begin while an unrecorded task proceeds into Docker side effects.
1451
- if (!(await options.canRemove())) {
1501
+ let removable: boolean;
1502
+ try {
1503
+ removable = await options.canRemove();
1504
+ } catch (error) {
1505
+ if (marker.phase === "checking") {
1506
+ // The check itself failed, so no teardown began — leaving the gate
1507
+ // behind here poisoned every taskUp/channelEnsure with a silent
1508
+ // retryable refusal until an operator found the file (live
1509
+ // 2026-08-18, twice). Same safety argument as the busy refusal
1510
+ // below: reopen admission, keep the error.
1511
+ await removeDurably(paths.maintenancePending);
1512
+ }
1513
+ throw error;
1514
+ }
1515
+ if (!removable) {
1452
1516
  if (marker.phase === "checking") {
1453
1517
  // No teardown began, so this ordinary busy refusal may safely reopen
1454
1518
  // admission. A retry of a previously-started teardown stays fail-closed.
@@ -111,7 +111,10 @@ import {
111
111
  type ResolvedEngineAccount,
112
112
  } from "./engine-accounts";
113
113
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
114
- import { stopPreviewSidecars } from "./preview-sidecar";
114
+ import {
115
+ PREVIEW_SIDECAR_TASK_LABEL,
116
+ stopPreviewSidecars,
117
+ } from "./preview-sidecar";
115
118
  import {
116
119
  agentClisReady,
117
120
  standardImageCorepackVersionPath,
@@ -4110,8 +4113,12 @@ async function dockerStart(containerName: string): Promise<DockerStartResult> {
4110
4113
  return "failed";
4111
4114
  }
4112
4115
 
4116
+ // The fourth column is the preview-sidecar marker: a sidecar can wear the
4117
+ // task's compose labels, and a row that is neither app-shaped nor a known
4118
+ // sidecar reads as AMBIGUOUS here — which fenced maintenance on a healthy
4119
+ // host (live 2026-08-18).
4113
4120
  const RUNTIME_WRITER_LIST_FORMAT =
4114
- '{{json .Names}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}';
4121
+ `{{json .Names}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
4115
4122
  const RUNTIME_WRITER_INSPECT_FORMAT = [
4116
4123
  "{{json .Id}}",
4117
4124
  "{{json .Name}}",
@@ -4233,10 +4240,12 @@ async function listRuntimeWriterCandidates(): Promise<
4233
4240
  for (const rawLine of listed.stdout.split(/\r?\n/)) {
4234
4241
  if (!rawLine) continue;
4235
4242
  const fields = rawLine.split("\t");
4236
- if (fields.length !== 3) {
4243
+ if (fields.length !== 4) {
4237
4244
  ambiguous = true;
4238
4245
  continue;
4239
4246
  }
4247
+ // A preview sidecar is a known non-writer, not an ambiguity.
4248
+ if (fields[3] !== "") continue;
4240
4249
  let name: unknown;
4241
4250
  try {
4242
4251
  name = JSON.parse(fields[0]!);
@@ -21,7 +21,17 @@ import { inspectTunnelContainer } from "./tunnel-runtime";
21
21
 
22
22
  /** Port the proxy listens on inside the sidecar (published to a random host port). */
23
23
  const INNER_PORT = 9000;
24
- const LABEL = "com.runuai.preview-sidecar.task";
24
+ /**
25
+ * Exported for every "which container is the task's APP" scan: sidecars can
26
+ * legitimately wear the task's compose project/service labels (teardown
27
+ * finds them by project), so any container carrying THIS label must be
28
+ * excluded before counting app containers. Live 2026-08-18: one browser
29
+ * sidecar with compose labels made legacy adoption count two app containers
30
+ * and fail the whole runtime selection closed — every task on the host read
31
+ * as "still starting".
32
+ */
33
+ export const PREVIEW_SIDECAR_TASK_LABEL = "com.runuai.preview-sidecar.task";
34
+ const LABEL = PREVIEW_SIDECAR_TASK_LABEL;
25
35
  const TARGET_LABEL = "com.runuai.preview-sidecar.target";
26
36
 
27
37
  /** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
@@ -16,6 +16,7 @@ import {
16
16
  } from "../agent";
17
17
  import { dockerCli, type DockerResult } from "../docker-exec";
18
18
  import { taskDir } from "../env";
19
+ import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
19
20
  import { assertSafeHostTaskId } from "../task-identity";
20
21
  import {
21
22
  assertTaskEnvironmentProcessRequest,
@@ -868,8 +869,12 @@ function taskStatusToEnvironmentStatus(
868
869
  };
869
870
  }
870
871
 
872
+ // The fourth column is the preview-sidecar marker: a sidecar wearing the
873
+ // task's compose labels must read as "not the app", not as malformed
874
+ // identity data — the latter turned a healthy running task's status into
875
+ // "unknown" (live 2026-08-18).
871
876
  const DOCKER_STATUS_FORMAT =
872
- '{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}';
877
+ `{{.Names}}\t{{.Label "com.docker.compose.project"}}\t{{.State}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
873
878
 
874
879
  async function inspectDockerTaskEnvironmentStatus(
875
880
  docker: typeof dockerCli,
@@ -897,14 +902,20 @@ async function inspectDockerTaskEnvironmentStatus(
897
902
 
898
903
  let state: string | undefined;
899
904
  for (const raw of result.stdout.split(/\r?\n/)) {
900
- const line = raw.trim();
901
- if (!line) continue;
902
- const [name, project, candidateState, ...extra] = line.split("\t");
905
+ // No trim before splitting: the app row's LAST column (the sidecar
906
+ // marker) is empty, and trimming would eat its tab and shift the shape.
907
+ if (!raw.trim()) continue;
908
+ const [name, project, candidateState, sidecar, ...extra] = raw.split("\t");
909
+ // A preview sidecar wearing the task's compose labels is not the app
910
+ // container; skipping it keeps a healthy task from reading as unknown.
911
+ if (sidecar !== undefined && sidecar !== "") continue;
903
912
  if (
904
913
  state !== undefined ||
905
914
  name !== environment.containerName ||
906
915
  project !== environment.composeProject ||
907
916
  !candidateState ||
917
+ !candidateState.trim() ||
918
+ sidecar === undefined ||
908
919
  extra.length > 0
909
920
  ) {
910
921
  return {
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
5
5
  import { dockerCli, type DockerResult } from "../docker-exec";
6
6
  import { getDb, schema, type HostTask } from "../db";
7
7
  import { parsePreviewPortRuntimes } from "../preview-ports";
8
+ import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
8
9
  import { assertSafeHostTaskId } from "../task-identity";
9
10
  import {
10
11
  APPLE_TASK_ENVIRONMENT_PROVIDER,
@@ -26,7 +27,11 @@ const INSPECT_OUTPUT_LIMIT = 128 * 1024;
26
27
  const INVENTORY_OUTPUT_LIMIT = 128 * 1024;
27
28
  const LEGACY_INSPECT_FORMAT =
28
29
  '{{json .Id}}\t{{json .Name}}\t{{json (index .Config.Labels "com.docker.compose.project")}}\t{{json (index .Config.Labels "com.docker.compose.service")}}\t{{json .State.Status}}\t{{json .Mounts}}';
29
- const LEGACY_INVENTORY_FORMAT = "{{.ID}}\t{{.Names}}";
30
+ // The third column is the preview-sidecar marker: a sidecar can wear the
31
+ // task's compose project/service labels (teardown finds it by project), so
32
+ // the app-container census must see and skip it — counting one as an app
33
+ // container failed the WHOLE runtime adoption closed (live 2026-08-18).
34
+ const LEGACY_INVENTORY_FORMAT = `{{.ID}}\t{{.Names}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
30
35
 
31
36
  type LegacyTaskRow = Pick<
32
37
  HostTask,
@@ -317,27 +322,38 @@ async function proveNoConflictingAppContainer(
317
322
  ) {
318
323
  throw new Error(`task ${candidate.row.taskId} app inventory failed`);
319
324
  }
325
+ if (result.stdout !== "" && !result.stdout.endsWith("\n")) {
326
+ throw new Error(`task ${candidate.row.taskId} app inventory failed`);
327
+ }
328
+ const appRows: string[][] = [];
329
+ for (const line of result.stdout === ""
330
+ ? []
331
+ : result.stdout.slice(0, -1).split("\n")) {
332
+ const fields = line.split("\t");
333
+ if (fields.length !== 3) {
334
+ throw new Error(`task ${candidate.row.taskId} app inventory failed`);
335
+ }
336
+ // A preview sidecar wearing the task's compose labels is not an app
337
+ // container; counting one here failed the whole runtime adoption closed
338
+ // (live 2026-08-18, six labeled browser sidecars on the Studio).
339
+ if (fields[2] !== "") continue;
340
+ appRows.push(fields);
341
+ }
320
342
  if (expected === null) {
321
343
  // The exact container is proven absent; adoption is sound only if no
322
- // same-project replacement exists either (an empty inventory prints
323
- // nothing at all).
324
- if (result.stdout !== "") {
344
+ // same-project replacement exists either.
345
+ if (appRows.length !== 0) {
325
346
  throw new Error(
326
347
  `task ${candidate.row.taskId} has conflicting app containers`,
327
348
  );
328
349
  }
329
350
  return;
330
351
  }
331
- if (!result.stdout.endsWith("\n")) {
332
- throw new Error(`task ${candidate.row.taskId} app inventory failed`);
333
- }
334
- const lines = result.stdout.slice(0, -1).split("\n");
335
- if (lines.length !== 1) {
352
+ if (appRows.length !== 1) {
336
353
  throw new Error(`task ${candidate.row.taskId} has conflicting app containers`);
337
354
  }
338
- const fields = lines[0]!.split("\t");
355
+ const fields = appRows[0]!;
339
356
  if (
340
- fields.length !== 2 ||
341
357
  fields[0] !== expected.containerId ||
342
358
  fields[1] !== expected.containerName
343
359
  ) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/index.ts CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  } from "../lib/preview-sidecar";
76
76
  import { inspectTunnelContainer } from "../lib/tunnel-runtime";
77
77
  import { ContainerRuntimeUnavailableError } from "../lib/runtime-guard";
78
- import { managedRuntimeAdmissionPending } from "../lib/managed-runtime";
78
+ import { managedRuntimeAdmissionBlocker } from "../lib/managed-runtime";
79
79
  import { retireTaskSshIdentityEnsures } from "../lib/git-identity";
80
80
  import { removeTaskIdentityStrict } from "../lib/ssh";
81
81
  import { proveOrphanTaskResourcesAbsent } from "../lib/task-inventory";
@@ -1346,8 +1346,20 @@ function boundedFailureMessage(error: unknown): string {
1346
1346
  }
1347
1347
 
1348
1348
  /** Managed installation, activation, and uninstall drain instead of admitting work. */
1349
+ let lastAdmissionRefusalLogAt = 0;
1349
1350
  function managedMaintenanceUnavailable(): HostCommandResult<never> | null {
1350
- if (!managedRuntimeAdmissionPending()) return null;
1351
+ const blocker = managedRuntimeAdmissionBlocker();
1352
+ if (blocker === null) return null;
1353
+ // A refusal must NAME its gate somewhere an operator can find it. Three
1354
+ // stacked silent refusals cost hours live (2026-08-18); rate-limited so a
1355
+ // retrying cloud cannot flood the log.
1356
+ const now = Date.now();
1357
+ if (now - lastAdmissionRefusalLogAt > 60_000) {
1358
+ lastAdmissionRefusalLogAt = now;
1359
+ console.warn(
1360
+ `[host-agent] refusing task admission: managed-maintenance marker present at ${blocker}`,
1361
+ );
1362
+ }
1351
1363
  return {
1352
1364
  ok: false,
1353
1365
  code: HostErrorCode.HostUnavailable,
package/src/main.ts CHANGED
@@ -127,7 +127,11 @@ import {
127
127
  onChange as onRegistryChange,
128
128
  } from "../lib/agents/registry";
129
129
  import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
130
- import { MANAGED_UPDATE_RESTART_EXIT_CODE } from "../lib/managed-runtime";
130
+ import {
131
+ MANAGED_UPDATE_RESTART_EXIT_CODE,
132
+ managedRuntimeAdmissionBlocker,
133
+ reclaimAbandonedMaintenanceCheck,
134
+ } from "../lib/managed-runtime";
131
135
  import { ManagedOperationDrain } from "../lib/managed-operation-drain";
132
136
  import {
133
137
  createHostLogManager,
@@ -371,6 +375,27 @@ interface PausableSource {
371
375
  }
372
376
 
373
377
  console.log(`[host-agent] starting host ${hostId}`);
378
+ // Self-heal the recurring silent admission gate BEFORE anything can refuse
379
+ // work: an uninstall/update helper that died at phase "checking" (live
380
+ // 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
381
+ // retryably with no feedback. Only a provably-untorn "checking" marker is
382
+ // reclaimed; anything else stays fail-closed and is NAMED in the log.
383
+ try {
384
+ const reclaim = await reclaimAbandonedMaintenanceCheck();
385
+ if (reclaim === "cleared") {
386
+ console.warn(
387
+ "[host-agent] cleared an abandoned maintenance marker (a managed uninstall/update died before any teardown); task admission reopened",
388
+ );
389
+ } else if (reclaim === "kept") {
390
+ console.warn(
391
+ `[host-agent] task admission is CLOSED by a managed-maintenance marker: ${managedRuntimeAdmissionBlocker() ?? "(marker path unavailable)"} — retry the pending uninstall/update or remove the marker to reopen`,
392
+ );
393
+ }
394
+ } catch (error) {
395
+ console.warn(
396
+ `[host-agent] abandoned-maintenance reclaim failed: ${error instanceof Error ? error.message : String(error)}`,
397
+ );
398
+ }
374
399
  migrateHostDb();
375
400
  // Reconcile a durable retention-off setting before recovery/connect can make
376
401
  // crash-left owner-facing terminal history observable again. Correctness