@moondeck/station 0.0.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- 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/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1470 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -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/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -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 +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -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 +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5550 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- package/bin/moondeck-station.mjs +0 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domains a key may never be proxied to (ADR-153 §4 "Fail closed"), a name
|
|
3
|
+
* itself or any name under it. GitHub keeps its own credential path (the GH
|
|
4
|
+
* identity work), and the product's own domains, runuai.com and moondeck.io
|
|
5
|
+
* both, carry the runner's and the cloud's traffic; moondeckapp.com serves
|
|
6
|
+
* user content (previews, the editor) as runuai.com's preview names do. A name with no dot, or
|
|
7
|
+
* under `localhost`, can never hold a public certificate.
|
|
8
|
+
*
|
|
9
|
+
* The one list: the runner's proxy and `.env.schema` parser, the protocol's
|
|
10
|
+
* binding checks and the cloud's task secrets all call isNeverProxiedDomain.
|
|
11
|
+
* lib/__tests__/never-proxied-domains.test.ts checks that each of those three
|
|
12
|
+
* lets its result decide (a `&&`-joined veto in its `return`, or an
|
|
13
|
+
* `||`-joined trigger in a throwing `if`), and fails on
|
|
14
|
+
* any other array literal in the source naming two or more of these domains
|
|
15
|
+
* (literals, concatenations and scope-resolved const strings). It cannot see
|
|
16
|
+
* a copy written another way (a regular expression, a list built at
|
|
17
|
+
* runtime): don't write one.
|
|
18
|
+
*/
|
|
19
|
+
export const NEVER_PROXIED_DOMAIN_SUFFIXES = [
|
|
20
|
+
"github.com",
|
|
21
|
+
"githubusercontent.com",
|
|
22
|
+
"runuai.com",
|
|
23
|
+
"moondeck.io",
|
|
24
|
+
"moondeckapp.com",
|
|
25
|
+
"localhost",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** For a canonical (lower-case, no trailing dot) hostname. */
|
|
29
|
+
export function isNeverProxiedDomain(name: string): boolean {
|
|
30
|
+
return NEVER_PROXIED_DOMAIN_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`));
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the runner may send the cloud. The cloud's bridge closes a runner's
|
|
3
|
+
* connection on any frame past its ceiling (100 MiB, lib/host-bridge.ts), so
|
|
4
|
+
* a frame that would serialize past it is never sent: a command's result is
|
|
5
|
+
* replaced by its failure, and anything else is dropped. Either way the
|
|
6
|
+
* connection, and every other task's traffic on it, stays open.
|
|
7
|
+
*/
|
|
8
|
+
import { HostErrorCode, type HostToCloud } from "./protocol";
|
|
9
|
+
|
|
10
|
+
/** Under the cloud's ceiling, with room to spare. */
|
|
11
|
+
export const MAX_OUTBOUND_FRAME_BYTES = 96 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/** The serialized frame to send for `frame`: itself, a command result's
|
|
14
|
+
* failure in its place, or null when nothing of it can be sent. */
|
|
15
|
+
export function outboundFrameJson(frame: HostToCloud): string | null {
|
|
16
|
+
const json = JSON.stringify(frame);
|
|
17
|
+
if (Buffer.byteLength(json, "utf8") <= MAX_OUTBOUND_FRAME_BYTES) return json;
|
|
18
|
+
if (frame.kind === "result") {
|
|
19
|
+
return JSON.stringify({
|
|
20
|
+
kind: "result",
|
|
21
|
+
commandId: frame.commandId,
|
|
22
|
+
result: {
|
|
23
|
+
ok: false,
|
|
24
|
+
code: HostErrorCode.Internal,
|
|
25
|
+
message: "the result is too large to send",
|
|
26
|
+
},
|
|
27
|
+
} satisfies HostToCloud);
|
|
28
|
+
}
|
|
29
|
+
console.warn(`[host] dropped an outbound ${frame.kind} frame past the bridge's size limit`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filesystem paths for the host service + local UI (ADR-028).
|
|
3
|
+
* Used by main.ts (server boot), cli.ts (status/open/logs), and the per-OS
|
|
4
|
+
* install modules so the log/port/asset locations have a single definition.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { env } from "../lib/env";
|
|
13
|
+
import { STATION } from "./station-identity";
|
|
14
|
+
|
|
15
|
+
const srcDir = dirname(fileURLToPath(import.meta.url)); // host-agent/src
|
|
16
|
+
|
|
17
|
+
/** Base dir for host state — the repo root in a checkout, ~/.moondeck when
|
|
18
|
+
* installed via npm. Used as the service working directory. */
|
|
19
|
+
export function uaiHome(): string {
|
|
20
|
+
return env.uaiHome;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `.env.local` under UAI_HOME — where `setup`/`pair` persist the credential. */
|
|
24
|
+
export function envLocalPath(): string {
|
|
25
|
+
return join(env.uaiHome, ".env.local");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Static UI asset dir (`host-agent/ui`). */
|
|
29
|
+
export function uiAssetsDir(): string {
|
|
30
|
+
return resolve(srcDir, "..", "ui");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where the chosen UI port is persisted (`$UAI_DATA_DIR/host.port`). */
|
|
34
|
+
export function uiPortFilePath(): string {
|
|
35
|
+
return join(env.dataDir, "host.port");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Stable host identity persisted by the daemon for legacy/token-only setups. */
|
|
39
|
+
export function hostIdFilePath(): string {
|
|
40
|
+
return join(env.dataDir, "host-id");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read the daemon's persisted identity without creating one. */
|
|
44
|
+
export function readPersistedHostId(): string | null {
|
|
45
|
+
const path = hostIdFilePath();
|
|
46
|
+
if (!existsSync(path)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(path, "utf8").trim() || null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Per-OS service log path — informational in `/api/status`, the file
|
|
55
|
+
* `uai-host logs` tails. Linux logs to journald, so returns a hint there. */
|
|
56
|
+
export function serviceLogPath(): string {
|
|
57
|
+
if (process.platform === "darwin") {
|
|
58
|
+
return resolve(homedir(), "Library", "Logs", STATION.logDirName, STATION.logFileName);
|
|
59
|
+
}
|
|
60
|
+
if (process.platform === "win32") {
|
|
61
|
+
return join(
|
|
62
|
+
process.env.LOCALAPPDATA ?? homedir(),
|
|
63
|
+
STATION.logDirName,
|
|
64
|
+
"service",
|
|
65
|
+
"logs",
|
|
66
|
+
STATION.logFileName,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return `journald (journalctl --user -u ${STATION.command})`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `@moondeck/station` package version (read at runtime; no JSON-import dance). */
|
|
73
|
+
export function packageVersion(): string {
|
|
74
|
+
try {
|
|
75
|
+
const raw = readFileSync(resolve(srcDir, "..", "package.json"), "utf8");
|
|
76
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
77
|
+
return pkg.version ?? "0.0.0";
|
|
78
|
+
} catch {
|
|
79
|
+
return "0.0.0";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -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,396 @@
|
|
|
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
|
+
isDeclaredProjectEnvBinding,
|
|
36
|
+
isProjectEnvBinding,
|
|
37
|
+
isProjectEnvSchemaDeclaration,
|
|
38
|
+
type ProjectEnvBinding,
|
|
39
|
+
type ProjectEnvPlaceholderShape,
|
|
40
|
+
type ProjectEnvSchemaDeclaration,
|
|
41
|
+
} from "./protocol";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The newest policy epoch: what `projectEnvKeyPolicy` and
|
|
45
|
+
* `initialProjectEnvBinding` derive. Bump it with any change to what they
|
|
46
|
+
* return for some input, and keep the previous epoch's derivation, frozen,
|
|
47
|
+
* in `PROJECT_ENV_POLICY_EPOCHS`: a binding is read under the epoch it was
|
|
48
|
+
* written under, and a token the settings page was given under another
|
|
49
|
+
* epoch is refused, so an owner never approves what a different policy
|
|
50
|
+
* showed them (ADR-153 §4, "Policy epochs"). The policy test's fingerprints
|
|
51
|
+
* pin each retained epoch's results on a fixed set of inputs: a tripwire,
|
|
52
|
+
* not a proof.
|
|
53
|
+
*/
|
|
54
|
+
export const PROJECT_ENV_POLICY_EPOCH = 2;
|
|
55
|
+
|
|
56
|
+
/** Every policy epoch this build derives under, oldest first. A runner
|
|
57
|
+
* advertises this set; the cloud derives under the newest it shares.
|
|
58
|
+
*
|
|
59
|
+
* - 1: ADR-153.
|
|
60
|
+
* - 2: ADR-154 §9, a key the owner declared proxied in Settings (a `proxy`
|
|
61
|
+
* binding with `declared`): the repository can only narrow it. Every other
|
|
62
|
+
* binding derives exactly as under 1, so it is stored under 1
|
|
63
|
+
* (`projectEnvBindingEpoch`) and needs no upgrade. */
|
|
64
|
+
export const PROJECT_ENV_POLICY_EPOCHS: readonly number[] = [1, 2];
|
|
65
|
+
|
|
66
|
+
/** What one task gets for a key. */
|
|
67
|
+
export type ProjectEnvDecision =
|
|
68
|
+
| { kind: "deliver" }
|
|
69
|
+
| { kind: "proxy"; domains: string[] }
|
|
70
|
+
| { kind: "withhold"; reason: string };
|
|
71
|
+
|
|
72
|
+
/** Why a key is withheld, beyond the declaration's own unsupported reasons
|
|
73
|
+
* and a `withhold` binding's reason. Lower-case snake_case, like every code
|
|
74
|
+
* on the wire. */
|
|
75
|
+
export const PROJECT_ENV_WITHHELD_REASONS = [
|
|
76
|
+
/** The declaration asks for more than the binding approves. */
|
|
77
|
+
"awaiting_approval",
|
|
78
|
+
/** The task's schema could not be read, and the key has no binding. */
|
|
79
|
+
"schema_unavailable",
|
|
80
|
+
/** The repository declares `@proxy=omit`. */
|
|
81
|
+
"repository_omits",
|
|
82
|
+
/** The owner's binding omits it. */
|
|
83
|
+
"binding_omits",
|
|
84
|
+
/** The stored binding is not one the contract accepts. */
|
|
85
|
+
"binding_invalid",
|
|
86
|
+
/** Approving what the declaration asks for would take the key past the
|
|
87
|
+
* domain limit. */
|
|
88
|
+
"too_many_domains",
|
|
89
|
+
/** The binding was written under a policy epoch this build does not know. */
|
|
90
|
+
"policy_epoch_unknown",
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
export interface ProjectEnvKeyPolicy {
|
|
94
|
+
decision: ProjectEnvDecision;
|
|
95
|
+
/** The binding the declaration asks for, when it is wider than the one
|
|
96
|
+
* approved. Only the owner's Approve applies it; it always passes the
|
|
97
|
+
* contract's binding guard. */
|
|
98
|
+
proposal: ProjectEnvBinding | null;
|
|
99
|
+
/** Why the declaration's wider ask cannot be proposed at all. */
|
|
100
|
+
blocked: "too_many_domains" | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a task's schema says about one key: its declaration (null if the
|
|
104
|
+
* repository does not declare it), or that the schema could not be read. */
|
|
105
|
+
export type ProjectEnvSchemaEvidence =
|
|
106
|
+
| { status: "resolved"; declaration: ProjectEnvSchemaDeclaration | null }
|
|
107
|
+
| { status: "unavailable" };
|
|
108
|
+
|
|
109
|
+
const withhold = (reason: string): ProjectEnvDecision => ({ kind: "withhold", reason });
|
|
110
|
+
const sorted = (domains: Iterable<string>): string[] => [...new Set(domains)].sort();
|
|
111
|
+
const policy = (
|
|
112
|
+
decision: ProjectEnvDecision,
|
|
113
|
+
proposal: ProjectEnvBinding | null = null,
|
|
114
|
+
blocked: ProjectEnvKeyPolicy["blocked"] = null,
|
|
115
|
+
): ProjectEnvKeyPolicy => ({ decision, proposal, blocked });
|
|
116
|
+
|
|
117
|
+
/** An object with exactly these own fields: none missing, inherited or extra. */
|
|
118
|
+
function exactly(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
|
119
|
+
return (
|
|
120
|
+
value !== null &&
|
|
121
|
+
typeof value === "object" &&
|
|
122
|
+
!Array.isArray(value) &&
|
|
123
|
+
keys.every((key) => Object.hasOwn(value, key)) &&
|
|
124
|
+
Object.keys(value).length === keys.length
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Schema evidence in exactly one of its two shapes, with a declaration the
|
|
129
|
+
* contract accepts; anything else could not be read. */
|
|
130
|
+
function evidenceOf(schema: unknown): ProjectEnvSchemaEvidence {
|
|
131
|
+
if (exactly(schema, ["status", "declaration"]) && schema.status === "resolved") {
|
|
132
|
+
const { declaration } = schema;
|
|
133
|
+
if (declaration === null) return { status: "resolved", declaration: null };
|
|
134
|
+
if (isProjectEnvSchemaDeclaration(declaration)) return { status: "resolved", declaration };
|
|
135
|
+
}
|
|
136
|
+
return { status: "unavailable" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What the binding alone gives a key, with nothing declared beyond it. */
|
|
140
|
+
function bindingDecision(binding: ProjectEnvBinding | null): ProjectEnvDecision {
|
|
141
|
+
if (binding === null || binding.kind === "passthrough") return { kind: "deliver" };
|
|
142
|
+
if (binding.kind === "proxy") return { kind: "proxy", domains: sorted(binding.domains) };
|
|
143
|
+
if (binding.kind === "omit") return withhold("binding_omits");
|
|
144
|
+
return withhold(binding.reason);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether a binding may hand the task the real value. */
|
|
148
|
+
const deliversValue = (binding: ProjectEnvBinding | null): boolean =>
|
|
149
|
+
binding === null || binding.kind === "passthrough";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What a task gets for one key under epoch 1 (ADR-153), frozen: what its
|
|
153
|
+
* declaration proposes beyond the binding, and why a wider ask cannot be
|
|
154
|
+
* proposed if so. A binding declared in Settings did not exist under it, and
|
|
155
|
+
* is one it cannot accept.
|
|
156
|
+
*/
|
|
157
|
+
function projectEnvKeyPolicyV1(schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
|
|
158
|
+
if (bindingInput !== null && (!isProjectEnvBinding(bindingInput) || isDeclaredProjectEnvBinding(bindingInput))) {
|
|
159
|
+
return policy(withhold("binding_invalid"));
|
|
160
|
+
}
|
|
161
|
+
const binding = bindingInput as ProjectEnvBinding | null;
|
|
162
|
+
const schema = evidenceOf(schemaInput);
|
|
163
|
+
|
|
164
|
+
if (schema.status === "unavailable") {
|
|
165
|
+
// Nothing declared can be trusted: a bound key follows its binding, and
|
|
166
|
+
// an unbound one is withheld rather than delivered as a plain value.
|
|
167
|
+
return policy(binding === null ? withhold("schema_unavailable") : bindingDecision(binding));
|
|
168
|
+
}
|
|
169
|
+
// An undeclared key is a plain value, like one declared with no directive.
|
|
170
|
+
const directive = schema.declaration?.directive ?? { kind: "none" as const };
|
|
171
|
+
|
|
172
|
+
switch (directive.kind) {
|
|
173
|
+
case "none":
|
|
174
|
+
case "passthrough":
|
|
175
|
+
// A plain value: the binding decides, and where it does not deliver
|
|
176
|
+
// (proxy, omit, withhold), delivering is a proposal.
|
|
177
|
+
return policy(bindingDecision(binding), deliversValue(binding) ? null : { kind: "passthrough" });
|
|
178
|
+
|
|
179
|
+
// The repository's own withholding wins under every binding.
|
|
180
|
+
case "omit":
|
|
181
|
+
return policy(withhold("repository_omits"));
|
|
182
|
+
|
|
183
|
+
case "unsupported":
|
|
184
|
+
return policy(withhold(directive.reason));
|
|
185
|
+
|
|
186
|
+
case "proxy": {
|
|
187
|
+
const declared = sorted(directive.domains);
|
|
188
|
+
// passthrough is the top: proxying exposes nothing it did not allow.
|
|
189
|
+
if (binding?.kind === "passthrough") return policy({ kind: "proxy", domains: declared });
|
|
190
|
+
// Otherwise only approved domains, which only a proxy binding has.
|
|
191
|
+
const approved = binding?.kind === "proxy" ? binding.domains : [];
|
|
192
|
+
const allowed = declared.filter((domain) => approved.includes(domain));
|
|
193
|
+
const decision: ProjectEnvDecision =
|
|
194
|
+
allowed.length > 0
|
|
195
|
+
? { kind: "proxy", domains: allowed }
|
|
196
|
+
: binding === null || binding.kind === "proxy"
|
|
197
|
+
? withhold("awaiting_approval")
|
|
198
|
+
: bindingDecision(binding);
|
|
199
|
+
if (allowed.length === declared.length) return policy(decision);
|
|
200
|
+
// Wider than approved: propose the approved and declared domains
|
|
201
|
+
// together, unless that would take the key past the domain limit.
|
|
202
|
+
const union = sorted([...approved, ...declared]);
|
|
203
|
+
if (union.length > MAX_PROJECT_ENV_PROXY_DOMAINS) {
|
|
204
|
+
return policy(allowed.length > 0 ? decision : withhold("too_many_domains"), null, "too_many_domains");
|
|
205
|
+
}
|
|
206
|
+
return policy(decision, { kind: "proxy", domains: union });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ADR-154 §9: a key the owner declared proxied in Settings. Declaring was the
|
|
213
|
+
* owner's approval of exactly these domains, which the repository's schema
|
|
214
|
+
* can only narrow:
|
|
215
|
+
* - unreadable, undeclared, plain or `passthrough`: proxied to the declared
|
|
216
|
+
* domains, and nothing proposed (the repository never asks to deliver it);
|
|
217
|
+
* - `@proxy=omit`, or an unsupported directive: withheld;
|
|
218
|
+
* - `@proxy` naming some of the declared domains: proxied to those; any it
|
|
219
|
+
* names beyond them are a proposal (still declared), as in ADR-153, or
|
|
220
|
+
* blocked past the domain limit.
|
|
221
|
+
*/
|
|
222
|
+
function declaredKeyPolicy(
|
|
223
|
+
schema: ProjectEnvSchemaEvidence,
|
|
224
|
+
binding: Extract<ProjectEnvBinding, { kind: "proxy" }> & { declared: { placeholder: ProjectEnvPlaceholderShape | null } },
|
|
225
|
+
): ProjectEnvKeyPolicy {
|
|
226
|
+
const settings = sorted(binding.domains);
|
|
227
|
+
const asDeclared: ProjectEnvDecision = { kind: "proxy", domains: settings };
|
|
228
|
+
if (schema.status === "unavailable") return policy(asDeclared);
|
|
229
|
+
const directive = schema.declaration?.directive ?? { kind: "none" as const };
|
|
230
|
+
switch (directive.kind) {
|
|
231
|
+
case "none":
|
|
232
|
+
case "passthrough":
|
|
233
|
+
return policy(asDeclared);
|
|
234
|
+
case "omit":
|
|
235
|
+
return policy(withhold("repository_omits"));
|
|
236
|
+
case "unsupported":
|
|
237
|
+
return policy(withhold(directive.reason));
|
|
238
|
+
case "proxy": {
|
|
239
|
+
const declared = sorted(directive.domains);
|
|
240
|
+
const allowed = declared.filter((domain) => settings.includes(domain));
|
|
241
|
+
const decision: ProjectEnvDecision =
|
|
242
|
+
allowed.length > 0 ? { kind: "proxy", domains: allowed } : withhold("awaiting_approval");
|
|
243
|
+
if (allowed.length === declared.length) return policy(decision);
|
|
244
|
+
const union = sorted([...settings, ...declared]);
|
|
245
|
+
if (union.length > MAX_PROJECT_ENV_PROXY_DOMAINS) {
|
|
246
|
+
return policy(allowed.length > 0 ? decision : withhold("too_many_domains"), null, "too_many_domains");
|
|
247
|
+
}
|
|
248
|
+
return policy(decision, { kind: "proxy", domains: union, declared: { placeholder: binding.declared.placeholder } });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What a task gets for one key under the newest epoch (2), what its
|
|
255
|
+
* declaration proposes beyond the binding, and why a wider ask cannot be
|
|
256
|
+
* proposed if so. `binding` is null for a row the owner never approved. Both
|
|
257
|
+
* arguments are checked: see the file's header. A binding declared in
|
|
258
|
+
* Settings follows `declaredKeyPolicy`; every other derives as under epoch 1.
|
|
259
|
+
*/
|
|
260
|
+
export function projectEnvKeyPolicy(schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
|
|
261
|
+
if (bindingInput !== null && !isProjectEnvBinding(bindingInput)) return policy(withhold("binding_invalid"));
|
|
262
|
+
if (isDeclaredProjectEnvBinding(bindingInput as ProjectEnvBinding | null)) {
|
|
263
|
+
return declaredKeyPolicy(
|
|
264
|
+
evidenceOf(schemaInput),
|
|
265
|
+
bindingInput as Parameters<typeof declaredKeyPolicy>[1],
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return projectEnvKeyPolicyV1(schemaInput, bindingInput);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The binding a value is stored with when its row has none yet (ADR-153 §4:
|
|
273
|
+
* "a value set in settings is stored atomically with the policy the owner
|
|
274
|
+
* was shown"): what the project's latest schema declares for the key, as
|
|
275
|
+
* the settings page showed it. Null when it declares nothing: the row stays
|
|
276
|
+
* unbound, a plain value. Where the schema could not be read the page showed
|
|
277
|
+
* the key withheld, and so it stays (withhold: schema_unavailable): a schema
|
|
278
|
+
* that later reads as plain cannot deliver it without the owner's Approve.
|
|
279
|
+
*
|
|
280
|
+
* A project no task has reported a schema for yet (`none`) has no evidence
|
|
281
|
+
* to pass: the caller branches on it first and leaves the row unbound. Here,
|
|
282
|
+
* anything that is not evidence, null included, is a schema that could not
|
|
283
|
+
* be read.
|
|
284
|
+
*/
|
|
285
|
+
export function initialProjectEnvBinding(schemaInput: unknown): ProjectEnvBinding | null {
|
|
286
|
+
const schema = evidenceOf(schemaInput);
|
|
287
|
+
if (schema.status === "unavailable") return { kind: "withhold", reason: "schema_unavailable" };
|
|
288
|
+
if (schema.declaration === null) return null;
|
|
289
|
+
const { directive } = schema.declaration;
|
|
290
|
+
switch (directive.kind) {
|
|
291
|
+
case "none":
|
|
292
|
+
return null;
|
|
293
|
+
case "proxy":
|
|
294
|
+
return { kind: "proxy", domains: sorted(directive.domains) };
|
|
295
|
+
case "passthrough":
|
|
296
|
+
return { kind: "passthrough" };
|
|
297
|
+
case "omit":
|
|
298
|
+
return { kind: "omit" };
|
|
299
|
+
case "unsupported":
|
|
300
|
+
return { kind: "withhold", reason: directive.reason };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What a task gets for one key under the epoch its binding was written
|
|
306
|
+
* under. An epoch this build does not know withholds the key, with nothing
|
|
307
|
+
* proposed: it never falls back to another epoch's meaning.
|
|
308
|
+
*/
|
|
309
|
+
export function projectEnvKeyPolicyAt(epoch: unknown, schemaInput: unknown, bindingInput: unknown): ProjectEnvKeyPolicy {
|
|
310
|
+
switch (epoch) {
|
|
311
|
+
case 1:
|
|
312
|
+
return projectEnvKeyPolicyV1(schemaInput, bindingInput);
|
|
313
|
+
case 2:
|
|
314
|
+
return projectEnvKeyPolicy(schemaInput, bindingInput);
|
|
315
|
+
default:
|
|
316
|
+
return policy(withhold("policy_epoch_unknown"));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The binding a first save stores under a given epoch; withheld under an
|
|
321
|
+
* epoch this build does not know. Epoch 2 stores what 1 does: a Settings
|
|
322
|
+
* declaration is the owner's own act, never a first save's. */
|
|
323
|
+
export function initialProjectEnvBindingAt(epoch: unknown, schemaInput: unknown): ProjectEnvBinding | null {
|
|
324
|
+
switch (epoch) {
|
|
325
|
+
case 1:
|
|
326
|
+
case 2:
|
|
327
|
+
return initialProjectEnvBinding(schemaInput);
|
|
328
|
+
default:
|
|
329
|
+
return { kind: "withhold", reason: "policy_epoch_unknown" };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The placeholder shape a proxied key's task gets, under its binding's
|
|
335
|
+
* epoch: the repository's `@placeholder` when its schema declares the key
|
|
336
|
+
* proxied with one; under epoch 2 a Settings declaration's shape next; null
|
|
337
|
+
* otherwise (the default shape). Only meaningful for a key the policy
|
|
338
|
+
* proxies; checked like the policy's own inputs.
|
|
339
|
+
*/
|
|
340
|
+
export function projectEnvPlaceholderAt(
|
|
341
|
+
epoch: unknown,
|
|
342
|
+
schemaInput: unknown,
|
|
343
|
+
bindingInput: unknown,
|
|
344
|
+
): ProjectEnvPlaceholderShape | null {
|
|
345
|
+
if (epoch !== 1 && epoch !== 2) return null;
|
|
346
|
+
const schema = evidenceOf(schemaInput);
|
|
347
|
+
const fromSchema =
|
|
348
|
+
schema.status === "resolved" && schema.declaration?.directive.kind === "proxy"
|
|
349
|
+
? (schema.declaration.placeholder ?? null)
|
|
350
|
+
: null;
|
|
351
|
+
if (fromSchema !== null || epoch === 1) return fromSchema;
|
|
352
|
+
const binding = bindingInput !== null && isProjectEnvBinding(bindingInput) ? bindingInput : null;
|
|
353
|
+
return isDeclaredProjectEnvBinding(binding) ? binding.declared.placeholder : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The epoch to store a binding under: the lowest this build knows whose
|
|
358
|
+
* derivation for it is the newest's, never below `floor` (a record's epoch
|
|
359
|
+
* only moves up). A declared binding needs 2; every other means under 1 what
|
|
360
|
+
* it means under 2, so it stays readable by runners that know only 1.
|
|
361
|
+
*/
|
|
362
|
+
export function projectEnvBindingEpoch(binding: ProjectEnvBinding | null, floor = 1): number {
|
|
363
|
+
return Math.max(isDeclaredProjectEnvBinding(binding) ? 2 : 1, floor);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Whether moving a binding from one epoch to a newer one changes what any
|
|
367
|
+
* task would get: only then is an upgrade offered for the owner's Approve. */
|
|
368
|
+
export function projectEnvEpochUpgradeChanges(fromEpoch: number, toEpoch: number, binding: unknown): boolean {
|
|
369
|
+
if (fromEpoch >= toEpoch) return false;
|
|
370
|
+
// Epochs 1 and 2 derive the same for every binding valid under 1.
|
|
371
|
+
return !(fromEpoch === 1 && toEpoch === 2 && !(isProjectEnvBinding(binding) && isDeclaredProjectEnvBinding(binding)));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The most policy epochs a runner may advertise. */
|
|
375
|
+
export const MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS = 64;
|
|
376
|
+
|
|
377
|
+
/** A runner's advertised epochs: a dense array of unique positive safe
|
|
378
|
+
* integers, no longer than the bound. Anything else is no set at all. */
|
|
379
|
+
function isAdvertisedEpochSet(value: unknown): value is number[] {
|
|
380
|
+
if (!Array.isArray(value) || value.length > MAX_ADVERTISED_PROJECT_ENV_POLICY_EPOCHS) return false;
|
|
381
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
382
|
+
if (!Object.hasOwn(value, index)) return false;
|
|
383
|
+
const epoch: unknown = value[index];
|
|
384
|
+
if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < 1) return false;
|
|
385
|
+
}
|
|
386
|
+
return new Set(value).size === value.length;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The newest policy epoch this build shares with a runner's advertised set,
|
|
390
|
+
* or null when they share none, or the set is malformed: then nothing is
|
|
391
|
+
* derived for that runner. */
|
|
392
|
+
export function sharedProjectEnvPolicyEpoch(advertised: unknown): number | null {
|
|
393
|
+
if (!isAdvertisedEpochSet(advertised)) return null;
|
|
394
|
+
const shared = PROJECT_ENV_POLICY_EPOCHS.filter((epoch) => advertised.includes(epoch));
|
|
395
|
+
return shared.length > 0 ? Math.max(...shared) : null;
|
|
396
|
+
}
|