@runuai/host 0.9.148 → 0.9.149
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/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +391 -69
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2219 -15
package/lib/runtime-archive.ts
CHANGED
|
@@ -28,6 +28,16 @@ const BASE_TOP_LEVEL = Object.freeze(["agent", "bin", "node", "runtime.json"]);
|
|
|
28
28
|
export const BUNDLED_RUNTIME_ROOT = "agent/bundled-runtime" as const;
|
|
29
29
|
export const BUNDLED_RUNTIME_CAPABILITY =
|
|
30
30
|
"agent/bundled-runtime/capability.json" as const;
|
|
31
|
+
/**
|
|
32
|
+
* Cross-platform managed-runtime generation capability. Keep this outside the
|
|
33
|
+
* Darwin-only bundled subtree: the 0.9.148 validator tolerates additive files
|
|
34
|
+
* below `agent/`, so it can install the first capability-4 generation without
|
|
35
|
+
* understanding the marker. That generation then uses it to fence every
|
|
36
|
+
* later update and rollback after project-env activation.
|
|
37
|
+
*/
|
|
38
|
+
export const MANAGED_RUNTIME_CAPABILITY =
|
|
39
|
+
"agent/managed-runtime-capability.json" as const;
|
|
40
|
+
export const MANAGED_RUNTIME_CAPABILITY_VERSION = 4 as const;
|
|
31
41
|
/** Plugin directory == plugin binary name in Apple's signed pkg payload. */
|
|
32
42
|
const APPLE_CONTAINER_PLUGINS = Object.freeze([
|
|
33
43
|
"container-core-images",
|
|
@@ -43,6 +53,7 @@ const STANDARD_IMAGE_ASSETS = Object.freeze([
|
|
|
43
53
|
Object.freeze({ path: "container/corepack-version", mode: 0o644 as const }),
|
|
44
54
|
Object.freeze({ path: "container/uai-init", mode: 0o755 as const }),
|
|
45
55
|
Object.freeze({ path: "container/uai-materialize-runtimes", mode: 0o755 as const }),
|
|
56
|
+
Object.freeze({ path: "container/uai-task-forwarder.mjs", mode: 0o644 as const }),
|
|
46
57
|
Object.freeze({ path: "tool-versions", mode: 0o644 as const }),
|
|
47
58
|
]);
|
|
48
59
|
const RUNTIME_ASSET_ROOTS = Object.freeze([
|
|
@@ -107,11 +118,22 @@ export interface RuntimeCapabilityDescriptorV3 {
|
|
|
107
118
|
readonly bundledRuntime: BundledRuntimeDescriptor;
|
|
108
119
|
}
|
|
109
120
|
|
|
110
|
-
export
|
|
111
|
-
|
|
121
|
+
export interface ManagedRuntimeCapabilityDescriptorV4 {
|
|
122
|
+
readonly capabilitySchemaVersion: 4;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type RuntimePlatformCapabilities =
|
|
112
126
|
| { readonly capabilitySchemaVersion: 1 }
|
|
113
|
-
| RuntimeCapabilityDescriptorV3
|
|
114
|
-
|
|
127
|
+
| RuntimeCapabilityDescriptorV3;
|
|
128
|
+
|
|
129
|
+
export type ResolvedRuntimeCapabilities = RuntimePlatformCapabilities & {
|
|
130
|
+
/** Effective rollback/update floor carried by this whole generation. */
|
|
131
|
+
readonly runtimeCapabilityVersion: 1 | 3 | 4;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type RuntimeDescriptor = RuntimeDescriptorV1;
|
|
135
|
+
export type ResolvedRuntimeDescriptor = RuntimeDescriptor &
|
|
136
|
+
ResolvedRuntimeCapabilities;
|
|
115
137
|
|
|
116
138
|
export interface RuntimeAssetManifestEntry {
|
|
117
139
|
readonly path: string;
|
|
@@ -138,9 +160,7 @@ interface ArchiveEntry {
|
|
|
138
160
|
|
|
139
161
|
export interface InspectedRuntimeArchive {
|
|
140
162
|
readonly descriptor: RuntimeDescriptor;
|
|
141
|
-
readonly capabilities:
|
|
142
|
-
| { readonly capabilitySchemaVersion: 1 }
|
|
143
|
-
| RuntimeCapabilityDescriptorV3;
|
|
163
|
+
readonly capabilities: ResolvedRuntimeCapabilities;
|
|
144
164
|
readonly memberCount: number;
|
|
145
165
|
}
|
|
146
166
|
|
|
@@ -245,9 +265,7 @@ export function inspectRuntimeArchive(
|
|
|
245
265
|
): {
|
|
246
266
|
entries: readonly ArchiveEntry[];
|
|
247
267
|
descriptor: RuntimeDescriptor;
|
|
248
|
-
capabilities:
|
|
249
|
-
| { readonly capabilitySchemaVersion: 1 }
|
|
250
|
-
| RuntimeCapabilityDescriptorV3;
|
|
268
|
+
capabilities: ResolvedRuntimeCapabilities;
|
|
251
269
|
} {
|
|
252
270
|
assertCanonicalGzipHeader(archiveBytes);
|
|
253
271
|
const tar = gunzipSync(archiveBytes, { maxOutputLength: MAX_UNCOMPRESSED_BYTES });
|
|
@@ -423,7 +441,7 @@ function validateEntryLayout(
|
|
|
423
441
|
entries: readonly ArchiveEntry[],
|
|
424
442
|
byPath: ReadonlyMap<string, ArchiveEntry>,
|
|
425
443
|
descriptor: RuntimeDescriptor,
|
|
426
|
-
):
|
|
444
|
+
): ResolvedRuntimeCapabilities {
|
|
427
445
|
const topLevel = new Set(entries.map((entry) => entry.memberPath.split("/")[0]!));
|
|
428
446
|
if (
|
|
429
447
|
[...topLevel].sort().join("\0") !== [...BASE_TOP_LEVEL].sort().join("\0")
|
|
@@ -447,9 +465,16 @@ function validateEntryLayout(
|
|
|
447
465
|
"agent/scripts/agent/task-down.sh",
|
|
448
466
|
"agent/scripts/agent/task-status.sh",
|
|
449
467
|
];
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
468
|
+
const platformCapabilities = validateArchivedRuntimeCapability(
|
|
469
|
+
byPath,
|
|
470
|
+
descriptor,
|
|
471
|
+
);
|
|
472
|
+
const runtimeCapabilityVersion = validateArchivedManagedRuntimeCapability(
|
|
473
|
+
byPath,
|
|
474
|
+
platformCapabilities.capabilitySchemaVersion,
|
|
475
|
+
);
|
|
476
|
+
if (platformCapabilities.capabilitySchemaVersion === 3) {
|
|
477
|
+
const bundled = platformCapabilities.bundledRuntime;
|
|
453
478
|
const pluginBinaries = APPLE_CONTAINER_PLUGINS.map(
|
|
454
479
|
(plugin) => `${bundled.layout.plugins}/${plugin}/bin/${plugin}`,
|
|
455
480
|
);
|
|
@@ -484,7 +509,7 @@ function validateEntryLayout(
|
|
|
484
509
|
`${bundled.layout.standardImage}/container`,
|
|
485
510
|
bundled.layout.licenses,
|
|
486
511
|
);
|
|
487
|
-
validateArchivedRuntimeAssets(byPath,
|
|
512
|
+
validateArchivedRuntimeAssets(byPath, platformCapabilities);
|
|
488
513
|
executables.push(
|
|
489
514
|
bundled.layout.containerCli,
|
|
490
515
|
bundled.layout.apiserver,
|
|
@@ -506,7 +531,27 @@ function validateEntryLayout(
|
|
|
506
531
|
throw new Error(`runtime archive executable mode is invalid: ${executable}`);
|
|
507
532
|
}
|
|
508
533
|
}
|
|
509
|
-
return
|
|
534
|
+
return { ...platformCapabilities, runtimeCapabilityVersion };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function validateArchivedManagedRuntimeCapability(
|
|
538
|
+
byPath: ReadonlyMap<string, ArchiveEntry>,
|
|
539
|
+
legacyCapabilityVersion: 1 | 3,
|
|
540
|
+
): 1 | 3 | 4 {
|
|
541
|
+
const entry = byPath.get(MANAGED_RUNTIME_CAPABILITY);
|
|
542
|
+
if (entry === undefined) return legacyCapabilityVersion;
|
|
543
|
+
if (entry.type !== "0" || entry.data.length > MAX_PAX_BYTES) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
"managed runtime capability descriptor is missing or too large",
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
let raw: unknown;
|
|
549
|
+
try {
|
|
550
|
+
raw = JSON.parse(entry.data.toString("utf8"));
|
|
551
|
+
} catch {
|
|
552
|
+
throw new Error("managed runtime capability descriptor is invalid JSON");
|
|
553
|
+
}
|
|
554
|
+
return validateManagedRuntimeCapabilityDescriptor(raw).capabilitySchemaVersion;
|
|
510
555
|
}
|
|
511
556
|
|
|
512
557
|
function validateArchivedRuntimeCapability(
|
|
@@ -609,6 +654,24 @@ export function validateRuntimeCapabilityDescriptor(
|
|
|
609
654
|
};
|
|
610
655
|
}
|
|
611
656
|
|
|
657
|
+
export function validateManagedRuntimeCapabilityDescriptor(
|
|
658
|
+
value: unknown,
|
|
659
|
+
): ManagedRuntimeCapabilityDescriptorV4 {
|
|
660
|
+
const object = strictObject(
|
|
661
|
+
value,
|
|
662
|
+
["capabilitySchemaVersion"],
|
|
663
|
+
"managed runtime capability descriptor",
|
|
664
|
+
);
|
|
665
|
+
if (
|
|
666
|
+
object.capabilitySchemaVersion !== MANAGED_RUNTIME_CAPABILITY_VERSION
|
|
667
|
+
) {
|
|
668
|
+
throw new Error("unsupported managed runtime capability schema");
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
capabilitySchemaVersion: MANAGED_RUNTIME_CAPABILITY_VERSION,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
612
675
|
function validateBundledRuntimeDescriptor(
|
|
613
676
|
value: unknown,
|
|
614
677
|
): BundledRuntimeDescriptor {
|
package/lib/runtime-authority.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectEnvMode,
|
|
3
|
+
ProjectEnvProfile,
|
|
4
|
+
} from "./task-environment/types";
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Host-owned runtime/cache authority for every command launched in a task
|
|
3
8
|
* container. Admission and recovery separately prove the container's fixed
|
|
@@ -95,6 +100,45 @@ export const MACHINE_RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
|
|
|
95
100
|
ENV: "",
|
|
96
101
|
});
|
|
97
102
|
|
|
103
|
+
/** Root-owned loader paths installed in every PR2+ task environment. */
|
|
104
|
+
export const PROJECT_ENV_HOOK_PATH = "/usr/local/share/uai/env-hook.sh";
|
|
105
|
+
export const PROJECT_ENV_EXEC_PATH = "/usr/local/bin/uai-env-exec";
|
|
106
|
+
|
|
107
|
+
/** Names that must reach a provider-added outer shell as well as the payload. */
|
|
108
|
+
export const PROJECT_ENV_PROFILE_ENV_NAMES = Object.freeze([
|
|
109
|
+
"BASH_ENV",
|
|
110
|
+
"SHELL",
|
|
111
|
+
"UAI_ENV_HOOK",
|
|
112
|
+
] as const);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Apply the caller's classified project-env policy at the final provider
|
|
116
|
+
* boundary. The overlay is deliberately last, so inherited/caller env cannot
|
|
117
|
+
* opt a maintenance command into the hook or replace the task login shell.
|
|
118
|
+
* Legacy baked environments are a rollout no-op: their image has no loader.
|
|
119
|
+
*/
|
|
120
|
+
export function applyProjectEnvProfile<
|
|
121
|
+
T extends {
|
|
122
|
+
projectEnvProfile: ProjectEnvProfile;
|
|
123
|
+
env?: Readonly<Record<string, string>>;
|
|
124
|
+
},
|
|
125
|
+
>(
|
|
126
|
+
request: T,
|
|
127
|
+
envMode: ProjectEnvMode,
|
|
128
|
+
taskShell: "/usr/bin/zsh" | "/bin/bash",
|
|
129
|
+
): T {
|
|
130
|
+
if (envMode === "baked") return request;
|
|
131
|
+
const profileEnv =
|
|
132
|
+
request.projectEnvProfile === "maintenance"
|
|
133
|
+
? { BASH_ENV: "", UAI_ENV_HOOK: "0" }
|
|
134
|
+
: {
|
|
135
|
+
BASH_ENV: PROJECT_ENV_HOOK_PATH,
|
|
136
|
+
SHELL: taskShell,
|
|
137
|
+
UAI_ENV_HOOK: "1",
|
|
138
|
+
};
|
|
139
|
+
return { ...request, env: { ...request.env, ...profileEnv } };
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
/** Rewrite a session env assembled for a CONTAINER into its machine form:
|
|
99
143
|
* every container-authority key is dropped, then the machine authority is
|
|
100
144
|
* overlaid. Non-authority keys (engine credentials, task tokens, preview
|
package/lib/runtime-state.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { eq, inArray, isNull, notInArray, or } from "drizzle-orm";
|
|
1
|
+
import { and, eq, inArray, isNull, notInArray, or } from "drizzle-orm";
|
|
2
2
|
|
|
3
3
|
import { getDb, schema, type Db } from "./db";
|
|
4
4
|
import { isTerminal, TERMINAL_STATUSES } from "./task-status";
|
|
5
5
|
import { newId } from "./ulid";
|
|
6
6
|
import type { HostTask } from "../db/schema";
|
|
7
|
-
import type { TaskUpResult } from "../src/protocol";
|
|
7
|
+
import type { TaskLaunchInput, TaskUpResult } from "../src/protocol";
|
|
8
8
|
import {
|
|
9
9
|
serializeTaskEnvironmentLocator,
|
|
10
|
+
type ProjectEnvMode,
|
|
10
11
|
type TaskEnvironmentLocator,
|
|
11
12
|
} from "./task-environment/types";
|
|
12
13
|
|
|
@@ -29,6 +30,22 @@ export function getHostTask(taskId: string): HostTask | null {
|
|
|
29
30
|
);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Loader tasks whose authenticated gateway generation may still have a live
|
|
34
|
+
* runtime after this host process restarts. Terminal/stopped rows are never
|
|
35
|
+
* reactivated merely because an old credential file survived. */
|
|
36
|
+
export function activeLoaderHostTasks(): HostTask[] {
|
|
37
|
+
return getDb()
|
|
38
|
+
.select()
|
|
39
|
+
.from(schema.hostTasks)
|
|
40
|
+
.where(
|
|
41
|
+
and(
|
|
42
|
+
eq(schema.hostTasks.envMode, "loader"),
|
|
43
|
+
inArray(schema.hostTasks.statusMirror, ["starting", "running"]),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
.all();
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
/** Remove a provisional first-seen row when admission loses a lifecycle gate. */
|
|
33
50
|
export function deleteHostTask(taskId: string): void {
|
|
34
51
|
// Row deletions are rare and load-bearing; a silent one cost a live
|
|
@@ -153,18 +170,17 @@ function upsertHostTaskIn(
|
|
|
153
170
|
export function recordTaskUpResult(
|
|
154
171
|
taskId: string,
|
|
155
172
|
result: TaskUpResult,
|
|
156
|
-
environment
|
|
173
|
+
environment: TaskEnvironmentLocator,
|
|
174
|
+
envMode: ProjectEnvMode,
|
|
157
175
|
): void {
|
|
158
176
|
upsertHostTask(taskId, {
|
|
159
177
|
codeServerPort: result.codeServerPort ?? null,
|
|
160
178
|
previewPorts: JSON.stringify(result.previewPorts ?? []),
|
|
161
179
|
composeProject: result.composeProject,
|
|
162
180
|
worktreePath: result.worktreePath,
|
|
163
|
-
environmentProvider: environment
|
|
164
|
-
environmentLocator:
|
|
165
|
-
|
|
166
|
-
? null
|
|
167
|
-
: serializeTaskEnvironmentLocator(environment),
|
|
181
|
+
environmentProvider: environment.provider,
|
|
182
|
+
environmentLocator: serializeTaskEnvironmentLocator(environment),
|
|
183
|
+
envMode,
|
|
168
184
|
statusMirror: "running",
|
|
169
185
|
lockedAt: null,
|
|
170
186
|
startedAt: Date.now(),
|
|
@@ -187,10 +203,12 @@ export function recordTaskStarting(taskId: string): void {
|
|
|
187
203
|
export function recordTaskEnvironmentPrepared(
|
|
188
204
|
taskId: string,
|
|
189
205
|
environment: TaskEnvironmentLocator,
|
|
206
|
+
envMode: ProjectEnvMode,
|
|
190
207
|
): void {
|
|
191
208
|
upsertHostTask(taskId, {
|
|
192
209
|
environmentProvider: environment.provider,
|
|
193
210
|
environmentLocator: serializeTaskEnvironmentLocator(environment),
|
|
211
|
+
envMode,
|
|
194
212
|
});
|
|
195
213
|
}
|
|
196
214
|
|
|
@@ -204,6 +222,8 @@ export function recordTaskDown(taskId: string, status: string | undefined): void
|
|
|
204
222
|
worktreePath: null,
|
|
205
223
|
environmentProvider: null,
|
|
206
224
|
environmentLocator: null,
|
|
225
|
+
envMode: null,
|
|
226
|
+
projectEnvSpec: null,
|
|
207
227
|
statusMirror: status ?? "killed",
|
|
208
228
|
lockedAt: null,
|
|
209
229
|
endedAt: Date.now(),
|
|
@@ -245,8 +265,8 @@ export function recordTaskOwner(
|
|
|
245
265
|
});
|
|
246
266
|
}
|
|
247
267
|
|
|
248
|
-
/**
|
|
249
|
-
* the column or carry an unreadable value. */
|
|
268
|
+
/** Legacy baked-machine project ids, lead first; `[]` for rows that predate
|
|
269
|
+
* the column or carry an unreadable value. Loader resolution uses projectEnvSpec. */
|
|
250
270
|
export function hostTaskProjectIds(task: { projectIds?: string | null }): string[] {
|
|
251
271
|
try {
|
|
252
272
|
const parsed: unknown = JSON.parse(task.projectIds ?? "[]");
|
|
@@ -258,6 +278,151 @@ export function hostTaskProjectIds(task: { projectIds?: string | null }): string
|
|
|
258
278
|
}
|
|
259
279
|
}
|
|
260
280
|
|
|
281
|
+
/** Null is the durable meaning of a task created before ADR-153 PR2. */
|
|
282
|
+
export function hostTaskEnvMode(task: {
|
|
283
|
+
envMode?: string | null;
|
|
284
|
+
}): ProjectEnvMode {
|
|
285
|
+
if (task.envMode === null || task.envMode === undefined) return "baked";
|
|
286
|
+
if (task.envMode === "baked" || task.envMode === "loader") {
|
|
287
|
+
return task.envMode;
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`unsupported task project env mode: ${task.envMode}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface TaskProjectEnvSpec {
|
|
293
|
+
schemaVersion: 1;
|
|
294
|
+
projects: Array<{
|
|
295
|
+
projectId: string;
|
|
296
|
+
position: number;
|
|
297
|
+
declaredNames: string[];
|
|
298
|
+
}>;
|
|
299
|
+
/** Cloud-computed, explicitly non-secret task literals (preview/theme). */
|
|
300
|
+
previewEnv: Record<string, string>;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const PROJECT_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
304
|
+
|
|
305
|
+
/** Build the value-free resolver input while the authoritative launch frame is present. */
|
|
306
|
+
export function taskProjectEnvSpec(
|
|
307
|
+
input: Pick<TaskLaunchInput, "projects" | "task">,
|
|
308
|
+
): TaskProjectEnvSpec {
|
|
309
|
+
const projects = input.projects
|
|
310
|
+
.map((project) => {
|
|
311
|
+
let parsed: unknown = [];
|
|
312
|
+
try {
|
|
313
|
+
parsed = JSON.parse(project.env);
|
|
314
|
+
} catch {
|
|
315
|
+
// Match task-up's established malformed-env behavior: no declarations.
|
|
316
|
+
}
|
|
317
|
+
const names: string[] = [];
|
|
318
|
+
const seen = new Set<string>();
|
|
319
|
+
if (Array.isArray(parsed)) {
|
|
320
|
+
for (const declaration of parsed) {
|
|
321
|
+
if (
|
|
322
|
+
!declaration ||
|
|
323
|
+
typeof declaration !== "object" ||
|
|
324
|
+
Array.isArray(declaration)
|
|
325
|
+
) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const name = (declaration as { key?: unknown }).key;
|
|
329
|
+
if (
|
|
330
|
+
typeof name === "string" &&
|
|
331
|
+
PROJECT_ENV_NAME.test(name) &&
|
|
332
|
+
!seen.has(name)
|
|
333
|
+
) {
|
|
334
|
+
seen.add(name);
|
|
335
|
+
names.push(name);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
projectId: project.id,
|
|
341
|
+
position: project.position,
|
|
342
|
+
declaredNames: names,
|
|
343
|
+
};
|
|
344
|
+
})
|
|
345
|
+
.sort(
|
|
346
|
+
(left, right) =>
|
|
347
|
+
left.position - right.position ||
|
|
348
|
+
left.projectId.localeCompare(right.projectId),
|
|
349
|
+
);
|
|
350
|
+
const previewEnv = Object.fromEntries(
|
|
351
|
+
Object.entries(input.task.previewEnv ?? {}).sort(([left], [right]) =>
|
|
352
|
+
left.localeCompare(right),
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
return { schemaVersion: 1, projects, previewEnv };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Persist resolver metadata before environment provisioning starts. */
|
|
359
|
+
export function recordTaskProjectEnvSpec(
|
|
360
|
+
taskId: string,
|
|
361
|
+
input: Pick<TaskLaunchInput, "projects" | "task">,
|
|
362
|
+
): void {
|
|
363
|
+
upsertHostTask(taskId, {
|
|
364
|
+
projectEnvSpec: JSON.stringify(taskProjectEnvSpec(input)),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Null is a pre-feature row; malformed/newer persisted state fails closed. */
|
|
369
|
+
export function hostTaskProjectEnvSpec(task: {
|
|
370
|
+
projectEnvSpec?: string | null;
|
|
371
|
+
}): TaskProjectEnvSpec | null {
|
|
372
|
+
if (task.projectEnvSpec === null || task.projectEnvSpec === undefined) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
let parsed: unknown;
|
|
376
|
+
try {
|
|
377
|
+
parsed = JSON.parse(task.projectEnvSpec);
|
|
378
|
+
} catch {
|
|
379
|
+
throw new Error("persisted task project env spec is malformed");
|
|
380
|
+
}
|
|
381
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
382
|
+
throw new Error("persisted task project env spec is malformed");
|
|
383
|
+
}
|
|
384
|
+
const record = parsed as Record<string, unknown>;
|
|
385
|
+
if (
|
|
386
|
+
record.schemaVersion !== 1 ||
|
|
387
|
+
!Array.isArray(record.projects) ||
|
|
388
|
+
!record.previewEnv ||
|
|
389
|
+
typeof record.previewEnv !== "object" ||
|
|
390
|
+
Array.isArray(record.previewEnv)
|
|
391
|
+
) {
|
|
392
|
+
throw new Error("unsupported persisted task project env spec");
|
|
393
|
+
}
|
|
394
|
+
const projects = record.projects.map((entry) => {
|
|
395
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
396
|
+
throw new Error("persisted task project env spec has a malformed project");
|
|
397
|
+
}
|
|
398
|
+
const project = entry as Record<string, unknown>;
|
|
399
|
+
if (
|
|
400
|
+
typeof project.projectId !== "string" ||
|
|
401
|
+
project.projectId.length === 0 ||
|
|
402
|
+
!Number.isSafeInteger(project.position) ||
|
|
403
|
+
!Array.isArray(project.declaredNames) ||
|
|
404
|
+
project.declaredNames.some(
|
|
405
|
+
(name) => typeof name !== "string" || !PROJECT_ENV_NAME.test(name),
|
|
406
|
+
)
|
|
407
|
+
) {
|
|
408
|
+
throw new Error("persisted task project env spec has a malformed project");
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
projectId: project.projectId,
|
|
412
|
+
position: project.position as number,
|
|
413
|
+
declaredNames: [...new Set(project.declaredNames as string[])],
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
const previewEnv: Record<string, string> = {};
|
|
417
|
+
for (const [name, value] of Object.entries(record.previewEnv)) {
|
|
418
|
+
if (!PROJECT_ENV_NAME.test(name) || typeof value !== "string") {
|
|
419
|
+
throw new Error("persisted task project env spec has malformed preview env");
|
|
420
|
+
}
|
|
421
|
+
previewEnv[name] = value;
|
|
422
|
+
}
|
|
423
|
+
return { schemaVersion: 1, projects, previewEnv };
|
|
424
|
+
}
|
|
425
|
+
|
|
261
426
|
/** Append a task lifecycle event to the host event log (local `/api/events`). */
|
|
262
427
|
export function recordHostEvent(
|
|
263
428
|
taskId: string,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which upstream addresses the secrets proxy may dial (ADR-153 §3, rule 6).
|
|
3
|
+
*
|
|
4
|
+
* A proxied request carries a real secret, so its destination must be the
|
|
5
|
+
* public API the key is bound to — never something only the runner can
|
|
6
|
+
* reach (its loopback, the Docker bridge, a cloud metadata endpoint, the
|
|
7
|
+
* tailnet). The runner resolves the domain itself, and one non-public
|
|
8
|
+
* address anywhere in the answer rejects the whole answer, so a rebinding
|
|
9
|
+
* record cannot win by mixing a private address in with a public one.
|
|
10
|
+
*
|
|
11
|
+
* IPv6 is an allow-list (global unicast 2000::/3 only) minus special-purpose
|
|
12
|
+
* blocks; everything that embeds an IPv4 address (mapped, compatible,
|
|
13
|
+
* NAT64, 6to4, Teredo) is refused outright rather than unpacked.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { BlockList, isIP } from "node:net";
|
|
17
|
+
|
|
18
|
+
const DENIED_IPV4: ReadonlyArray<readonly [string, number]> = [
|
|
19
|
+
["0.0.0.0", 8], // "this network"
|
|
20
|
+
["10.0.0.0", 8], // RFC 1918
|
|
21
|
+
["100.64.0.0", 10], // CGNAT (RFC 6598) — Tailscale lives here
|
|
22
|
+
["127.0.0.0", 8], // loopback
|
|
23
|
+
["169.254.0.0", 16], // link-local, incl. cloud metadata
|
|
24
|
+
["172.16.0.0", 12], // RFC 1918
|
|
25
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
26
|
+
["192.0.2.0", 24], // TEST-NET-1
|
|
27
|
+
["192.88.99.0", 24], // deprecated 6to4 relay anycast
|
|
28
|
+
["192.168.0.0", 16], // RFC 1918
|
|
29
|
+
["198.18.0.0", 15], // benchmarking
|
|
30
|
+
["198.51.100.0", 24], // TEST-NET-2
|
|
31
|
+
["203.0.113.0", 24], // TEST-NET-3
|
|
32
|
+
["224.0.0.0", 4], // multicast
|
|
33
|
+
["240.0.0.0", 4], // reserved, incl. 255.255.255.255
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const DENIED_IPV6: ReadonlyArray<readonly [string, number]> = [
|
|
37
|
+
["::ffff:0:0", 96], // IPv4-mapped
|
|
38
|
+
["2001::", 23], // IETF protocol assignments, incl. Teredo 2001::/32
|
|
39
|
+
["2001:db8::", 32], // documentation
|
|
40
|
+
["2002::", 16], // 6to4
|
|
41
|
+
["3fff::", 20], // documentation (RFC 9637)
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Two lists, never one: BlockList compares IPv4 against IPv6 rules through
|
|
45
|
+
// the mapped form, so a `::ffff:0:0/96` rule in a shared list denies EVERY
|
|
46
|
+
// IPv4 address (checked live on Node 22.11).
|
|
47
|
+
const deniedIpv4 = new BlockList();
|
|
48
|
+
for (const [network, prefix] of DENIED_IPV4) deniedIpv4.addSubnet(network, prefix, "ipv4");
|
|
49
|
+
const deniedIpv6 = new BlockList();
|
|
50
|
+
for (const [network, prefix] of DENIED_IPV6) deniedIpv6.addSubnet(network, prefix, "ipv6");
|
|
51
|
+
|
|
52
|
+
// Loopback, unspecified, ULA fc00::/7, link-local fe80::/10, multicast
|
|
53
|
+
// ff00::/8, NAT64 64:ff9b::/96 and IPv4-compatible ::/96 all fall outside.
|
|
54
|
+
const globalUnicast = new BlockList();
|
|
55
|
+
globalUnicast.addSubnet("2000::", 3, "ipv6");
|
|
56
|
+
|
|
57
|
+
export function isPublicAddress(ip: string): boolean {
|
|
58
|
+
// isIP accepts zone ids (`fe80::1%eth0`); BlockList would quietly miss them.
|
|
59
|
+
if (typeof ip !== "string" || ip.includes("%")) return false;
|
|
60
|
+
try {
|
|
61
|
+
switch (isIP(ip)) {
|
|
62
|
+
case 4:
|
|
63
|
+
return !deniedIpv4.check(ip, "ipv4");
|
|
64
|
+
case 6:
|
|
65
|
+
return globalUnicast.check(ip, "ipv6") && !deniedIpv6.check(ip, "ipv6");
|
|
66
|
+
default:
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy's audit log (ADR-153 §3 rule 8), next to
|
|
3
|
+
* mcp-audit.jsonl. The engine already keeps values, placeholders, paths and
|
|
4
|
+
* queries out of every entry; this only appends.
|
|
5
|
+
*
|
|
6
|
+
* It THROWS when a line cannot be written. An injection is recorded before
|
|
7
|
+
* its value leaves, and the engine refuses the request when that record
|
|
8
|
+
* fails; refusals and skips go through the engine's best-effort wrapper.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const SECRETS_PROXY_AUDIT_FILE = "secrets-proxy-audit.jsonl";
|
|
15
|
+
|
|
16
|
+
export function createAuditWriter(logsDir: string): (entry: Record<string, unknown>) => void {
|
|
17
|
+
const file = join(logsDir, SECRETS_PROXY_AUDIT_FILE);
|
|
18
|
+
return (entry) => {
|
|
19
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// A logs dir removed under a running runner (a cleanup job) must not
|
|
24
|
+
// refuse every proxied request until a restart: recreate and retry once.
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
26
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|