import { napi, type NapiSandboxModifyOptions } from "./internal/napi.js"; import type { SecretSubstitution } from "./network-config.js"; import type { ViolationAction } from "./violation-action.js"; // The wire contract for these shapes is generated from the Rust types in // packages/microsandbox-types (see packages/microsandbox-types/typescript/src/index.ts, // generated by `cargo run -p microsandbox-types --features ts --bin microsandbox-types-generate`). // This module mirrors those shapes with the node SDK's camelCase field names; keep the // literal unions below in sync with the generated `@microsandbox/types` declarations. /** Policy for applying a sandbox modification. */ export type ModificationPolicy = "no_restart" | "next_start" | "restart"; /** When or whether a planned change can take effect. */ export type ModificationDisposition = | "live" | "next start" | "requires restart" | "unsupported"; /** Natural config change type for human output. */ export type ChangeKind = "added" | "updated" | "removed"; /** Natural secret change type for human output. */ export type SecretChangeKind = | "added" | "rotated" | "removed" | "renamed" | "hosts updated" | "placeholder updated"; /** Resource kind used by live resize convergence reporting. */ export type ResourceKind = "cpus" | "memory"; /** Runtime convergence state for an accepted resource resize. */ export type ResourceConvergenceState = | "accepted" | "converging" | "applied" | "guest-refused" | "failed"; /** * Options for `Sandbox.modify()` / `SandboxHandle.modify()`. * * `memory` / `maxMemory` / `rootDiskSize` are in MiB. Omitted fields are left unchanged. */ export interface ModifyOptions { /** Desired effective vCPU count. */ cpus?: number; /** Desired boot-time maximum possible vCPU count. */ maxCpus?: number; /** Desired effective guest memory in MiB. */ memory?: number; /** Desired boot-time maximum hotpluggable memory in MiB. */ maxMemory?: number; /** Desired root disk size in MiB. Managed and flat disks are grow-only. */ rootDiskSize?: number; /** Environment variables to set for future execs. */ env?: Record; /** Environment variable keys to remove. */ envRemove?: string[]; /** Labels to set. */ labels?: Record; /** Label keys to remove. */ labelsRemove?: string[]; /** Desired working directory for future execs. */ workdir?: string; /** * Desired secret specs, keyed by secret name. The planner diffs each spec * against the existing config to infer what changes; omitting a name never * means removal (see `secretsRemove`). */ secrets?: Record; /** Secret names to remove. */ secretsRemove?: string[]; /** Published ports to add or update; other mappings remain unchanged. */ ports?: ModifyPort[]; /** Published host endpoints to remove. */ portsRemove?: ModifyPortEndpoint[]; /** Apply policy. Defaults to `"no_restart"`. */ policy?: ModificationPolicy; /** Compute the plan without applying anything. Defaults to `false`. */ dryRun?: boolean; } /** A published host endpoint. Defaults to loopback TCP. */ export interface ModifyPortEndpoint { hostPort: number; hostBind?: string; protocol?: "tcp" | "udp"; } /** A published port to add or update. */ export interface ModifyPort extends ModifyPortEndpoint { guestPort: number; } /** * Desired state for one secret in `ModifyOptions.secrets`. * * `env`, `value`, and `store` are mutually exclusive ways to provide the * secret material; setting more than one is rejected at the native boundary. * Only `value` may carry raw secret material, and it never appears in the * returned plan. * * Omitted policy fields keep the stored setting. Unknown keys, here or in * `substitution`, are rejected. */ export interface SecretModifySpec { /** Resolve the value from this host environment variable at apply time. */ env?: string; /** Raw secret value supplied directly (e.g. from the caller's own vault). */ value?: string; /** Resolve the value from this host-side secret store reference. */ store?: string; /** Guest-visible placeholder/reference, if explicitly requested. */ placeholder?: string; /** * Desired allowed host patterns. Empty leaves an existing secret's hosts * unchanged; a new secret needs at least one. */ allowedHosts?: string[]; /** * Request locations for substitution. A supplied value replaces the whole * rule; omitted locations take their defaults. */ substitution?: SecretSubstitution; /** Per-secret blocking action. */ violationAction?: ViolationAction; /** Whether substitution requires verified TLS identity. */ requireTlsIdentity?: boolean; /** Hosts allowed to receive the unchanged placeholder; empty leaves them unchanged. */ allowPlaceholderFor?: string[]; } const SECRET_SPEC_KEYS: readonly string[] = [ "env", "value", "store", "placeholder", "allowedHosts", "substitution", "violationAction", "requireTlsIdentity", "allowPlaceholderFor", ]; const SECRET_SUBSTITUTION_KEYS: readonly string[] = [ "headers", "headerFields", "query", "body", ]; /** Reject options that an older native library would accept but not apply. */ export function requireModifyCapabilities(opts?: ModifyOptions): void { if ((opts?.ports?.length || opts?.portsRemove?.length) && !napi.supportsPortModification?.()) { throw new Error("published-port modification requires an updated native library"); } if (usesSecretPolicyFields(opts) && !napi.supportsSecretPolicyModification?.()) { throw new Error("secret policy modification requires an updated native library"); } } /** Whether any secret spec sets a field that needs `supportsSecretPolicyModification`. */ function usesSecretPolicyFields(opts?: ModifyOptions): boolean { return Object.values(opts?.secrets ?? {}).some( (spec) => spec?.substitution != null || spec?.violationAction != null || spec?.requireTlsIdentity != null || (spec?.allowPlaceholderFor?.length ?? 0) > 0, ); } /** Native code drops undeclared keys silently, so a misspelled key must fail here. */ function rejectUnknownSecretKeys(secrets: Record): void { for (const [name, spec] of Object.entries(secrets)) { rejectUnknownKeys(name, spec, SECRET_SPEC_KEYS, ""); rejectUnknownKeys(name, spec?.substitution, SECRET_SUBSTITUTION_KEYS, "substitution."); } } function rejectUnknownKeys( name: string, spec: unknown, allowed: readonly string[], prefix: string, ): void { if (typeof spec !== "object" || spec === null) return; for (const [key, value] of Object.entries(spec)) { if (value !== undefined && !allowed.includes(key)) { throw new Error( `secret ${JSON.stringify(name)}: unknown key ${JSON.stringify(prefix + key)}; ` + `expected one of ${allowed.map((k) => JSON.stringify(prefix + k)).join(", ")}`, ); } } } /** Ordinary config change planned by `modify()`. */ export interface ConfigPlannedChange { kind: "config"; field: string; change: ChangeKind; before?: string; after?: string; disposition: ModificationDisposition; reason?: string; } /** Secret change planned by `modify()`. Values are omitted by construction. */ export interface SecretPlannedChange { kind: "secret"; field: string; name: string; change: SecretChangeKind; beforeRef?: string; afterRef?: string; disposition: ModificationDisposition; allowHosts: string[]; reason?: string; } export type PlannedChange = ConfigPlannedChange | SecretPlannedChange; /** Conflict that blocks applying a modification. */ export interface ModificationConflict { field: string; message: string; } /** Warning emitted while planning a modification. */ export interface ModificationWarning { field: string; message: string; } /** Runtime convergence status for a live resource resize. */ export interface ResourceResizeStatus { resource: ResourceKind; requested: string; actual: string; enforced: string; state: ResourceConvergenceState; } /** Dry-run or apply plan returned by `modify()`. */ export interface SandboxModificationPlan { sandbox: string; status: string; applied: boolean; policy: ModificationPolicy; changes: PlannedChange[]; conflicts: ModificationConflict[]; warnings: ModificationWarning[]; resizeStatus: ResourceResizeStatus[]; } /** Map TS modify options onto the native option object. */ export function modifyOptionsToNapi( opts?: ModifyOptions, ): NapiSandboxModifyOptions | undefined { if (!opts) return undefined; if (opts.secrets) rejectUnknownSecretKeys(opts.secrets); return { cpus: opts.cpus, maxCpus: opts.maxCpus, memoryMib: opts.memory, maxMemoryMib: opts.maxMemory, rootDiskSizeMib: opts.rootDiskSize, env: opts.env, envRemove: opts.envRemove, labels: opts.labels, labelsRemove: opts.labelsRemove, workdir: opts.workdir, secrets: opts.secrets, secretsRemove: opts.secretsRemove, ports: opts.ports, portsRemove: opts.portsRemove, policy: opts.policy, dryRun: opts.dryRun, }; } /** Parse the canonical plan JSON emitted by the native layer. */ export function modificationPlanFromJson(raw: string): SandboxModificationPlan { const plan = JSON.parse(raw); return { sandbox: plan.sandbox, status: plan.status, applied: plan.applied, policy: plan.policy, changes: (plan.changes ?? []).map(plannedChangeFromJson), conflicts: plan.conflicts ?? [], warnings: plan.warnings ?? [], resizeStatus: plan.resize_status ?? [], }; } function plannedChangeFromJson(change: any): PlannedChange { if (change.kind === "secret") { return { kind: "secret", field: change.field, name: change.name, change: change.change, beforeRef: change.before_ref ?? undefined, afterRef: change.after_ref ?? undefined, disposition: change.disposition, allowHosts: change.allow_hosts ?? [], reason: change.reason ?? undefined, }; } return { kind: "config", field: change.field, change: change.change, before: change.before ?? undefined, after: change.after ?? undefined, disposition: change.disposition, reason: change.reason ?? undefined, }; }