import { z } from "zod"; import { BootProgressSchema } from "../events/system-events.js"; import { AnnounceStateSchema } from "./platform-link.js"; import { StatePlanSchema, StateStatusSchema } from "./state-plan.js"; import { AutoUpdateSchema, UpdateOutcomeSchema, WithdrawnReleaseSchema } from "./updates.js"; // version is this build's baked-in version; latest/updateAvailable come from comparing it to the published stable // release. // Whether a runtime can serve a turn, probed off the turn path. "unknown" reads as available-but-unverified, never as // unavailable. export const AdapterHealthSchema = z.object({ state: z .enum(["ready", "unavailable", "unknown"]) .describe( "Whether this runtime can serve a turn. Unknown is a real answer rather than a soft no: a check that could not run must not grey out a provider you can in fact use.", ), // Why it cannot serve and what to do about it; absent when ready. detail: z.string().optional().describe("Why it cannot, and what to do about it. Absent when it can."), checkedAt: z.number().describe("When it was last checked, in milliseconds."), }); export type AdapterHealthReport = z.infer; // An update already downloaded and built, waiting for the restart that applies it. Advisory only: the swap re-derives // these facts and refuses the fast path if they drifted. export const StagedUpdateSchema = z.object({ // What the staged build reports about itself; absent means ready but unnamed, not that nothing is ready. version: z.string().optional().describe("What the downloaded build says it is. Absent means ready but unnamed, never that nothing is ready."), // Channel it was staged from; not necessarily the one this sandbox follows. channel: z .string() .describe( "Which channel it was taken from. Not necessarily the one this sandbox follows: downloading a beta build is not the same as moving onto beta.", ), // Epoch ms the download finished; used to check this is still the update being offered. at: z.number().describe("When the download finished, in milliseconds, which answers whether this is still the update being offered."), // The staged build's state pre-flight (the daemon's state-plan.ts, run by `ic sandbox prepare` over read-only mounts // of this sandbox's volumes): what its first boot converts, before anyone accepts the update. // Embedded verbatim by the ic that staged it (StatePlanSchema); a marker an older ic wrote held only `ok` with // `downgrade` and `steps`, or `failures`, so everything but `ok` stays optional here. plan: StatePlanSchema.partial() .extend({ ok: StatePlanSchema.shape.ok }) .optional() .describe("What the downloaded build's first boot would convert in this sandbox's stored files. Absent when no plan could be had, which says nothing either way."), }); export type StagedUpdate = z.infer; // A download of the next update running on the machine right now, with how far it has got. `ic sandbox prepare` writes // it onto /history while it works (update-preparing.json), refreshing `at` every few seconds, and removes it when it // ends, whichever way. Advisory like the staged marker: the daemon passes it on only while `at` is fresh, so a download // whose ic was killed is never drawn as one still running. // It names no version: only the finished image can say which it is, and asking it costs a container; the card already // knows the release it is downloading as `latest`. export const PreparingUpdateSchema = z.object({ channel: z.string().describe("Which channel it is being taken from."), startedAt: z.number().describe("When the download began, in milliseconds."), at: z.number().describe("When the machine last said it is still working on it, in milliseconds."), // A string rather than an enum, as UpdateOutcome's `verb` is: a newer ic's step must not make an older daemon drop // the whole marker. `download`, `build` and `check` are the ones this build names. phase: z .string() .describe( "What it is doing: download (pulling the new image), build (building this sandbox's environment on it), or check (checking this sandbox's stored files against it).", ), percent: z.number().min(0).max(100).optional().describe("How far through the download it is, from 0 to 100. Absent for a step with no measure of its own."), }); export type PreparingUpdate = z.infer; // What answers (state/sandbox-build.ts RunningSandbox): the build this container runs and when its daemon started. export const RunningSandboxSchema = z.object({ build: z .object({ base: z.string().describe("The base image as the machine that runs it named it; empty when it said nothing."), baseDigest: z.string().optional().describe("That base's registry digest, when the machine read one."), overlay: z.string().optional().describe("The hash of the environment recipe built on the base. Absent on a stock image."), }) .describe("Which build this container is: its base image and the environment built on it."), bootedAt: z.number().describe("When the daemon answering started, in milliseconds. A container swapped in is a new boot."), }); export const InfoSchema = z.object({ name: z.string().optional().describe("What this sandbox is called."), image: z.string().optional().describe("The image it is running."), version: z.string().optional().describe("The version of that image."), latest: z.string().optional().describe("The newest published version on its channel."), updateAvailable: z.boolean().optional().describe("Whether those two differ."), // Keyed by AgentCapabilities.runtime; absent until the first sweep, reading as every entry unknown. runtimes: z .record(z.string(), AdapterHealthSchema) .optional() .describe( "Which agent runtimes can serve a turn right now, keyed by runtime. Absent until the first check has run, which reads the same as every entry being unknown.", ), // Channel and previousImage are set by the host swap script; previousImage absent means no rollback. channel: z.string().optional().describe("Which release channel this sandbox follows."), previousImage: z .string() .optional() .describe("The image the last update replaced, which is what a rollback would return to. Absent means there is nothing to go back to."), // User-facing release notes from version to latest, newest first; absent or empty means nothing to say. updateNotes: z .array(z.string()) .optional() .describe( "What is in the update, in the words of the people it is for, newest first. Absent or empty whenever there is nothing worth saying, which reads on screen exactly as it did before there were notes at all.", ), // How many further notes exist beyond the ones sent; absent or zero means updateNotes has them all. moreUpdateNotes: z .number() .optional() .describe( "How many further notes there are beyond the ones sent, for a sandbox left alone a long time. Absent or zero means you have all of them.", ), // Breaking-change lines from every release in the gap, uncapped; absent for updates that break nothing. breakingNotes: z .array(z.string()) .optional() .describe( "What the update takes away, uncapped, because a warning that fell off a shortened list is a breaking update taken unwarned. Absent for the overwhelming majority, which break nothing.", ), // Update already downloaded and built, waiting for the restart that applies it; absent when nothing is staged. staged: StagedUpdateSchema.optional().describe( "An update already downloaded and built on the machine running this container, waiting only for the restart that applies it. That restart is seconds, where an unprepared update is minutes, which is a different decision entirely. Absent when nothing is waiting.", ), // A download running on the host right now (update-preparing.json, written by ic while it prepares); absent when // none is, and when the host stopped refreshing it (PREPARING_FRESH_MS in the daemon). preparing: PreparingUpdateSchema.optional().describe( "A download of the next update running on the machine right now, and how far it has got. Absent when nothing is downloading, or when the machine stopped saying it is.", ), // What the host last did about this sandbox's version (update-outcome.json on /history, written by ic); absent // when no host has said, which is every hosted sandbox and every host older than the file. lastUpdate: UpdateOutcomeSchema.optional().describe( "What the machine running this sandbox last did about its version: an update that took, one it gave up on and why, and until when the previous version stays ready. Absent when that machine has never said.", ), // The running version was taken back after it shipped; absent for every version still standing. withdrawn: WithdrawnReleaseSchema.optional().describe( "Set when the version this sandbox runs was withdrawn after it shipped, which is the moment to go back to the one before it. Absent for every version still standing.", ), // The owner's "not this one" (POST /system/update/skip); updateAvailable reads false while `latest` is it. skippedVersion: z .string() .optional() .describe("A release the owner chose to skip. While it is the newest, no update is offered; a newer one is. Absent when nothing is skipped."), // Taking a downloaded update by itself at a quiet moment (system/updates/auto-update.ts); absent where the daemon // cannot (a hosted machine, a checkout-built base, a daemon outside a container) and from a daemon older than it. autoUpdate: AutoUpdateSchema.optional().describe( "Whether and when this sandbox takes a downloaded update by itself, and what it is waiting on. Absent where it cannot: a hosted sandbox, one built from a checkout, or one older than automatic updates.", ), // What answers now, for a page telling a swap it pressed from one still running; absent from a daemon older than it. running: RunningSandboxSchema.optional().describe( "Which build answers and when its daemon started, so a page that asked for a rebuild, an update or a rollback can tell when the new container is the one answering. Absent from a sandbox older than this field.", ), }); export type Info = z.infer; // What the daemon could not read in its own `.intentic/` state files, and in the files on its volume that // REPORTED_VOLUME_FILES names. Reported per file, only for files listed in REPORTED_MANIFEST_PATHS or // REPORTED_VOLUME_FILES (workspace-state.ts). `kind`: // unreadable: whole file ignored, everything in it at default. // unknownKey: only that key ignored; `suggestion` may name what it should be. // invalidEntry: one list entry skipped, rest of the file stands. export const ManifestProblemSchema = z.object({ kind: z .enum(["unreadable", "unknownKey", "invalidEntry"]) .describe( "What to do about it. Unreadable means the whole file is being ignored and everything in it is at its default. An unknown key means only that key is ignored. An invalid entry means one item of a list was skipped and the rest is fine.", ), // Why, as a word a reader matches on; `detail` is prose for a person and never parsed. Absent from an older daemon. reason: z .enum(["io", "not-json", "conversion-failed", "rejected"]) .optional() .describe( "Why it could not be read: the file could not be opened (io), it is not JSON, a conversion to this version's shape failed, or its contents are not what this version expects (rejected). A rejected file after a newer version ran is usually that version's, not a broken one.", ), detail: z.string().describe("What exactly was wrong, as one sentence and nothing else. Never the remedy: that is `fix`."), suggestion: z.string().optional().describe("The name it was probably meant to be, when one is close enough to guess honestly."), // Set only when the fix isn't "open the file and correct it", e.g. a version skew where editing would be wrong. fix: z .string() .optional() .describe("What to do about it, when that is something other than 'correct the file'. Absent whenever the file itself is the thing to edit."), }); export type ManifestProblem = z.infer; // Workspace-relative path and everything currently wrong with that file; a clean file is absent from the list, not // present and empty. export const ManifestProblemReportSchema = z.object({ path: z .string() .describe( "The file, as a workspace path, or as its absolute path on the daemon's own history volume for one kept there (the conversation registry). The file is the unit somebody fixes, which is why problems are grouped by it.", ), problems: z .array(ManifestProblemSchema) .describe("Everything currently wrong with it. A file with nothing wrong is absent rather than present and empty."), }); export type ManifestProblemReport = z.infer; export const ManifestProblemsSchema = z.array(ManifestProblemReportSchema); // The one repair a button may perform: drop a stray top-level key, or rename it to what `suggestion` named. The other // two problem kinds are refused: // unreadable: nothing to act on but bytes; the file may already be correct (version skew). // invalidEntry: dropping it would remove real configuration, not an inert key. export const ManifestRepairSchema = z.object({ path: z .string() .describe( "The file to repair, as the workspace path the problem was reported under. Only the handful of manifests a person hand-edits can be named; anything else is refused.", ), key: z.string().describe("The stray top-level key, exactly as it was reported. Absent from the file already means there is nothing to do."), to: z .string() .optional() .describe( "Rename the key to this instead of removing it, carrying its value across. Absent means remove it. Naming a key that is already in the file is refused rather than silently overwriting what is there.", ), }); export type ManifestRepair = z.infer; // Steady-state browser credential (system.session), exchanged for a verified Google ID token or the platform's identity // assertion, so sign-in UI appears only once. expiresAt is epoch ms; renew ahead of it without parsing the token. export const DaemonSessionSchema = z.object({ token: z.string().describe("The credential every other call carries. Present it as a bearer token."), expiresAt: z.number().describe("When it stops working, in milliseconds, so a caller can renew ahead of it without reading the token."), email: z.string().describe("Who the sandbox verified you as."), }); export type DaemonSession = z.infer; // Passkeys registered with one sandbox, and the owner's rule that a passkey is the only proof that opens it. The // daemon is the WebAuthn relying party; these are the shapes its /system/passkeys routes and the 428 step-up carry. // How a session's identity was proven. A session carries the set it was minted from; the require-passkey policy reads // it on every request, so a session minted before the switch stops working on its next call. `email` is the platform's // signed word that the person controls the address (policy/identity-assertion.ts), accepted only where the owner opted // that person in, and on hosted machines. `host` is the machine that runs the sandbox: a session `ic sandbox session` // minted through `docker exec` for the bound owner, so the desktop app and Intentic OS reach their own sandboxes with no // network (the daemon's auth/host/host-session.ts). It is never a sign-in a browser can offer, so no daemon lists it in // `signIn.proofs`. export const ProofMethodSchema = z.enum(["google", "ticket", "passkey", "recovery", "email", "host"]); export type ProofMethod = z.infer; // The `reason` a daemon's refusal of an identity assertion carries beside its `error`, so the editor can say what to do: // a 403 for a person this sandbox has not opted in to email sign-in (the owner turns it on for them), a 401 when the // daemon has not yet learned the platform's identity key (it has not reached the platform; worth a retry). export const EMAIL_SIGN_IN_OFF = "email-sign-in-off"; export const PLATFORM_KEY_UNKNOWN = "platform-key-unknown"; export const PasskeySummarySchema = z.object({ id: z.string().describe("The credential id the authenticator chose, base64url."), email: z.string().describe("Whose passkey this is; the owner's list carries every member's, a member's only their own."), label: z.string().describe("The name given at registration, or the daemon's default."), rpId: z.string().describe("The editor host this passkey is bound to; a passkey answers only from that origin."), createdAt: z.number().describe("Epoch ms of registration."), lastUsedAt: z.number().optional().describe("Epoch ms of the last sign-in it answered; absent means never."), backedUp: z.boolean().describe("Whether the authenticator syncs this passkey (a phone's keychain) or holds the only copy (a hardware key)."), }); export type PasskeySummary = z.infer; export const PasskeysListSchema = z.object({ passkeys: z.array(PasskeySummarySchema), required: z.boolean().describe("Whether a passkey is the only proof that opens this sandbox; owner-set."), recovery: z .object({ remaining: z.number() }) .optional() .describe("Owner only, while required: how many one-time recovery codes are still unspent."), }); export type PasskeysList = z.infer; export const PasskeyPolicySchema = z.object({ required: z.boolean() }); export type PasskeyPolicy = z.infer; // POST /system/passkeys/policy: switching the rule on answers with the recovery codes, off with none. export const PasskeyPolicyAnswerSchema = PasskeyPolicySchema.extend({ codes: z.array(z.string()).optional() }); export type PasskeyPolicyAnswer = z.infer; // Shown exactly once: the daemon keeps only their hashes. export const RecoveryCodesSchema = z.object({ codes: z.array(z.string()) }); export type RecoveryCodes = z.infer; export const PasskeyRecoverRequestSchema = z.object({ code: z.string().min(1) }); // The 428 body: the caller is who they say, but this sandbox requires a passkey and the proof presented has none. // `enrolled` says whether they hold one to answer with, or must add their first before anything else is served. export const PasskeyRequiredSchema = z.object({ error: z.string(), requires: z.literal("passkey"), enrolled: z.boolean(), }); export type PasskeyRequired = z.infer; // The two WebAuthn responses as the browser serialises them (`PublicKeyCredential.toJSON()`); every binary field is // base64url. The daemon parses the body with these before any cryptography runs. const Base64Url = z.string().regex(/^[A-Za-z0-9_-]+$/, "base64url"); export const RegistrationResponseSchema = z.object({ id: Base64Url, rawId: Base64Url, type: z.literal("public-key"), response: z.object({ clientDataJSON: Base64Url, attestationObject: Base64Url, transports: z.array(z.string()).optional(), }), authenticatorAttachment: z.string().optional(), clientExtensionResults: z.record(z.string(), z.unknown()).optional(), }); export type RegistrationResponse = z.infer; export const AuthenticationResponseSchema = z.object({ id: Base64Url, rawId: Base64Url, type: z.literal("public-key"), response: z.object({ clientDataJSON: Base64Url, authenticatorData: Base64Url, signature: Base64Url, userHandle: Base64Url.optional(), }), authenticatorAttachment: z.string().optional(), clientExtensionResults: z.record(z.string(), z.unknown()).optional(), }); export type AuthenticationResponse = z.infer; export const PasskeyRegisterRequestSchema = z.object({ response: RegistrationResponseSchema, label: z.string().optional(), }); export const PasskeyAssertRequestSchema = z.object({ response: AuthenticationResponseSchema }); // The options the daemon hands the browser, in the JSON form `PublicKeyCredential.parseCreationOptionsFromJSON` and // `parseRequestOptionsFromJSON` take. Typed rather than parsed: only the daemon writes them. export interface CredentialDescriptorJSON { readonly type: "public-key"; readonly id: string; readonly transports?: readonly string[]; } export interface RegistrationOptionsJSON { readonly rp: { readonly id: string; readonly name: string }; readonly user: { readonly id: string; readonly name: string; readonly displayName: string }; readonly challenge: string; readonly pubKeyCredParams: readonly { readonly type: "public-key"; readonly alg: number }[]; readonly timeout: number; readonly attestation: "none"; readonly excludeCredentials: readonly CredentialDescriptorJSON[]; readonly authenticatorSelection: { readonly residentKey: "required"; readonly requireResidentKey: true; readonly userVerification: "required" }; } export interface AuthenticationOptionsJSON { readonly rpId: string; readonly challenge: string; readonly timeout: number; readonly userVerification: "required"; readonly allowCredentials: readonly CredentialDescriptorJSON[]; // Whether any passkey is registered for this origin at all, so a sign-in screen can offer the button only when // pressing it can succeed. readonly available: boolean; } // The two interfaces above read off the wire with the one check a body needs before the browser's own parser takes it // (`parseCreationOptionsFromJSON` rejects the rest): an object with a challenge. Typed, not parsed field by field, since // only the daemon writes them. const WithChallengeSchema = z.object({ challenge: z.string() }); const optionsWithChallenge = () => z.custom((value) => WithChallengeSchema.safeParse(value).success); export const RegistrationOptionsSchema = optionsWithChallenge(); export const AuthenticationOptionsSchema = optionsWithChallenge(); // POST /system/passkeys/register: the passkey as listed, and the session upgraded on the spot when the bearer that // registered held no passkey yet (the same proof, now carrying one). export const PasskeyRegisteredSchema = z.object({ passkey: PasskeySummarySchema, session: DaemonSessionSchema.optional(), }); export type PasskeyRegistered = z.infer; // POST /system/session/recover: the session the recovery code bought, and how many codes are left unspent. export const SessionRecoveredSchema = DaemonSessionSchema.extend({ remaining: z.number() }); export type SessionRecovered = z.infer; // Whether this sandbox's public address answers, probed by the box itself; the same shape as `announce` beside it, so // `ic sandbox doctor` and the setup wizard can name whichever link broke. export const ReachStateSchema = z.object({ // off: nothing to probe; checking: no verdict yet; reachable: its own public address answered with our id. state: z.enum(["off", "checking", "reachable", "unreachable"]), // Why, for "unreachable", already in the user's terms since the wizard renders it verbatim. detail: z.string().optional(), // False once the give-up window is spent; only a restart retries after that. retrying: z.boolean().optional(), at: z.number().optional(), }); export type ReachState = z.infer; // Which build answers, so "is the fix live?" is one read. export const BuildStampSchema = z.object({ version: z.string(), commit: z.string().optional(), builtAt: z.string().optional(), }); // GET /health: the "is a daemon there" probe every flow uses (launch scripts, /setup, the browser's loopback probe, // the platform's hosted gate, the onboarding lane), served raw before any authentication. `ic` reads it too, in Rust // (_sandbox/ic/src/health.rs), which cannot import this and reads only `boot`, `state` and `announce` loosely. export const DaemonHealthSchema = z.object({ ok: z.literal(true), // The sandbox id, so a loopback probe can confirm it reached this daemon and not an unrelated process. Absent on a // daemon with no connect token. sandboxId: z.string().optional(), // Which posture answers (the daemon's platform/profile.ts); needed before any authenticated read. profile: z.string(), // Tells "not answering" from "still converging". boot: BootProgressSchema, // Whether an update's state conversions are still uncommitted; the host rolls the update back when they stay open. state: StateStatusSchema, // Whether this daemon reached the platform. announce: AnnounceStateSchema, reach: ReachStateSchema, // How the world reaches here: a dialed tunnel, or loopback only. reachedBy: z.enum(["tunnel", "loopback"]), build: BuildStampSchema, atRestEncryption: z.object({ active: z.boolean(), stores: z.array(z.string()) }), // Which proofs this daemon takes to open a session, read before any sign-in since the route list arrives only after // one. Absent on a daemon from before email sign-in: Google, and the owner ticket on a hosted machine. signIn: z.object({ proofs: z.array(ProofMethodSchema) }).optional(), }); export type DaemonHealth = z.infer; // The same answer as a reader outside the daemon takes it: from a daemon of any age, so every field may be missing, // and one of an unexpected shape reads as missing rather than failing the whole answer. A daemon older than the boot // report said `ready` at the top level instead. export const DaemonHealthReadingSchema = z.object({ ok: z.boolean().optional().catch(undefined), ready: z.boolean().optional().catch(undefined), sandboxId: z.string().optional().catch(undefined), profile: z.string().optional().catch(undefined), boot: z.object({ ready: z.boolean().optional().catch(undefined) }).optional().catch(undefined), state: z.object({ journal: z.string().optional().catch(undefined) }).optional().catch(undefined), announce: AnnounceStateSchema.optional().catch(undefined), reach: ReachStateSchema.optional().catch(undefined), reachedBy: z.string().optional().catch(undefined), build: BuildStampSchema.optional().catch(undefined), atRestEncryption: z.object({ active: z.boolean(), stores: z.array(z.string()) }).optional().catch(undefined), // Proof names this build may not know are kept as text, so a newer daemon's list still reads. signIn: z .object({ proofs: z.array(z.string()).catch([]) }) .optional() .catch(undefined), }); export type DaemonHealthReading = z.infer;