@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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-153 §4, "The latest observation": the one order the cloud keeps each
|
|
3
|
+
* project's latest schema observation by. The state is the greatest report
|
|
4
|
+
* the cloud has accepted, so it never depends on the order reports arrive
|
|
5
|
+
* in. Pure: the cloud's storage applies a move atomically, with the
|
|
6
|
+
* project's next generation.
|
|
7
|
+
*
|
|
8
|
+
* Reports are ordered by, in turn:
|
|
9
|
+
* 1. what they say: nothing about the schema, below a proven-empty
|
|
10
|
+
* repository, below a reading of a commit (readable or not);
|
|
11
|
+
* 2. the parser epoch;
|
|
12
|
+
* 3. for readings of a commit, the commit time, then the commit id;
|
|
13
|
+
* 4. a resolved reading over an unreadable one of the same commit;
|
|
14
|
+
* 5. their canonical bytes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isProjectEnvSchemaObservation, type ProjectEnvSchemaObservation } from "./protocol";
|
|
18
|
+
|
|
19
|
+
/** A report the cloud could not read, or a stored state that fails its
|
|
20
|
+
* check: it says nothing about the schema, from epoch 0. */
|
|
21
|
+
export interface UnreadableProjectEnvSchemaReport {
|
|
22
|
+
status: "unreadable_report";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A report as the order reads it. */
|
|
26
|
+
export type OrderedProjectEnvSchemaReport = ProjectEnvSchemaObservation | UnreadableProjectEnvSchemaReport;
|
|
27
|
+
|
|
28
|
+
/** A project's latest observation: none until the first report. */
|
|
29
|
+
export type LatestProjectEnvSchema = { status: "none" } | OrderedProjectEnvSchemaReport;
|
|
30
|
+
|
|
31
|
+
const UNREADABLE: UnreadableProjectEnvSchemaReport = { status: "unreadable_report" };
|
|
32
|
+
|
|
33
|
+
/** A report checked against the contract: one it rejects, including one from
|
|
34
|
+
* a parser epoch newer than this build knows, says nothing. */
|
|
35
|
+
export function orderedProjectEnvSchemaReport(input: unknown): OrderedProjectEnvSchemaReport {
|
|
36
|
+
return isProjectEnvSchemaObservation(input) ? input : UNREADABLE;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** JSON with every object's keys sorted, no whitespace, and arrays in their
|
|
40
|
+
* own order. As on the wire, a field set to undefined is absent, and an
|
|
41
|
+
* array's hole or undefined item is null. */
|
|
42
|
+
function canonicalJson(value: unknown): string {
|
|
43
|
+
if (Array.isArray(value)) return `[${Array.from(value, (item) => (item === undefined ? "null" : canonicalJson(item))).join(",")}]`;
|
|
44
|
+
if (value !== null && typeof value === "object") {
|
|
45
|
+
const fields = Object.entries(value)
|
|
46
|
+
.filter(([, inner]) => inner !== undefined)
|
|
47
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
48
|
+
return `{${fields.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(",")}}`;
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(value) ?? "null";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The report's canonical bytes: its canonical JSON as UTF-8. Two reports
|
|
54
|
+
* with the same bytes are the same report as JSON on the wire. */
|
|
55
|
+
export function canonicalProjectEnvSchemaReportBytes(report: OrderedProjectEnvSchemaReport): Uint8Array {
|
|
56
|
+
return new TextEncoder().encode(canonicalJson(report));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Byte by byte as unsigned values; a prefix before anything longer that
|
|
60
|
+
* starts with it. */
|
|
61
|
+
function compareBytes(a: Uint8Array, b: Uint8Array): number {
|
|
62
|
+
const shared = Math.min(a.length, b.length);
|
|
63
|
+
for (let index = 0; index < shared; index += 1) {
|
|
64
|
+
if (a[index] !== b[index]) return a[index]! < b[index]! ? -1 : 1;
|
|
65
|
+
}
|
|
66
|
+
return Math.sign(a.length - b.length);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What a report says: nothing about the schema (0), that the repository is
|
|
70
|
+
* empty (1), or a reading of a commit, readable or not (2). */
|
|
71
|
+
function says(report: OrderedProjectEnvSchemaReport): 0 | 1 | 2 {
|
|
72
|
+
if (report.status === "unreadable_report" || report.baseOid === null) {
|
|
73
|
+
return report.status === "resolved" ? 1 : 0;
|
|
74
|
+
}
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const epochOf = (report: OrderedProjectEnvSchemaReport): number =>
|
|
79
|
+
report.status === "unreadable_report" ? 0 : report.parserEpoch;
|
|
80
|
+
|
|
81
|
+
/** Commit time, then commit id (lowercase hex), for two readings of a commit. */
|
|
82
|
+
function compareCommits(a: OrderedProjectEnvSchemaReport, b: OrderedProjectEnvSchemaReport): number {
|
|
83
|
+
if (a.status === "unreadable_report" || b.status === "unreadable_report") return 0;
|
|
84
|
+
if (a.baseOid === null || b.baseOid === null) return 0;
|
|
85
|
+
const time = Math.sign((a.baseCommitTimeMs ?? 0) - (b.baseCommitTimeMs ?? 0));
|
|
86
|
+
if (time !== 0) return time;
|
|
87
|
+
return a.baseOid < b.baseOid ? -1 : a.baseOid > b.baseOid ? 1 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const readable = (report: OrderedProjectEnvSchemaReport): number => (report.status === "resolved" ? 1 : 0);
|
|
91
|
+
|
|
92
|
+
/** Negative, zero or positive as `a` is below, the same report as, or above
|
|
93
|
+
* `b`. Zero only for reports with the same canonical bytes. */
|
|
94
|
+
export function compareProjectEnvSchemaReports(a: OrderedProjectEnvSchemaReport, b: OrderedProjectEnvSchemaReport): number {
|
|
95
|
+
return (
|
|
96
|
+
Math.sign(says(a) - says(b)) ||
|
|
97
|
+
Math.sign(epochOf(a) - epochOf(b)) ||
|
|
98
|
+
compareCommits(a, b) ||
|
|
99
|
+
Math.sign(readable(a) - readable(b)) ||
|
|
100
|
+
compareBytes(canonicalProjectEnvSchemaReportBytes(a), canonicalProjectEnvSchemaReportBytes(b))
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isNone(value: unknown): boolean {
|
|
105
|
+
return (
|
|
106
|
+
value !== null &&
|
|
107
|
+
typeof value === "object" &&
|
|
108
|
+
Object.keys(value).length === 1 &&
|
|
109
|
+
Object.hasOwn(value, "status") &&
|
|
110
|
+
(value as { status: unknown }).status === "none"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where one report leaves a project's latest observation. It moves only when
|
|
116
|
+
* the report is greater than the stored state, and nothing returns it to
|
|
117
|
+
* none. A stored state that fails its check counts as a report the cloud
|
|
118
|
+
* could not read, so any valid report repairs it.
|
|
119
|
+
*/
|
|
120
|
+
export function nextLatestProjectEnvSchema(
|
|
121
|
+
stored: unknown,
|
|
122
|
+
reportInput: unknown,
|
|
123
|
+
): { moved: boolean; latest: LatestProjectEnvSchema } {
|
|
124
|
+
const report = orderedProjectEnvSchemaReport(reportInput);
|
|
125
|
+
if (isNone(stored)) return { moved: true, latest: report };
|
|
126
|
+
const current = orderedProjectEnvSchemaReport(stored);
|
|
127
|
+
return compareProjectEnvSchemaReports(report, current) > 0
|
|
128
|
+
? { moved: true, latest: report }
|
|
129
|
+
: { moved: false, latest: current };
|
|
130
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-153 §4 "Pinning": what a task gets for one stored env key, from what
|
|
3
|
+
* the repository declares for it and what the owner approved (its binding).
|
|
4
|
+
*
|
|
5
|
+
* One derivation for the runner, which applies it to each task's own
|
|
6
|
+
* immutable schema observation, and for the web API, which applies it to a
|
|
7
|
+
* project's latest observation to show the settings page and to know what
|
|
8
|
+
* Approve would apply. The two never disagree, and the browser only renders
|
|
9
|
+
* what the server derived. Pure: no storage, no I/O.
|
|
10
|
+
*
|
|
11
|
+
* The bindings, from least to most the owner approved:
|
|
12
|
+
* - `omit` or `withhold`: nothing reaches the task.
|
|
13
|
+
* - none (null): today's plain value is delivered, and no domain is
|
|
14
|
+
* approved, so a declaration that asks for proxying waits for approval.
|
|
15
|
+
* - `proxy(domains)`: the key is proxied to those domains only; never
|
|
16
|
+
* delivered.
|
|
17
|
+
* - `passthrough`: the real value may be delivered, so proxying it anywhere
|
|
18
|
+
* the repository declares exposes nothing more. It is the top: nothing
|
|
19
|
+
* is ever proposed beyond it.
|
|
20
|
+
* A declaration never widens a binding: anything it asks for beyond it is a
|
|
21
|
+
* proposal, which only the owner's Approve applies, and until then the task
|
|
22
|
+
* gets the narrower policy. Approving moves a binding up, so tasks on other
|
|
23
|
+
* commits only gain (the first proxy approval of an unbound key is the one
|
|
24
|
+
* exception: it stops delivering the plain value, which is its point).
|
|
25
|
+
*
|
|
26
|
+
* Inputs come from storage and frames, so they are checked here with the
|
|
27
|
+
* contract's guards: a binding the contract rejects withholds the key, and
|
|
28
|
+
* schema evidence that is not exactly one of its two shapes, with a
|
|
29
|
+
* declaration the contract accepts, counts as a schema that could not be
|
|
30
|
+
* read.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
MAX_PROJECT_ENV_PROXY_DOMAINS,
|
|
35
|
+
isProjectEnvBinding,
|
|
36
|
+
isProjectEnvSchemaDeclaration,
|
|
37
|
+
type ProjectEnvBinding,
|
|
38
|
+
type ProjectEnvSchemaDeclaration,
|
|
39
|
+
} from "./protocol";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The newest policy epoch: what `projectEnvKeyPolicy` and
|
|
43
|
+
* `initialProjectEnvBinding` derive. Bump it with any change to what they
|
|
44
|
+
* return for some input, and keep the previous epoch's derivation, frozen,
|
|
45
|
+
* in `PROJECT_ENV_POLICY_EPOCHS`: a binding is read under the epoch it was
|
|
46
|
+
* written under, and a token the settings page was given under another
|
|
47
|
+
* epoch is refused, so an owner never approves what a different policy
|
|
48
|
+
* showed them (ADR-153 §4, "Policy epochs"). The policy test's fingerprints
|
|
49
|
+
* pin each retained epoch's results on a fixed set of inputs: a tripwire,
|
|
50
|
+
* not a proof.
|
|
51
|
+
*/
|
|
52
|
+
export const PROJECT_ENV_POLICY_EPOCH = 1;
|
|
53
|
+
|
|
54
|
+
/** Every policy epoch this build derives under, oldest first. A runner
|
|
55
|
+
* advertises this set; the cloud derives under the newest it shares. */
|
|
56
|
+
export const PROJECT_ENV_POLICY_EPOCHS: readonly number[] = [1];
|
|
57
|
+
|
|
58
|
+
/** What one task gets for a key. */
|
|
59
|
+
export type ProjectEnvDecision =
|
|
60
|
+
| { kind: "deliver" }
|
|
61
|
+
| { kind: "proxy"; domains: string[] }
|
|
62
|
+
| { kind: "withhold"; reason: string };
|
|
63
|
+
|
|
64
|
+
/** Why a key is withheld, beyond the declaration's own unsupported reasons
|
|
65
|
+
* and a `withhold` binding's reason. Lower-case snake_case, like every code
|
|
66
|
+
* on the wire. */
|
|
67
|
+
export const PROJECT_ENV_WITHHELD_REASONS = [
|
|
68
|
+
/** The declaration asks for more than the binding approves. */
|
|
69
|
+
"awaiting_approval",
|
|
70
|
+
/** The task's schema could not be read, and the key has no binding. */
|
|
71
|
+
"schema_unavailable",
|
|
72
|
+
/** The repository declares `@proxy=omit`. */
|
|
73
|
+
"repository_omits",
|
|
74
|
+
/** The owner's binding omits it. */
|
|
75
|
+
"binding_omits",
|
|
76
|
+
/** The stored binding is not one the contract accepts. */
|
|
77
|
+
"binding_invalid",
|
|
78
|
+
/** Approving what the declaration asks for would take the key past the
|
|
79
|
+
* domain limit. */
|
|
80
|
+
"too_many_domains",
|
|
81
|
+
/** The binding was written under a policy epoch this build does not know. */
|
|
82
|
+
"policy_epoch_unknown",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
export interface ProjectEnvKeyPolicy {
|
|
86
|
+
decision: ProjectEnvDecision;
|
|
87
|
+
/** The binding the declaration asks for, when it is wider than the one
|
|
88
|
+
* approved. Only the owner's Approve applies it; it always passes the
|
|
89
|
+
* contract's binding guard. */
|
|
90
|
+
proposal: ProjectEnvBinding | null;
|
|
91
|
+
/** Why the declaration's wider ask cannot be proposed at all. */
|
|
92
|
+
blocked: "too_many_domains" | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What a task's schema says about one key: its declaration (null if the
|
|
96
|
+
* repository does not declare it), or that the schema could not be read. */
|
|
97
|
+
export type ProjectEnvSchemaEvidence =
|
|
98
|
+
| { status: "resolved"; declaration: ProjectEnvSchemaDeclaration | null }
|
|
99
|
+
| { status: "unavailable" };
|
|
100
|
+
|
|
101
|
+
const withhold = (reason: string): ProjectEnvDecision => ({ kind: "withhold", reason });
|
|
102
|
+
const sorted = (domains: Iterable<string>): string[] => [...new Set(domains)].sort();
|
|
103
|
+
const policy = (
|
|
104
|
+
decision: ProjectEnvDecision,
|
|
105
|
+
proposal: ProjectEnvBinding | null = null,
|
|
106
|
+
blocked: ProjectEnvKeyPolicy["blocked"] = null,
|
|
107
|
+
): ProjectEnvKeyPolicy => ({ decision, proposal, blocked });
|
|
108
|
+
|
|
109
|
+
/** An object with exactly these own fields: none missing, inherited or extra. */
|
|
110
|
+
function exactly(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
|
111
|
+
return (
|
|
112
|
+
value !== null &&
|
|
113
|
+
typeof value === "object" &&
|
|
114
|
+
!Array.isArray(value) &&
|
|
115
|
+
keys.every((key) => Object.hasOwn(value, key)) &&
|
|
116
|
+
Object.keys(value).length === keys.length
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Schema evidence in exactly one of its two shapes, with a declaration the
|
|
121
|
+
* contract accepts; anything else could not be read. */
|
|
122
|
+
function evidenceOf(schema: unknown): ProjectEnvSchemaEvidence {
|
|
123
|
+
if (exactly(schema, ["status", "declaration"]) && schema.status === "resolved") {
|
|
124
|
+
const { declaration } = schema;
|
|
125
|
+
if (declaration === null) return { status: "resolved", declaration: null };
|
|
126
|
+
if (isProjectEnvSchemaDeclaration(declaration)) return { status: "resolved", declaration };
|
|
127
|
+
}
|
|
128
|
+
return { status: "unavailable" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What the binding alone gives a key, with nothing declared beyond it. */
|
|
132
|
+
function bindingDecision(binding: ProjectEnvBinding | null): ProjectEnvDecision {
|
|
133
|
+
if (binding === null || binding.kind === "passthrough") return { kind: "deliver" };
|
|
134
|
+
if (binding.kind === "proxy") return { kind: "proxy", domains: sorted(binding.domains) };
|
|
135
|
+
if (binding.kind === "omit") return withhold("binding_omits");
|
|
136
|
+
return withhold(binding.reason);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether a binding may hand the task the real value. */
|
|
140
|
+
const deliversValue = (binding: ProjectEnvBinding | null): boolean =>
|
|
141
|
+
binding === null || binding.kind === "passthrough";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What a task gets for one key, what its declaration proposes beyond the
|
|
145
|
+
* binding, and why a wider ask cannot be proposed if so. `binding` is null
|
|
146
|
+
* for a row the owner never approved. Both arguments are checked: see the
|
|
147
|
+
* file's header.
|
|
148
|
+
*/
|
|
149
|
+
export function projectEnvKeyPolicy(schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
|
|
150
|
+
if (bindingInput !== null && !isProjectEnvBinding(bindingInput)) return policy(withhold("binding_invalid"));
|
|
151
|
+
const binding = bindingInput as ProjectEnvBinding | null;
|
|
152
|
+
const schema = evidenceOf(schemaInput);
|
|
153
|
+
|
|
154
|
+
if (schema.status === "unavailable") {
|
|
155
|
+
// Nothing declared can be trusted: a bound key follows its binding, and
|
|
156
|
+
// an unbound one is withheld rather than delivered as a plain value.
|
|
157
|
+
return policy(binding === null ? withhold("schema_unavailable") : bindingDecision(binding));
|
|
158
|
+
}
|
|
159
|
+
// An undeclared key is a plain value, like one declared with no directive.
|
|
160
|
+
const directive = schema.declaration?.directive ?? { kind: "none" as const };
|
|
161
|
+
|
|
162
|
+
switch (directive.kind) {
|
|
163
|
+
case "none":
|
|
164
|
+
case "passthrough":
|
|
165
|
+
// A plain value: the binding decides, and where it does not deliver
|
|
166
|
+
// (proxy, omit, withhold), delivering is a proposal.
|
|
167
|
+
return policy(bindingDecision(binding), deliversValue(binding) ? null : { kind: "passthrough" });
|
|
168
|
+
|
|
169
|
+
// The repository's own withholding wins under every binding.
|
|
170
|
+
case "omit":
|
|
171
|
+
return policy(withhold("repository_omits"));
|
|
172
|
+
|
|
173
|
+
case "unsupported":
|
|
174
|
+
return policy(withhold(directive.reason));
|
|
175
|
+
|
|
176
|
+
case "proxy": {
|
|
177
|
+
const declared = sorted(directive.domains);
|
|
178
|
+
// passthrough is the top: proxying exposes nothing it did not allow.
|
|
179
|
+
if (binding?.kind === "passthrough") return policy({ kind: "proxy", domains: declared });
|
|
180
|
+
// Otherwise only approved domains, which only a proxy binding has.
|
|
181
|
+
const approved = binding?.kind === "proxy" ? binding.domains : [];
|
|
182
|
+
const allowed = declared.filter((domain) => approved.includes(domain));
|
|
183
|
+
const decision: ProjectEnvDecision =
|
|
184
|
+
allowed.length > 0
|
|
185
|
+
? { kind: "proxy", domains: allowed }
|
|
186
|
+
: binding === null || binding.kind === "proxy"
|
|
187
|
+
? withhold("awaiting_approval")
|
|
188
|
+
: bindingDecision(binding);
|
|
189
|
+
if (allowed.length === declared.length) return policy(decision);
|
|
190
|
+
// Wider than approved: propose the approved and declared domains
|
|
191
|
+
// together, unless that would take the key past the domain limit.
|
|
192
|
+
const union = sorted([...approved, ...declared]);
|
|
193
|
+
if (union.length > MAX_PROJECT_ENV_PROXY_DOMAINS) {
|
|
194
|
+
return policy(allowed.length > 0 ? decision : withhold("too_many_domains"), null, "too_many_domains");
|
|
195
|
+
}
|
|
196
|
+
return policy(decision, { kind: "proxy", domains: union });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The binding a value is stored with when its row has none yet (ADR-153 §4:
|
|
203
|
+
* "a value set in settings is stored atomically with the policy the owner
|
|
204
|
+
* was shown"): what the project's latest schema declares for the key, as
|
|
205
|
+
* the settings page showed it. Null when it declares nothing: the row stays
|
|
206
|
+
* unbound, a plain value. Where the schema could not be read the page showed
|
|
207
|
+
* the key withheld, and so it stays (withhold: schema_unavailable): a schema
|
|
208
|
+
* that later reads as plain cannot deliver it without the owner's Approve.
|
|
209
|
+
*
|
|
210
|
+
* A project no task has reported a schema for yet (`none`) has no evidence
|
|
211
|
+
* to pass: the caller branches on it first and leaves the row unbound. Here,
|
|
212
|
+
* anything that is not evidence, null included, is a schema that could not
|
|
213
|
+
* be read.
|
|
214
|
+
*/
|
|
215
|
+
export function initialProjectEnvBinding(schemaInput: unknown): ProjectEnvBinding | null {
|
|
216
|
+
const schema = evidenceOf(schemaInput);
|
|
217
|
+
if (schema.status === "unavailable") return { kind: "withhold", reason: "schema_unavailable" };
|
|
218
|
+
if (schema.declaration === null) return null;
|
|
219
|
+
const { directive } = schema.declaration;
|
|
220
|
+
switch (directive.kind) {
|
|
221
|
+
case "none":
|
|
222
|
+
return null;
|
|
223
|
+
case "proxy":
|
|
224
|
+
return { kind: "proxy", domains: sorted(directive.domains) };
|
|
225
|
+
case "passthrough":
|
|
226
|
+
return { kind: "passthrough" };
|
|
227
|
+
case "omit":
|
|
228
|
+
return { kind: "omit" };
|
|
229
|
+
case "unsupported":
|
|
230
|
+
return { kind: "withhold", reason: directive.reason };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What a task gets for one key under the epoch its binding was written
|
|
236
|
+
* under. An epoch this build does not know withholds the key, with nothing
|
|
237
|
+
* proposed: it never falls back to another epoch's meaning.
|
|
238
|
+
*/
|
|
239
|
+
export function projectEnvKeyPolicyAt(epoch: unknown, schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
|
|
240
|
+
switch (epoch) {
|
|
241
|
+
case 1:
|
|
242
|
+
return projectEnvKeyPolicy(schemaInput, bindingInput);
|
|
243
|
+
default:
|
|
244
|
+
return policy(withhold("policy_epoch_unknown"));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The binding a first save stores under a given epoch; withheld under an
|
|
249
|
+
* epoch this build does not know. */
|
|
250
|
+
export function initialProjectEnvBindingAt(epoch: unknown, schemaInput: unknown): ProjectEnvBinding | null {
|
|
251
|
+
switch (epoch) {
|
|
252
|
+
case 1:
|
|
253
|
+
return initialProjectEnvBinding(schemaInput);
|
|
254
|
+
default:
|
|
255
|
+
return { kind: "withhold", reason: "policy_epoch_unknown" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The most policy epochs a runner may advertise. */
|
|
260
|
+
export const MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS = 64;
|
|
261
|
+
|
|
262
|
+
/** A runner's advertised epochs: a dense array of unique positive safe
|
|
263
|
+
* integers, no longer than the bound. Anything else is no set at all. */
|
|
264
|
+
function isAdvertisedEpochSet(value: unknown): value is number[] {
|
|
265
|
+
if (!Array.isArray(value) || value.length > MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS) return false;
|
|
266
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
267
|
+
if (!Object.hasOwn(value, index)) return false;
|
|
268
|
+
const epoch: unknown = value[index];
|
|
269
|
+
if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < 1) return false;
|
|
270
|
+
}
|
|
271
|
+
return new Set(value).size === value.length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The newest policy epoch this build shares with a runner's advertised set,
|
|
275
|
+
* or null when they share none, or the set is malformed: then nothing is
|
|
276
|
+
* derived for that runner. */
|
|
277
|
+
export function sharedProjectEnvPolicyEpoch(advertised: unknown): number | null {
|
|
278
|
+
if (!isAdvertisedEpochSet(advertised)) return null;
|
|
279
|
+
const shared = PROJECT_ENV_POLICY_EPOCHS.filter((epoch) => advertised.includes(epoch));
|
|
280
|
+
return shared.length > 0 ? Math.max(...shared) : null;
|
|
281
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-153 §4: why Uai withholds a key, in words for people, by the
|
|
3
|
+
* contract's snake_case codes. The settings page and the task's note both
|
|
4
|
+
* read these, so the owner and the task see the same reason.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MAX_PROJECT_ENV_PROXY_DOMAINS } from "./protocol";
|
|
8
|
+
|
|
9
|
+
const WITHHELD_REASONS: Readonly<Record<string, string>> = {
|
|
10
|
+
// The repository's @proxy, as the parser reports it.
|
|
11
|
+
malformed: "its @proxy could not be read",
|
|
12
|
+
invalid_value: "its @proxy is neither a domain, passthrough nor omit",
|
|
13
|
+
conflicting_forms: "its @proxy declarations conflict",
|
|
14
|
+
missing_domain: "its @proxy names no domain",
|
|
15
|
+
option_path: "its @proxy uses path, which Uai does not support",
|
|
16
|
+
option_method: "its @proxy uses method, which Uai does not support",
|
|
17
|
+
option_block: "its @proxy uses block, which Uai does not support",
|
|
18
|
+
option_keys: "its @proxy uses keys, which Uai does not support",
|
|
19
|
+
option_substitute_in: "its @proxy uses substituteIn, which Uai does not support",
|
|
20
|
+
option_transform: "its @proxy uses transform, which Uai does not support",
|
|
21
|
+
option_approval: "its @proxy uses approval, which Uai does not support",
|
|
22
|
+
option_rules: "its @proxy uses rules, which Uai does not support",
|
|
23
|
+
option_unknown: "its @proxy uses an option Uai does not know",
|
|
24
|
+
wildcard_domain: "a wildcard domain cannot be pinned",
|
|
25
|
+
ip_literal: "an IP address cannot be proxied",
|
|
26
|
+
localhost: "localhost cannot be proxied",
|
|
27
|
+
reserved_domain: "Uai itself depends on that domain",
|
|
28
|
+
invalid_domain: "its domain is not a public DNS name",
|
|
29
|
+
// The repository's own list, or that list with the approved ones: either
|
|
30
|
+
// way, more than one key may be proxied to.
|
|
31
|
+
too_many_domains: `it would take the key past ${MAX_PROJECT_ENV_PROXY_DOMAINS} proxy domains`,
|
|
32
|
+
header_rule: "a header-level @proxy rule names it",
|
|
33
|
+
duplicate_key: "the repository declares it more than once",
|
|
34
|
+
// A runner's state for the key.
|
|
35
|
+
awaiting_approval: "waiting for approval",
|
|
36
|
+
schema_unavailable: "its .env.schema could not be read",
|
|
37
|
+
repository_omits: "the repository omits it",
|
|
38
|
+
binding_omits: "omitted on this runner",
|
|
39
|
+
binding_invalid: "this runner's approval for it could not be read",
|
|
40
|
+
policy_epoch_unknown: "it was approved under a policy this runner does not know; update the runner",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The words for a withheld reason. A code these words do not know yet still
|
|
44
|
+
* reads as withheld, with the code shown. */
|
|
45
|
+
export function projectEnvWithheldReason(reason: string): string {
|
|
46
|
+
return Object.hasOwn(WITHHELD_REASONS, reason) ? WITHHELD_REASONS[reason]! : `not supported (${reason})`;
|
|
47
|
+
}
|