@runuai/host 0.9.148 → 0.9.150
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 +408 -81
- 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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy as one runner starts it (ADR-153 §3): the kill switch,
|
|
3
|
+
* the runtime (CA, placeholders, engine, audit) under the runner's data dir,
|
|
4
|
+
* and the gateway's TLS handler, which is how connections a task's forwarder
|
|
5
|
+
* authenticated reach the engine.
|
|
6
|
+
*
|
|
7
|
+
* What a task may use comes from the policy source; where upstream names are
|
|
8
|
+
* resolved depends on where the runner sits (see `resolveWithSystem` and
|
|
9
|
+
* `resolverIgnoringHostsFile`). Both are the caller's, so this module holds
|
|
10
|
+
* no knowledge of tasks, runtimes or declarations.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Socket } from "node:net";
|
|
14
|
+
|
|
15
|
+
import { openAesGcm, sealAesGcm } from "../secrets";
|
|
16
|
+
import type { SealedText } from "./ca-store";
|
|
17
|
+
import {
|
|
18
|
+
createSecretsProxyRuntime,
|
|
19
|
+
secretsProxyEnabled,
|
|
20
|
+
type SecretsProxyRuntime,
|
|
21
|
+
type TaskProxyPolicySource,
|
|
22
|
+
} from "./runtime";
|
|
23
|
+
|
|
24
|
+
/** The connection the gateway hands over: authenticated as one task's
|
|
25
|
+
* generation, with the gateway's own check of whether it still is. */
|
|
26
|
+
export interface AuthenticatedTaskConnection {
|
|
27
|
+
readonly taskId: string;
|
|
28
|
+
readonly socket: Socket;
|
|
29
|
+
readonly isCurrent: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SecretsProxyHostDeps {
|
|
33
|
+
dataDir: string;
|
|
34
|
+
policy: TaskProxyPolicySource;
|
|
35
|
+
resolveHost: (hostname: string) => Promise<string[]>;
|
|
36
|
+
/** `registerTaskGatewayTlsHandler` from the gateway; returns the undo. */
|
|
37
|
+
registerTlsHandler: (handler: (connection: AuthenticatedTaskConnection) => void) => () => void;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
log?: Pick<Console, "warn">;
|
|
40
|
+
/** Test seams; production seals with host-master.key. */
|
|
41
|
+
seal?: (plaintext: string) => SealedText;
|
|
42
|
+
open?: (ct: Buffer, nonce: Buffer) => string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SecretsProxyHost {
|
|
46
|
+
readonly runtime: SecretsProxyRuntime;
|
|
47
|
+
/** Unregisters from the gateway, then closes every connection. */
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Starts the proxy, or returns null when `UAI_SECRETS_PROXY=off`. Off, no CA
|
|
53
|
+
* is created and nothing is registered with the gateway; the policy source
|
|
54
|
+
* must then withhold every proxied key (ADR-153: a proxied key is a
|
|
55
|
+
* placeholder or withheld, never its value).
|
|
56
|
+
*/
|
|
57
|
+
export function startSecretsProxyHost(deps: SecretsProxyHostDeps): SecretsProxyHost | null {
|
|
58
|
+
if (!secretsProxyEnabled(deps.env)) return null;
|
|
59
|
+
const runtime = createSecretsProxyRuntime({
|
|
60
|
+
dataDir: deps.dataDir,
|
|
61
|
+
policy: deps.policy,
|
|
62
|
+
seal: deps.seal ?? ((plaintext) => sealAesGcm(plaintext)),
|
|
63
|
+
open: deps.open ?? ((ct, nonce) => openAesGcm(ct, nonce)),
|
|
64
|
+
resolveHost: deps.resolveHost,
|
|
65
|
+
...(deps.log ? { log: deps.log } : {}),
|
|
66
|
+
});
|
|
67
|
+
const unregister = deps.registerTlsHandler(({ taskId, socket, isCurrent }) =>
|
|
68
|
+
runtime.proxy.handleConnection({ taskId, socket, isCurrent }),
|
|
69
|
+
);
|
|
70
|
+
let stopping: Promise<void> | null = null;
|
|
71
|
+
return {
|
|
72
|
+
runtime,
|
|
73
|
+
stop() {
|
|
74
|
+
stopping ??= (async () => {
|
|
75
|
+
unregister();
|
|
76
|
+
await runtime.close();
|
|
77
|
+
})();
|
|
78
|
+
return stopping;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The runner's one proxy, set at startup and cleared at shutdown; null under
|
|
84
|
+
* the kill switch. Task lifecycle code reads it, and never starts or stops it. */
|
|
85
|
+
let active: SecretsProxyHost | null = null;
|
|
86
|
+
|
|
87
|
+
export function setActiveSecretsProxy(host: SecretsProxyHost | null): void {
|
|
88
|
+
active = host;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function activeSecretsProxy(): SecretsProxyHost | null {
|
|
92
|
+
return active;
|
|
93
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical DNS names for the secrets proxy (ADR-153 §3, rule 2).
|
|
3
|
+
*
|
|
4
|
+
* The SNI name, the Host header and an absolute-form authority are all
|
|
5
|
+
* compared against the task's declared domains in this one form: a lowercase
|
|
6
|
+
* A-label hostname with at most one trailing dot removed. Anything else —
|
|
7
|
+
* an IP literal, a U-label, a port, an empty label — is not a name we could
|
|
8
|
+
* have minted a certificate for, so it is refused rather than normalised.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isIP } from "node:net";
|
|
12
|
+
|
|
13
|
+
const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
14
|
+
|
|
15
|
+
export function canonicalHostname(input: string): string | null {
|
|
16
|
+
if (typeof input !== "string") return null;
|
|
17
|
+
// ASCII is checked BEFORE lowercasing: `"K".toLowerCase()` is "k", so a
|
|
18
|
+
// U-label could otherwise lowercase into a declared A-label.
|
|
19
|
+
if (!/^[A-Za-z0-9.-]{1,254}$/.test(input)) return null;
|
|
20
|
+
let name = input.toLowerCase();
|
|
21
|
+
if (name.endsWith(".")) name = name.slice(0, -1);
|
|
22
|
+
if (name.length === 0 || name.length > 253) return null;
|
|
23
|
+
const labels = name.split(".");
|
|
24
|
+
if (!labels.every((label) => LABEL.test(label))) return null;
|
|
25
|
+
if (isIP(name) !== 0) return null;
|
|
26
|
+
// WHATWG URL parsers and inet_aton read a name whose last label is a number
|
|
27
|
+
// (`127.1`, `0x7f.1`) as IPv4; such a name is an IP literal in disguise.
|
|
28
|
+
const last = labels[labels.length - 1] ?? "";
|
|
29
|
+
if (/^[0-9]+$/.test(last) || /^0x[0-9a-f]*$/.test(last)) return null;
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when an authority (`host` or `host:port`, from a Host header or an
|
|
35
|
+
* absolute-form request target) names exactly `domain` on the HTTPS port.
|
|
36
|
+
* Bracketed IPv6 literals, userinfo and any port but 443 never match.
|
|
37
|
+
*/
|
|
38
|
+
export function authorityMatches(authority: string, domain: string): boolean {
|
|
39
|
+
const match = /^([^:@[\]]+)(?::([0-9]+))?$/.exec(authority);
|
|
40
|
+
if (match === null) return false;
|
|
41
|
+
const [, host = "", port] = match;
|
|
42
|
+
if (port !== undefined && port !== "443") return false;
|
|
43
|
+
return canonicalHostname(host) === domain;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Domains a key may never be proxied to (ADR-153 §4 "Fail closed"), checked
|
|
48
|
+
* on the canonical form. GitHub keeps its own credential path (the GH
|
|
49
|
+
* identity work), and Uai's own domains carry the runner's and the cloud's
|
|
50
|
+
* traffic. A name with no dot, or under `localhost`, can never hold a public
|
|
51
|
+
* certificate, so proxying to it could only ever fail upstream verification.
|
|
52
|
+
* A key declared for one of these is withheld, never delivered.
|
|
53
|
+
*/
|
|
54
|
+
const NEVER_PROXIED_SUFFIXES = ["github.com", "githubusercontent.com", "runuai.com", "localhost"] as const;
|
|
55
|
+
|
|
56
|
+
export function isProxiableDomain(domain: string): boolean {
|
|
57
|
+
const name = canonicalHostname(domain);
|
|
58
|
+
if (name === null || name !== domain || !name.includes(".")) return false;
|
|
59
|
+
return !NEVER_PROXIED_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`));
|
|
60
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholders for proxied keys (ADR-153 §4 "Placeholders").
|
|
3
|
+
*
|
|
4
|
+
* A placeholder stands for one (task, source project, key). It is random,
|
|
5
|
+
* so it names exactly one task and cannot be guessed from the key name, and
|
|
6
|
+
* it is stable for the task's life, so processes that started with it keep
|
|
7
|
+
* working through value rotations and runner restarts. The per-task map is
|
|
8
|
+
* persisted (0600) in the runner's data dir and dropped at teardown.
|
|
9
|
+
*
|
|
10
|
+
* A repo's `@placeholder` fixes the SHAPE, not the value: the parser keeps
|
|
11
|
+
* only its prefix (up to and including the last `_` or `-`) and its length,
|
|
12
|
+
* so SDKs that check a key's prefix accept it; the rest is always random
|
|
13
|
+
* letters and digits, at least 32 of them. A literal placeholder would be
|
|
14
|
+
* shared by every task of the project and could not tell them apart, so it
|
|
15
|
+
* never reaches the runner.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomInt } from "node:crypto";
|
|
19
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { writeDurableFileAtomic } from "../env-file";
|
|
23
|
+
import { isSafeHostTaskId } from "../task-identity";
|
|
24
|
+
|
|
25
|
+
const STORE_VERSION = 1;
|
|
26
|
+
const MIN_RANDOM_CHARS = 32;
|
|
27
|
+
/** The longest placeholder minted: a declared shape that would mint a longer
|
|
28
|
+
* one falls back to the default shape. */
|
|
29
|
+
const MAX_PLACEHOLDER_CHARS = 256;
|
|
30
|
+
const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
31
|
+
const DEFAULT_PREFIX = "uai_proxied_";
|
|
32
|
+
/** A declared prefix this code mints after: RFC 3986's unreserved
|
|
33
|
+
* characters, ending in `_` or `-`, or none. A placeholder made only of
|
|
34
|
+
* these reads the same raw, percent-encoded and JSON-escaped, so wherever
|
|
35
|
+
* an upstream echoes it the task sees exactly its own placeholder; and
|
|
36
|
+
* nothing in it can break a header, a URL, a JSON string or a shell value. */
|
|
37
|
+
const SHAPE_PREFIX = /^(?:[A-Za-z0-9._~-]*[_-])?$/;
|
|
38
|
+
|
|
39
|
+
/** The shape a repository's `@placeholder` declares, as the parser keeps it. */
|
|
40
|
+
export interface PlaceholderShape {
|
|
41
|
+
prefix: string;
|
|
42
|
+
totalLength: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PlaceholderRef {
|
|
46
|
+
taskId: string;
|
|
47
|
+
projectId: string;
|
|
48
|
+
key: string;
|
|
49
|
+
/** The repo's `@placeholder` shape, when it declared one. */
|
|
50
|
+
shape?: PlaceholderShape | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PlaceholderEntry {
|
|
54
|
+
projectId: string;
|
|
55
|
+
key: string;
|
|
56
|
+
placeholder: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function randomFrom(alphabet: string, length: number): string {
|
|
60
|
+
let output = "";
|
|
61
|
+
for (let index = 0; index < length; index += 1) output += alphabet[randomInt(alphabet.length)];
|
|
62
|
+
return output;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A shape this code can mint: a safe prefix, and a length that leaves at
|
|
66
|
+
* least 32 random characters within the cap. Anything else falls back to
|
|
67
|
+
* the default shape. */
|
|
68
|
+
function isMintableShape(shape: unknown): shape is PlaceholderShape {
|
|
69
|
+
if (shape === null || typeof shape !== "object") return false;
|
|
70
|
+
const { prefix, totalLength } = shape as Partial<PlaceholderShape>;
|
|
71
|
+
return (
|
|
72
|
+
typeof prefix === "string" &&
|
|
73
|
+
SHAPE_PREFIX.test(prefix) &&
|
|
74
|
+
typeof totalLength === "number" &&
|
|
75
|
+
Number.isSafeInteger(totalLength) &&
|
|
76
|
+
totalLength >= prefix.length + MIN_RANDOM_CHARS &&
|
|
77
|
+
totalLength <= MAX_PLACEHOLDER_CHARS
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Exported for tests; the store is the only production caller. */
|
|
82
|
+
export function mintPlaceholder(key: string, shape?: PlaceholderShape | null): string {
|
|
83
|
+
if (isMintableShape(shape)) return shape.prefix + randomFrom(BASE62, shape.totalLength - shape.prefix.length);
|
|
84
|
+
const name = key.replace(/[^A-Za-z0-9_]/g, "_").slice(0, 64);
|
|
85
|
+
return `${DEFAULT_PREFIX}${name}_${randomFrom(BASE62, MIN_RANDOM_CHARS)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface StoredTask {
|
|
89
|
+
v: number;
|
|
90
|
+
entries: PlaceholderEntry[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A name that keys the per-task index: non-empty, and without the NUL the
|
|
94
|
+
* index joins project and key with. */
|
|
95
|
+
const isName = (value: unknown): value is string =>
|
|
96
|
+
typeof value === "string" && value.length > 0 && !value.includes("\u0000");
|
|
97
|
+
|
|
98
|
+
/** What this code can mint: an optional prefix of safe characters ending in
|
|
99
|
+
* `_` or `-`, then a random tail of at least 32 base62 characters (hex is
|
|
100
|
+
* base62 too), within the cap. */
|
|
101
|
+
const MINTED_SHAPE = new RegExp(`^(?:[A-Za-z0-9._~-]*[_-])?[A-Za-z0-9]{${MIN_RANDOM_CHARS},}$`);
|
|
102
|
+
const isMintedShape = (value: unknown): value is string =>
|
|
103
|
+
typeof value === "string" && value.length <= MAX_PLACEHOLDER_CHARS && MINTED_SHAPE.test(value);
|
|
104
|
+
|
|
105
|
+
function isEntry(value: unknown): value is PlaceholderEntry {
|
|
106
|
+
if (value === null || typeof value !== "object") return false;
|
|
107
|
+
const entry = value as Record<string, unknown>;
|
|
108
|
+
return isName(entry.projectId) && isName(entry.key) && isMintedShape(entry.placeholder);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PlaceholderStore {
|
|
112
|
+
/** The task's placeholder for this key, minted and persisted on first use. */
|
|
113
|
+
placeholderFor(ref: PlaceholderRef): string;
|
|
114
|
+
/** Every placeholder the task holds. */
|
|
115
|
+
entries(taskId: string): readonly PlaceholderEntry[];
|
|
116
|
+
/** Forget the task (teardown). */
|
|
117
|
+
dropTask(taskId: string): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface TaskPlaceholders {
|
|
121
|
+
readonly entries: readonly PlaceholderEntry[];
|
|
122
|
+
/** `${projectId}\0${key}` → placeholder, for O(1) lookups. */
|
|
123
|
+
readonly byKey: ReadonlyMap<string, string>;
|
|
124
|
+
readonly taken: ReadonlySet<string>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Null when the entries are not a function both ways — two placeholders
|
|
128
|
+
* for one key, or one placeholder for two keys — which this code never
|
|
129
|
+
* writes and the engine could not route. */
|
|
130
|
+
function indexed(entries: readonly PlaceholderEntry[]): TaskPlaceholders | null {
|
|
131
|
+
const byKey = new Map(entries.map((entry) => [`${entry.projectId}\u0000${entry.key}`, entry.placeholder]));
|
|
132
|
+
const taken = new Set(entries.map((entry) => entry.placeholder));
|
|
133
|
+
if (byKey.size !== entries.length || taken.size !== entries.length) return null;
|
|
134
|
+
return { entries, byKey, taken };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createPlaceholderStore(options: { dir: string }): PlaceholderStore {
|
|
138
|
+
const tasksDir = join(options.dir, "placeholders");
|
|
139
|
+
const cache = new Map<string, TaskPlaceholders>();
|
|
140
|
+
|
|
141
|
+
const pathFor = (taskId: string): string => {
|
|
142
|
+
// The id becomes a path component; an unsafe one never reaches disk.
|
|
143
|
+
if (!isSafeHostTaskId(taskId)) throw new Error("invalid host task identity");
|
|
144
|
+
return join(tasksDir, `${taskId}.json`);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const load = (taskId: string): TaskPlaceholders => {
|
|
148
|
+
const cached = cache.get(taskId);
|
|
149
|
+
if (cached !== undefined) return cached;
|
|
150
|
+
const path = pathFor(taskId);
|
|
151
|
+
let entries: PlaceholderEntry[] = [];
|
|
152
|
+
if (existsSync(path)) {
|
|
153
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredTask>;
|
|
154
|
+
if (parsed.v !== STORE_VERSION || !Array.isArray(parsed.entries) || !parsed.entries.every(isEntry)) {
|
|
155
|
+
// A torn or foreign file must not silently re-mint: processes may
|
|
156
|
+
// already hold the old placeholders, and new ones would fail for
|
|
157
|
+
// them without any sign why.
|
|
158
|
+
throw new Error("the task's placeholder store is not in a format this runner reads");
|
|
159
|
+
}
|
|
160
|
+
entries = parsed.entries.map(({ projectId, key, placeholder }) => ({ projectId, key, placeholder }));
|
|
161
|
+
}
|
|
162
|
+
const task = indexed(entries);
|
|
163
|
+
if (task === null) throw new Error("the task's placeholder store maps a key or a placeholder twice");
|
|
164
|
+
cache.set(taskId, task);
|
|
165
|
+
return task;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
placeholderFor(ref) {
|
|
170
|
+
if (!isName(ref.projectId) || !isName(ref.key)) throw new Error("invalid placeholder reference");
|
|
171
|
+
const task = load(ref.taskId);
|
|
172
|
+
const existing = task.byKey.get(`${ref.projectId}\u0000${ref.key}`);
|
|
173
|
+
if (existing !== undefined) return existing;
|
|
174
|
+
let placeholder = mintPlaceholder(ref.key, ref.shape);
|
|
175
|
+
// 190 random bits make a collision a practical impossibility; checking
|
|
176
|
+
// anyway keeps the task's map a function from placeholder to key.
|
|
177
|
+
while (task.taken.has(placeholder)) placeholder = mintPlaceholder(ref.key, ref.shape);
|
|
178
|
+
const entries = [...task.entries, { projectId: ref.projectId, key: ref.key, placeholder }];
|
|
179
|
+
const next = indexed(entries);
|
|
180
|
+
if (next === null) throw new Error("placeholder collision");
|
|
181
|
+
// Durable: processes already hold these placeholders, so a file lost to
|
|
182
|
+
// a crash would re-mint ones that fail for them.
|
|
183
|
+
writeDurableFileAtomic(pathFor(ref.taskId), `${JSON.stringify({ v: STORE_VERSION, entries } satisfies StoredTask)}\n`);
|
|
184
|
+
cache.set(ref.taskId, next);
|
|
185
|
+
return placeholder;
|
|
186
|
+
},
|
|
187
|
+
entries(taskId) {
|
|
188
|
+
return load(taskId).entries;
|
|
189
|
+
},
|
|
190
|
+
dropTask(taskId) {
|
|
191
|
+
cache.delete(taskId);
|
|
192
|
+
if (!isSafeHostTaskId(taskId)) return;
|
|
193
|
+
rmSync(pathFor(taskId), { force: true });
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which keys each task has in proxy mode (ADR-153's effective policy: the
|
|
3
|
+
* owner's binding ∩ the task's schema observation), and what of that the
|
|
4
|
+
* task may use right now. The declarations work supplies the policy; until
|
|
5
|
+
* it lands no key is proxied, so a served task's policy is empty and the
|
|
6
|
+
* proxy, though running, intercepts nothing.
|
|
7
|
+
*
|
|
8
|
+
* The raw policy is what the engine learns first, before a key's pins go in.
|
|
9
|
+
* Using a key is gated, per key and per moment, on its readiness
|
|
10
|
+
* (`proxiedKeyDeliverable`): the placeholder in /env, the preamble, and the
|
|
11
|
+
* real value read for each injection. A key that fails the gate is withheld,
|
|
12
|
+
* never delivered.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { sameTaskEnvHead, taskEnvView, type TaskEnvSnapshot } from "../project-env-task-policy";
|
|
16
|
+
import { activeSecretsProxy } from "./host";
|
|
17
|
+
import { proxiedKeyDeliverable } from "./readiness";
|
|
18
|
+
import type { ProxiedKeyPolicy, TaskProxyPolicySource } from "./runtime";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The runner's policy: for a served task, what one snapshot of its env says
|
|
22
|
+
* (ADR-153 §4). Each call reads its own snapshot, and a value is given only
|
|
23
|
+
* for a key still proxied at exactly the head the caller learned with it, so
|
|
24
|
+
* a head that moved since withholds the key until the engine learns the new
|
|
25
|
+
* one. With no snapshot reader, or none for the task, nothing is proxied.
|
|
26
|
+
*/
|
|
27
|
+
export function createTaskProxyPolicySource(
|
|
28
|
+
isServed: (taskId: string) => boolean,
|
|
29
|
+
readSnapshot: (taskId: string) => TaskEnvSnapshot | null = () => null,
|
|
30
|
+
): TaskProxyPolicySource {
|
|
31
|
+
const viewOf = (taskId: string) => {
|
|
32
|
+
if (!isServed(taskId)) return null;
|
|
33
|
+
const snapshot = readSnapshot(taskId);
|
|
34
|
+
return snapshot === null ? null : taskEnvView(snapshot);
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
proxiedKeys: (taskId) => {
|
|
38
|
+
if (!isServed(taskId)) return null;
|
|
39
|
+
return (viewOf(taskId)?.proxied ?? []).map(({ projectId, key, domains, placeholder, head }) => ({
|
|
40
|
+
projectId,
|
|
41
|
+
key,
|
|
42
|
+
domains,
|
|
43
|
+
placeholder,
|
|
44
|
+
head,
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
realValue: (taskId, ref) => {
|
|
48
|
+
const { head } = ref;
|
|
49
|
+
if (head === undefined) return null;
|
|
50
|
+
const proxied = viewOf(taskId)?.proxied.find((entry) => entry.projectId === ref.projectId && entry.key === ref.key);
|
|
51
|
+
return proxied !== undefined && sameTaskEnvHead(proxied.head, head) ? proxied.value : null;
|
|
52
|
+
},
|
|
53
|
+
notDelivered: (taskId) => viewOf(taskId)?.notDelivered ?? null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function keyOf(source: TaskProxyPolicySource, taskId: string, ref: { projectId: string; key: string }): ProxiedKeyPolicy | null {
|
|
58
|
+
return source.proxiedKeys(taskId)?.find((key) => key.projectId === ref.projectId && key.key === ref.key) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The policy the engine consults: its keys as given, and a key's real value
|
|
63
|
+
* only while that key is deliverable to the task. A placeholder a shell kept
|
|
64
|
+
* from an earlier, ready moment therefore stops injecting the moment the
|
|
65
|
+
* task's CA or pins are no longer in place.
|
|
66
|
+
*/
|
|
67
|
+
export function gateTaskProxyPolicy(source: TaskProxyPolicySource): TaskProxyPolicySource {
|
|
68
|
+
return {
|
|
69
|
+
proxiedKeys: (taskId) => source.proxiedKeys(taskId),
|
|
70
|
+
realValue: (taskId, ref) => {
|
|
71
|
+
const key = keyOf(source, taskId, ref);
|
|
72
|
+
if (key === null || !proxiedKeyDeliverable(taskId, key.domains)) return null;
|
|
73
|
+
return source.realValue(taskId, ref);
|
|
74
|
+
},
|
|
75
|
+
// What /env withholds passes through untouched: the gate only narrows
|
|
76
|
+
// what the proxy injects, never what the task is denied.
|
|
77
|
+
notDelivered: (taskId) => source.notDelivered?.(taskId) ?? null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The runner's policy, set at startup whether or not the proxy runs: with
|
|
82
|
+
* it off, proxy-mode keys must still be known, to be withheld. */
|
|
83
|
+
let active: TaskProxyPolicySource | null = null;
|
|
84
|
+
|
|
85
|
+
export function setActiveTaskProxyPolicy(policy: TaskProxyPolicySource | null): void {
|
|
86
|
+
active = policy;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function activeTaskProxyPolicy(): TaskProxyPolicySource | null {
|
|
90
|
+
return active;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a task's /env carries for its proxy-mode keys. */
|
|
94
|
+
export interface TaskProxiedEnv {
|
|
95
|
+
/** Every proxy-mode name: never rendered from the project's values. */
|
|
96
|
+
readonly withheld: readonly string[];
|
|
97
|
+
/** Those of them the task may use now, with their placeholders. */
|
|
98
|
+
readonly placeholders: Readonly<Record<string, string>>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const NOTHING_PROXIED: TaskProxiedEnv = Object.freeze({ withheld: [], placeholders: {} });
|
|
102
|
+
|
|
103
|
+
function usable(policy: TaskProxyPolicySource, taskId: string, keys: readonly ProxiedKeyPolicy[]): ProxiedKeyPolicy[] {
|
|
104
|
+
return keys.filter((key) => proxiedKeyDeliverable(taskId, key.domains) && policy.realValue(taskId, key) !== null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The task's proxy-mode keys it may use right now: each one the proxy would
|
|
109
|
+
* inject at this moment (deliverable, and with a value; null withholds). The
|
|
110
|
+
* placeholder in /env and the preamble's line both come from this, so
|
|
111
|
+
* neither ever offers a key the proxy would refuse.
|
|
112
|
+
*/
|
|
113
|
+
export function usableProxiedKeys(taskId: string): ProxiedKeyPolicy[] {
|
|
114
|
+
return active === null ? [] : usable(active, taskId, active.proxiedKeys(taskId) ?? []);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The /env view of a task's proxy-mode keys: withheld from the project
|
|
119
|
+
* values, and replaced by its placeholder where the key is usable now.
|
|
120
|
+
* Read per render; the renderer keys its cache on the result, so a change of
|
|
121
|
+
* readiness alone re-renders.
|
|
122
|
+
*/
|
|
123
|
+
export function taskProxiedEnv(taskId: string): TaskProxiedEnv {
|
|
124
|
+
const policy = active;
|
|
125
|
+
const keys = policy?.proxiedKeys(taskId) ?? [];
|
|
126
|
+
// Every name the task does not get as its value, withheld or proxied.
|
|
127
|
+
const notDelivered = policy?.notDelivered?.(taskId) ?? [];
|
|
128
|
+
if (policy === null || (keys.length === 0 && notDelivered.length === 0)) return NOTHING_PROXIED;
|
|
129
|
+
const runtime = activeSecretsProxy()?.runtime ?? null;
|
|
130
|
+
const placeholders: Record<string, string> = {};
|
|
131
|
+
if (runtime !== null) {
|
|
132
|
+
for (const key of usable(policy, taskId, keys)) {
|
|
133
|
+
try {
|
|
134
|
+
placeholders[key.key] = runtime.placeholderFor(taskId, key);
|
|
135
|
+
} catch {
|
|
136
|
+
// Not mintable now (a closing runtime, a stale policy): withheld.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { withheld: [...new Set([...keys.map((key) => key.key), ...notDelivered])].sort(), placeholders };
|
|
141
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far each task is behind the secrets proxy (ADR-153 §3), and what that
|
|
3
|
+
* allows: the per-task readiness the runner records as its root steps land,
|
|
4
|
+
* the trust env it gives the task's shells, and the gate every proxied key
|
|
5
|
+
* passes before the task gets its placeholder or the engine reads its value.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { activeSecretsProxy } from "./host";
|
|
12
|
+
import { TASK_CA_BUNDLE_PATH } from "./task-scripts";
|
|
13
|
+
|
|
14
|
+
const SYSTEM_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
|
15
|
+
|
|
16
|
+
/** The trust env every loader shell gets while the proxy runs: Node reads the
|
|
17
|
+
* Uai bundle; OpenSSL, Python and the rest the system bundle the CA is
|
|
18
|
+
* installed into. All three are reserved: no project value can set them. */
|
|
19
|
+
export const TASK_TRUST_ENV: Readonly<Record<string, string>> = Object.freeze({
|
|
20
|
+
NODE_EXTRA_CA_CERTS: TASK_CA_BUNDLE_PATH,
|
|
21
|
+
SSL_CERT_FILE: SYSTEM_BUNDLE,
|
|
22
|
+
REQUESTS_CA_BUNDLE: SYSTEM_BUNDLE,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type ProxyStepOutcome = "installed" | "failed" | "skipped";
|
|
26
|
+
|
|
27
|
+
/** What the task's current container start, and every reconcile since,
|
|
28
|
+
* actually put in place. */
|
|
29
|
+
export interface TaskProxyReadiness {
|
|
30
|
+
/** The CA bundle in the task's trust store. */
|
|
31
|
+
readonly trust: ProxyStepOutcome;
|
|
32
|
+
/** The bundle installed, to notice a newer one. */
|
|
33
|
+
readonly bundle: string | null;
|
|
34
|
+
/** The NAT chain and the hosts block. */
|
|
35
|
+
readonly pins: ProxyStepOutcome;
|
|
36
|
+
/** The domains those pins currently cover. */
|
|
37
|
+
readonly pinnedDomains: readonly string[];
|
|
38
|
+
/** The task's forwarder is known to run: the start that recorded this
|
|
39
|
+
* ended with it running, or a recovery launched it. Pins wait for it, so
|
|
40
|
+
* no name ever points at a listener that is not there. */
|
|
41
|
+
readonly forwarder: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NOTHING: TaskProxyReadiness = Object.freeze({
|
|
45
|
+
trust: "skipped",
|
|
46
|
+
bundle: null,
|
|
47
|
+
pins: "skipped",
|
|
48
|
+
pinnedDomains: [],
|
|
49
|
+
forwarder: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const recorded = new Map<string, TaskProxyReadiness>();
|
|
53
|
+
|
|
54
|
+
/** A task-up under way: the directory it reports into, and what the runner
|
|
55
|
+
* handed it. Read live, so readiness is exact while uai-init already runs. */
|
|
56
|
+
export interface PendingStart {
|
|
57
|
+
readonly dir: string;
|
|
58
|
+
readonly bundle: string;
|
|
59
|
+
readonly domains: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
const starting = new Map<string, PendingStart>();
|
|
62
|
+
|
|
63
|
+
function outcomeIn(dir: string, name: "trust" | "pins"): ProxyStepOutcome {
|
|
64
|
+
try {
|
|
65
|
+
const text = readFileSync(join(dir, name), "utf8").trim();
|
|
66
|
+
return text === "installed" || text === "failed" ? text : "skipped";
|
|
67
|
+
} catch {
|
|
68
|
+
// Not reported yet: the step has not finished.
|
|
69
|
+
return "skipped";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function taskProxyReadiness(taskId: string): TaskProxyReadiness {
|
|
74
|
+
const pending = starting.get(taskId);
|
|
75
|
+
if (pending !== undefined) {
|
|
76
|
+
const trust = outcomeIn(pending.dir, "trust");
|
|
77
|
+
const pins = outcomeIn(pending.dir, "pins");
|
|
78
|
+
return {
|
|
79
|
+
trust,
|
|
80
|
+
bundle: trust === "installed" ? pending.bundle : null,
|
|
81
|
+
pins,
|
|
82
|
+
pinnedDomains: pins === "installed" ? pending.domains : [],
|
|
83
|
+
// Known only once the start ends.
|
|
84
|
+
forwarder: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return recorded.get(taskId) ?? NOTHING;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function recordTaskProxyReadiness(taskId: string, change: Partial<TaskProxyReadiness>): void {
|
|
91
|
+
recorded.set(taskId, { ...(recorded.get(taskId) ?? NOTHING), ...change });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The task's container is gone, or stopped: nothing of the proxy is in it. */
|
|
95
|
+
export function forgetTaskProxyReadiness(taskId: string): void {
|
|
96
|
+
recorded.delete(taskId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function beginTaskStart(taskId: string, pending: PendingStart): void {
|
|
100
|
+
forgetTaskProxyReadiness(taskId);
|
|
101
|
+
starting.set(taskId, pending);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function endTaskStart(taskId: string): PendingStart | undefined {
|
|
105
|
+
const pending = starting.get(taskId);
|
|
106
|
+
starting.delete(taskId);
|
|
107
|
+
return pending;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A task-up is under way: it alone writes the task's pins until it ends. */
|
|
111
|
+
export function isTaskStarting(taskId: string): boolean {
|
|
112
|
+
return starting.has(taskId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The runner's own env for the task's shells (see TASK_TRUST_ENV): only
|
|
116
|
+
* once the CA is in its trust store, so Node never names a missing file. */
|
|
117
|
+
export function taskTrustEnv(taskId: string): Readonly<Record<string, string>> {
|
|
118
|
+
return activeSecretsProxy() !== null && taskProxyReadiness(taskId).trust === "installed" ? TASK_TRUST_ENV : {};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CERTIFICATE_END = "-----END CERTIFICATE-----";
|
|
122
|
+
|
|
123
|
+
/** The certificate that signs now: the first in the current bundle. */
|
|
124
|
+
function signerOf(bundle: string): string {
|
|
125
|
+
const end = bundle.indexOf(CERTIFICATE_END);
|
|
126
|
+
return end === -1 ? bundle : bundle.slice(0, end + CERTIFICATE_END.length);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a proxy-mode key reaching `domains` may be used by the task right
|
|
131
|
+
* now: the proxy runs, the CA that signs now is in the task's trust store,
|
|
132
|
+
* and every one of those domains is pinned. Checked for the placeholder in
|
|
133
|
+
* /env, for the preamble, and again for every injection, so a placeholder a
|
|
134
|
+
* shell kept from an earlier, ready moment stops working as soon as the task
|
|
135
|
+
* is not ready.
|
|
136
|
+
*/
|
|
137
|
+
export function proxiedKeyDeliverable(taskId: string, domains: readonly string[]): boolean {
|
|
138
|
+
const proxy = activeSecretsProxy();
|
|
139
|
+
if (proxy === null || domains.length === 0) return false;
|
|
140
|
+
const ready = taskProxyReadiness(taskId);
|
|
141
|
+
return (
|
|
142
|
+
ready.trust === "installed" &&
|
|
143
|
+
ready.bundle !== null &&
|
|
144
|
+
ready.bundle.includes(signerOf(proxy.runtime.caBundlePem())) &&
|
|
145
|
+
ready.pins === "installed" &&
|
|
146
|
+
domains.every((domain) => ready.pinnedDomains.includes(domain))
|
|
147
|
+
);
|
|
148
|
+
}
|