@runuai/host 0.9.167 → 0.9.169

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.
@@ -393,6 +393,11 @@ export function assertWorkspaceScopedPath(
393
393
  }
394
394
  }
395
395
 
396
+ /** A malformed process request, distinct from provider/runtime failures. */
397
+ export class TaskEnvironmentRequestValidationError extends Error {
398
+ override name = "TaskEnvironmentRequestValidationError";
399
+ }
400
+
396
401
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
397
402
 
398
403
  /** Shared admission guard for provider implementations and fake providers. */
@@ -402,7 +407,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
402
407
  export function assertTaskEnvironmentExecRequest(request: TaskEnvironmentExecRequest): void {
403
408
  const { privileged, ...unprivileged } = request;
404
409
  if (privileged !== undefined && (privileged !== true || request.user !== "root")) {
405
- throw new Error("a privileged task environment exec must run as root");
410
+ throw new TaskEnvironmentRequestValidationError("a privileged task environment exec must run as root");
406
411
  }
407
412
  assertTaskEnvironmentProcessRequest(unprivileged);
408
413
  }
@@ -412,7 +417,7 @@ export function assertTaskEnvironmentProcessRequest(
412
417
  ): void {
413
418
  assertTaskEnvironmentSessionRequest(request);
414
419
  if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) {
415
- throw new Error("task environment timeout must be a positive integer");
420
+ throw new TaskEnvironmentRequestValidationError("task environment timeout must be a positive integer");
416
421
  }
417
422
  }
418
423
 
@@ -420,7 +425,7 @@ export function assertTaskEnvironmentProcessRequest(
420
425
  * step without the capability it asked for. */
421
426
  export function refusePrivilegedExec(request: TaskEnvironmentExecRequest): void {
422
427
  if (request.privileged === true) {
423
- throw new Error("this task environment provider has no privileged exec");
428
+ throw new TaskEnvironmentRequestValidationError("this task environment provider has no privileged exec");
424
429
  }
425
430
  }
426
431
 
@@ -434,13 +439,13 @@ export function assertTaskEnvironmentSessionRequest(
434
439
  // Only a one-shot exec may be privileged (assertTaskEnvironmentExecRequest
435
440
  // strips it after checking); anywhere else it is refused outright.
436
441
  if ("privileged" in request) {
437
- throw new Error("only a one-shot task environment exec may be privileged");
442
+ throw new TaskEnvironmentRequestValidationError("only a one-shot task environment exec may be privileged");
438
443
  }
439
444
  if (
440
445
  request.projectEnvProfile !== "maintenance" &&
441
446
  request.projectEnvProfile !== "task"
442
447
  ) {
443
- throw new Error(
448
+ throw new TaskEnvironmentRequestValidationError(
444
449
  "task environment project env profile must be maintenance or task",
445
450
  );
446
451
  }
@@ -451,19 +456,19 @@ export function assertTaskEnvironmentSessionRequest(
451
456
  (part) => typeof part !== "string" || part.length === 0 || part.includes("\0"),
452
457
  )
453
458
  ) {
454
- throw new Error("task environment argv must contain nonempty NUL-free strings");
459
+ throw new TaskEnvironmentRequestValidationError("task environment argv must contain nonempty NUL-free strings");
455
460
  }
456
461
  if (
457
462
  request.cwd !== undefined &&
458
- (!request.cwd.startsWith("/") || request.cwd.includes("\0"))
463
+ (typeof request.cwd !== "string" || !request.cwd.startsWith("/") || request.cwd.includes("\0"))
459
464
  ) {
460
- throw new Error("task environment cwd must be an absolute NUL-free path");
465
+ throw new TaskEnvironmentRequestValidationError("task environment cwd must be an absolute NUL-free path");
461
466
  }
462
467
  if (
463
468
  request.user !== undefined &&
464
- (request.user.length === 0 || request.user.includes("\0"))
469
+ (typeof request.user !== "string" || request.user.length === 0 || request.user.includes("\0"))
465
470
  ) {
466
- throw new Error("task environment user must be a nonempty NUL-free value");
471
+ throw new TaskEnvironmentRequestValidationError("task environment user must be a nonempty NUL-free value");
467
472
  }
468
473
  if (
469
474
  request.anchor !== undefined &&
@@ -474,7 +479,7 @@ export function assertTaskEnvironmentSessionRequest(
474
479
  (part) => typeof part !== "string" || part.length === 0 || part.includes("\0"),
475
480
  ))
476
481
  ) {
477
- throw new Error("a task environment anchor must be a NUL-free argv for a task user's process");
482
+ throw new TaskEnvironmentRequestValidationError("a task environment anchor must be a NUL-free argv for a task user's process");
478
483
  }
479
484
  if (
480
485
  request.inheritEnv !== undefined &&
@@ -485,7 +490,7 @@ export function assertTaskEnvironmentSessionRequest(
485
490
  (name) => typeof name !== "string" || !ENV_NAME.test(name),
486
491
  ))
487
492
  ) {
488
- throw new Error("task environment inherited env names are invalid");
493
+ throw new TaskEnvironmentRequestValidationError("task environment inherited env names are invalid");
489
494
  }
490
495
  for (const [name, value] of Object.entries(request.env ?? {})) {
491
496
  if (
@@ -493,21 +498,21 @@ export function assertTaskEnvironmentSessionRequest(
493
498
  typeof value !== "string" ||
494
499
  value.includes("\0")
495
500
  ) {
496
- throw new Error("task environment env must contain valid NUL-free entries");
501
+ throw new TaskEnvironmentRequestValidationError("task environment env must contain valid NUL-free entries");
497
502
  }
498
503
  }
499
504
  if (
500
505
  !Number.isSafeInteger(request.maxOutputBytes) ||
501
506
  request.maxOutputBytes <= 0
502
507
  ) {
503
- throw new Error("task environment output limit must be a positive integer");
508
+ throw new TaskEnvironmentRequestValidationError("task environment output limit must be a positive integer");
504
509
  }
505
510
  if (
506
511
  "launchTimeoutMs" in request &&
507
512
  (!Number.isSafeInteger(request.launchTimeoutMs) ||
508
513
  request.launchTimeoutMs <= 0)
509
514
  ) {
510
- throw new Error(
515
+ throw new TaskEnvironmentRequestValidationError(
511
516
  "task environment detached launch timeout must be a positive integer",
512
517
  );
513
518
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ADR-161 §2: the live-project commands wired to this runner's real state —
3
+ * the task's persisted environment, its run-epoch admission, its lifecycle
4
+ * slot, its lease grant and env replica, and its persisted env spec.
5
+ */
6
+
7
+ import { activeCloudCredentials, credentialsMode } from "./cloud-credential-host";
8
+ import { getOrchestrator } from "./orchestrator";
9
+ import {
10
+ getHostTask,
11
+ hostTaskProjectEnvSpec,
12
+ readTaskRunEpochState,
13
+ taskProjectEnvSpec,
14
+ upsertHostTask,
15
+ type TaskProjectEnvSpec,
16
+ } from "./runtime-state";
17
+ import { taskUaiDir } from "./env";
18
+ import { captureTaskEnvSchemaFromClone, taskEnvSchemaObservation } from "./project-env-schema-capture";
19
+ import { reconstructPersistedTaskEnvironment } from "./task-environment";
20
+ import { forgetTaskEnvSnapshot, taskEnvResolver } from "./task-env-source";
21
+ import { currentTaskGatewayRegistration, setTaskGatewayEnvResolver } from "./task-gateway-state";
22
+ import { TaskProjectJournalStore, TaskProjectOps } from "./task-project-ops";
23
+ import type { TaskCommandProject } from "../src/protocol";
24
+
25
+ function runIsCurrent(taskId: string, runEpoch: number): boolean {
26
+ const task = getHostTask(taskId);
27
+ if (!task || task.statusMirror !== "running") return false;
28
+ const state = readTaskRunEpochState(taskId);
29
+ // A task started before run epochs carries none; the cloud never sends
30
+ // these commands to it (it has no `live-task-projects-v1` contract).
31
+ return state !== null && state.assignmentOpen && state.highestRunEpoch === runEpoch;
32
+ }
33
+
34
+ /** Names the cloud's literal channel must never set live: the editor theme
35
+ * is the task's, chosen at start. */
36
+ const RESERVED_LITERAL_ENV = new Set(["UAI_EDITOR_THEME"]);
37
+
38
+ /**
39
+ * One project (and its preview URL literals) added to a persisted env spec.
40
+ * Every other entry stays; a literal name the task already has keeps its
41
+ * value, as at start where the first project to declare one wins.
42
+ */
43
+ export function specWithProject(
44
+ spec: TaskProjectEnvSpec,
45
+ project: TaskCommandProject,
46
+ previewEnv: Record<string, string> = {},
47
+ ): TaskProjectEnvSpec {
48
+ const hasProject = spec.projects.some((entry) => entry.projectId === project.id);
49
+ const addedLiterals = Object.entries(previewEnv).filter(
50
+ ([name]) => !RESERVED_LITERAL_ENV.has(name) && !(name in spec.previewEnv),
51
+ );
52
+ if (hasProject && addedLiterals.length === 0) return spec;
53
+ const [entry] = taskProjectEnvSpec({
54
+ projects: [project],
55
+ task: { previewEnv: {} } as Parameters<typeof taskProjectEnvSpec>[0]["task"],
56
+ }).projects;
57
+ return {
58
+ ...spec,
59
+ projects: hasProject
60
+ ? spec.projects
61
+ : [...spec.projects, entry!].sort(
62
+ (left, right) => left.position - right.position || left.projectId.localeCompare(right.projectId),
63
+ ),
64
+ previewEnv: Object.fromEntries(
65
+ [...Object.entries(spec.previewEnv), ...addedLiterals].sort(([left], [right]) => left.localeCompare(right)),
66
+ ),
67
+ };
68
+ }
69
+
70
+ function liveProjectsSupported(taskId: string): boolean {
71
+ const task = getHostTask(taskId);
72
+ if (!task) return false;
73
+ try {
74
+ return hostTaskProjectEnvSpec(task) !== null && currentTaskGatewayRegistration(taskId) !== null;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Persist the extended spec, then ALWAYS re-point the live resolver at the
82
+ * persisted spec, so a repeat repairs a resolver a crash left behind. Throws
83
+ * when the environment can't take it; Activate then answers `incomplete`.
84
+ */
85
+ function installProjectEnv(
86
+ taskId: string,
87
+ runEpoch: number,
88
+ project: TaskCommandProject,
89
+ previewEnv: Record<string, string>,
90
+ ): void {
91
+ const task = getHostTask(taskId);
92
+ if (!task) throw new Error(`no such task: ${taskId}`);
93
+ const spec = hostTaskProjectEnvSpec(task);
94
+ if (spec === null) throw new Error("this task has no persisted env spec to extend");
95
+ const registration = currentTaskGatewayRegistration(taskId);
96
+ if (!registration) throw new Error("this task has no live env gateway");
97
+ const next = specWithProject(spec, project, previewEnv);
98
+ if (next !== spec) upsertHostTask(taskId, { projectEnvSpec: JSON.stringify(next) });
99
+ forgetTaskEnvSnapshot(taskId);
100
+ if (!setTaskGatewayEnvResolver(taskId, registration.generation, taskEnvResolver(taskId, next, runEpoch))) {
101
+ throw new Error("the task's env gateway changed while the project was installed");
102
+ }
103
+ }
104
+
105
+ let singleton: TaskProjectOps | null = null;
106
+
107
+ export function taskProjectOps(): TaskProjectOps {
108
+ singleton ??= new TaskProjectOps({
109
+ journal: new TaskProjectJournalStore(taskUaiDir),
110
+ environment: async (taskId) => {
111
+ const task = getHostTask(taskId);
112
+ if (!task || task.statusMirror !== "running") return null;
113
+ return reconstructPersistedTaskEnvironment(task);
114
+ },
115
+ runIsCurrent,
116
+ inLifecycleSlot: (taskId, fn) => getOrchestrator().runTaskLifecycle(taskId, fn),
117
+ credentials: () => (credentialsMode() === "active" ? "cloud" : "own"),
118
+ installedGrant: (taskId, runEpoch) => {
119
+ const composer = activeCloudCredentials();
120
+ if (!composer) return null;
121
+ const view = composer.registry.currentView({ taskId, runEpoch });
122
+ return view
123
+ ? { runEpoch, manifestRevision: view.manifestRevision, manifestDigest: view.manifestDigest }
124
+ : null;
125
+ },
126
+ envServes: (taskId, runEpoch, projectId) => {
127
+ const composer = activeCloudCredentials();
128
+ // The runner's own store serves every project it holds values for.
129
+ if (!composer) return true;
130
+ const view = composer.registry.currentView({ taskId, runEpoch });
131
+ if (!view || !view.projectIds.includes(projectId)) return false;
132
+ return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null;
133
+ },
134
+ liveProjectsSupported,
135
+ captureEnvSchema: (taskId, projectId, git) => captureTaskEnvSchemaFromClone(taskId, projectId, git),
136
+ storedEnvSchema: (taskId, projectId) => taskEnvSchemaObservation(taskId, projectId),
137
+ servedProjectIds: (taskId, runEpoch) => {
138
+ const composer = activeCloudCredentials();
139
+ if (!composer) return null;
140
+ const view = composer.registry.currentView({ taskId, runEpoch });
141
+ if (!view) return null;
142
+ return composer.envSnapshot({ taskId, runEpoch }, view.projectIds) !== null ? [...view.projectIds] : null;
143
+ },
144
+ installProjectEnv,
145
+ });
146
+ return singleton;
147
+ }