# @sealant/sdk

The fluent public SDK for Sealant — **create a workspace, run a harness, replay the record.**

```ts
import { Sealant, opencode } from "@sealant/sdk";

const sealant = new Sealant({ baseUrl: "http://localhost:8080" });

const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: opencode(),
});

const run = await workspace.harness.run("Round invoice totals once, after applying the discount.");

await run.record.replay();
```

## Design

- **Plain-Promise facade over an Effect core.** The default export is ordinary `async`/`await`. The
  Effect-native core is reachable via the `@sealant/sdk/effect` subpath for consumers that are
  Effect end-to-end: the contract-derived client as a service, one operation effect per endpoint,
  and the typed contract errors on the failure channel (no squashing) —

  ```ts
  import { Effect } from "effect";
  import { getRunOp, resolveInternalConfig, sealantApiClientLayer } from "@sealant/sdk/effect";

  const layer = sealantApiClientLayer(resolveInternalConfig({ baseUrl: "http://localhost:8080" }));

  const status = getRunOp("run_123").pipe(
    Effect.map((run) => run.status),
    Effect.catchTag("RunNotFoundError", () => Effect.succeed("gone" as const)),
    Effect.provide(layer),
  );
  ```

- **Decoupled public types.** The types in [`src/types.ts`](src/types.ts) are hand-written and kept
  independent of the Effect-core and `@sealant/telemetry` internal shapes, so the public surface
  stays stable across internal change. The whole surface is typed now, including operations not yet
  implemented (those reject with `SealantNotImplementedError`).
- **Harness-neutral.** `opencode()`, `codex()`, `claudeCode()`, and `customHarness()` are thin
  client values describing how to invoke a harness one-shot.

## Deterministic exec

Run a command in the workspace with no agent in the loop — recorded into a run record like any other
process:

```ts
const check = await workspace.exec(["pnpm", "test"], { cwd: "/workspace/repo" });
check.exitCode; // the check datum — a NONZERO exit RESOLVES (that's the point)
check.stdout; // full stdout, decoded
check.run.record; // the durable evidence

// A causal proof is three execs with three recorded exit codes:
const base = await workspace.exec(["pnpm", "test"]); // fails
// ...apply the fix...
const head = await workspace.exec(["pnpm", "test"]); // passes
```

`exec()` rejects only when the execution machinery itself broke (workspace gone, transport dropped)
— i.e. when the exit code cannot be trusted. The underlying endpoint
(`POST /v1/workspaces/:id/exec`) accepts an ordered **list** of commands recorded as one check run;
the SDK surface starts with the single-command form.

### As a person's Linux user

`exec(argv, { user })` and `sessions.open(argv, { user })` start the process as a person's Linux
user (a login name or uid), on a workspace whose sealantd reports `exec.user`:

```ts
if (
  (await sealant.features()).processUserRoutes &&
  (await workspace.processUser()) === "supported"
) {
  await workspace.exec(["pnpm", "install"], { user: "m4lice000" });
}
```

- The daemon starts it as that passwd entry: uid, groups, `HOME`, `USER`, `LOGNAME`, `SHELL`, umask
  0002, a private `TMPDIR` and `XDG_RUNTIME_DIR`, the image's `/etc/sealant/person-env`, and none of
  the daemon's logins or `SEALANT_*` keys.
- Only a person Mend made: a uid in 40001–49999 whose primary group is `mend` (40000), never root.
  Anything else is refused with code `user-unsupported` and the reason in its message (the
  workspace's sealantd doesn't run processes as another user, the user is not in range, or it does
  not exist yet). Nothing is started. The daemon checks again itself: it starts a process only as
  one of its owner map's people or a person in Mend's reserved range (a uid in 40001–49999 whose
  primary group is 40000), and refuses root, root's group and anyone outside the range, so a person
  who edits `/etc/passwd` with `sudo` cannot run one as root or a system user.
- They go to their own routes (`POST /v1/workspaces/:id/exec-as-user`, `POST /v1/sessions/as-user`),
  so a control plane from before them answers `404` instead of running the process as root.
- The run records the user (`user` on the run resource), never the arguments.
- `workspace.processUser()` reads `supported`, `unsupported` or `unknown` from the image's sealantd;
  `launch.processUser` has it after `ready()`. The call itself asks the running daemon, whose answer
  decides.
- `users.bindPerson(userId, { id, uid, home })` binds a user's person once; a different binding, or
  a person id or uid another user holds, rejects (`409`).
  `create({ credentialsHome, sshAsOwner: true })` runs the workspace's SSH sessions, VS Code
  Remote-SSH included, as the owner's bound person: the create's owner map and `credentialsHome`
  must agree with the binding (`403` otherwise). No caller names the user. `workspace.sshAsRoot()`
  sets them back to root. A create with it is refused (`ssh-user-unsupported`) with nothing sent
  when the control plane does not report `workspaceSshUser`.
- `sealant.features()` reports what the control plane can do: `processUserRoutes`, `dotfilesApply`,
  `credentialsPartialPut`, `credentialsPiOpencode`, `captureOwnerMap` and `workspaceSshUser`. Detect
  them instead of reading the control plane's version.

## Typed record events

Timeline reads are discriminated by `kind` — switch on it and `data` narrows to the event's typed
payload (all 12 recorded kinds: process, io, file, network, runtime, and loss events):

```ts
for await (const entry of run.record.timeline()) {
  if (entry.kind === "networkSourceObserved") {
    entry.data.host; // typed — the raw material of a "sources the agent opened" trail
    entry.data.status;
  }
}
```

Forward compatibility is a case, not an error: kinds newer than your SDK version (and payloads that
fail their schema) arrive as `{ kind: "unknown", rawKind, data }` with everything preserved. Wire
conventions carry through: uint64 fields are decimal strings, protocol enums are numbers.

## Connected-account credentials

Attach the caller's connected Claude / Codex / GitHub accounts to a workspace so the harness
authenticates as that identity instead of running unauthenticated:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: claudeCode(),
  credentials: { claude: true, github: "bot-account" },
});
```

`true` means "my default account"; a string names a specific connected account. `profile` names a
profile whose bundled per-provider bindings apply first, and any explicit `claude`/`codex`/`github`
field wins over the profile's binding for that provider. Only account references cross the SDK
surface — secret material never does; the control plane resolves references to encrypted credentials
and injects them at launch.

## Workspace tools and services

Choose a supported operating-system family — `fedora` (the default), `arch`, `nix`, or `ubuntu` —
request portable package names, and opt into services that need runtime support rather than a
package install:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: codex(),
  os: "arch",
  packages: ["pnpm", "python", "uv", "mise", "github-cli", "lazygit", "bat"],
  services: { docker: true },
  credentials: { codex: true, github: true },
});
```

`services.docker` installs the Docker client in the image and starts a disposable, workspace-scoped
rootless daemon at launch. The workspace receives `DOCKER_HOST`; Sealant never mounts the host
Docker socket. On Kubernetes the daemon is a sidecar of a user-namespaced workspace Pod and the
operator has to enable it (`workspaces.docker.enabled`); an install that cannot serve it refuses
`create` with the stable code `workspace-docker-unsupported` (HTTP 422) — map that code to explain
the gap beside your Docker switch instead of surfacing a launch failure later. GitHub credentials
provide both `GH_TOKEN` and `GITHUB_TOKEN` to the workspace.

## The cloud metadata address

On a cloud host, 169.254.169.254 (and fd00:ec2::254 over IPv6 on AWS) hands out the instance's own
credentials. On the Docker runtime a workspace is refused it by default: a connection fails at once,
from the workspace and from every container its Docker service runs. Its users cannot remove the
refusal, and root gets no raw sockets to send packets past it. The refusal is in place before
`create` reports the workspace ready; steps the workspace runs at boot on its own may start slightly
earlier. Nothing else is touched. A workspace that genuinely needs the address opts in:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/infra",
  harness: codex(),
  network: { cloudMetadata: true },
});
```

A gVisor (`runsc`) workspace cannot be guarded, so it launches only with the opt-in. A control plane
older than the option rejects it.

## Workspace environment variables

Ordinary (non-secret) configuration set on the workspace at creation and inherited by every process
the platform starts inside it — the harness, later shells, exec'd commands, and their descendants:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: codex(),
  env: { APP_MODE: "review", FEATURE_FLAGS: "checkout,invoices" },
});
```

The contract, stated plainly:

- **Not for secrets.** Values are persisted verbatim in the durable workspace spec and are returned
  by workspace-details APIs to authorized clients for the life of that record. Use `credentials` for
  connected-account material. There is no partial support: secret-looking names — containing
  `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, or `APIKEY` (as a substring, so
  `TOKENIZER_PATH` counts), ending in `_KEY`, or exactly `KEY` — are rejected at create, because the
  workspace runtime's secret filter would silently drop them before any process could see them. A
  loud rejection beats a variable that never arrives.
- **Validated, client-side and server-side, with the same policy.** Names are
  `[A-Za-z_][A-Za-z0-9_]*` (max 128 chars); values are any UTF-8 up to 4 KiB (empty and multiline
  included, NUL excluded); at most 128 entries and 32 KiB total per workspace. Platform-owned names
  (`SEALANT_*`, `HOME`, `PATH`, `TERM`, `DOCKER_HOST`, proxy variables, loader/shell/
  runtime-injection controls like `LD_*`, `BASH_ENV`, `NODE_OPTIONS`, `PYTHONPATH`, and Git/SSH
  config controls) are rejected. The policy is exported (`parseWorkspaceEnv`,
  `findWorkspaceEnvReservedRule`, `formatWorkspaceEnvIssue`, `WORKSPACE_ENV_*` constants) so your
  own settings surface can validate with the platform's exact rules.
- **Fixed at creation.** A live workspace is never mutated; a platform-side restart reuses the
  stored spec. Caller values can never override platform controls or injected connected-account
  credentials.
- **No nested-container injection.** Docker Compose or `docker run` inside the workspace can use the
  values for interpolation, but child containers receive only what the Compose file or the command
  explicitly passes (`environment`, `env_file`, `-e`). Docker runtime only.

## Secret environment variables

The half of a real `.env` that `env` deliberately refuses — API keys, database URLs with passwords —
goes through the **transient secret channel**:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: codex(),
  env: { APP_MODE: "review" },
  secretEnv: {
    DATABASE_URL: "postgres://app:s3cret@db.internal/billing",
    STRIPE_API_KEY: "sk_live_…",
  },
});
```

What the platform guarantees for `secretEnv`, and how it differs from `env`:

- **Same grammar and size bounds** (`parseWorkspaceSecretEnv`, exported), and the same
  platform-owned names are reserved — but secret-shaped names are exactly what belongs here.
  Connected-account names (`GITHUB_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `GH_TOKEN`) stay reserved:
  attach those through `credentials`.
- **Never persisted in the clear.** The map rides the create request beside the spec, is sealed with
  the install's credential key on the build job, is decrypted by the worker just before launch, and
  the sealed row is cleared once the launch settles. It is never in the blueprint, the attempt
  snapshot, `WorkspaceDetails`, or any read API.
- **Never in `docker run` argv or container env.** The worker stages a `0600` file the workspace
  daemon reads once at boot and removes it the moment the workspace is ready. `docker inspect` shows
  a file path, not values.
- **Inherited by every process the platform starts in the workspace**, winning over `env` and
  container env for the same name — the harness, later shells, exec'd commands, Services.
- **Masked in captured output.** Every value seeds the daemon's redactor regardless of its name, so
  a `DATABASE_URL` a process echoes is recorded as `***REDACTED***`, like a token.
- **Fixed at creation.** A platform-side _restart_ of the workspace runs **without** secret env (the
  sealed copy is gone by design); create a new workspace to re-supply it. Docker runtime only;
  nested containers started by Compose or `docker run` inside the workspace still receive only what
  you explicitly pass.

Not covered, and worth saying plainly: a process that _deliberately_ writes a secret to a file in
the repository or to a mount is producing ordinary workspace state, and the redactor masks captured
I/O, not files.

## Capture-sourced workspaces

A `capture` source (sealantd ADR-0015) mounts nothing and clones nothing. The workspace daemon
registers with a session channel, fetches the worktree's head plan, materialises it onto the
executor's own disk, claims the lease, and ships captures back over the channel — so a session can
run where no host path exists (a Cloudflare sandbox, a MicroVM) and outlive any one executor:

```ts
const workspace = await sealant.workspaces.create({
  source: {
    kind: "capture",
    endpoint: "https://mend.example.com/session/s1",
    worktreeId: "wt_1",
    token: sessionToken, // the channel credential; secret
    harnessHome: "/workspace/harness-home",
  },
  harness: claudeCode(),
});
```

- `token` is lifted out of the blueprint and rides the request top level as `captureToken`. The
  control plane seals it beside `secretEnv` and delivers it through the same boot file, as
  `SEALANT_CAPTURE_TOKEN`; it is never in the spec, `WorkspaceDetails`, argv, or container env.
- The channel address and worktree id reach the daemon as `SEALANT_CAPTURE_ENDPOINT` and
  `SEALANT_CAPTURE_WORKTREE_ID`, with `SEALANT_WORKSPACE_SOURCE=capture`.
- `worktreeId` is optional. A standby executor launched ahead of any worktree omits it;
  `SEALANT_CAPTURE_WORKTREE_ID` stays unset and the daemon takes the worktree from the channel's
  plan answer.
- `harnessHome` is optional. It names an absolute, normalized directory on the executor's own
  filesystem. When a captured head contains a `harness/` subtree, cold materialization restores it
  there. The option does not create or relocate `HOME`, choose an agent state directory, or route
  harness writes. Configure the harness to write there and, for an empty chain, create the directory
  before the daemon watcher starts. The daemon then captures that existing directory and keeps using
  the same path after a standby claim or capture replan. Omitting the option keeps the legacy
  behavior and captures no harness subtree.
- `transport` is optional. Without it the daemon dials `endpoint` and every object URL over HTTPS
  with a certificate the public roots verify, and refuses to boot otherwise. Set
  `transport.plaintext: true` only when the executor and the channel share a private network;
  `transport.channelCaPem` and `transport.objectCaPem` name a private CA for each path. The control
  plane refuses a plain-HTTP endpoint beyond loopback that `plaintext` does not account for, and an
  endpoint outside the operator's `SEALANT_CAPTURE_ALLOWED_ENDPOINTS`.
- `harnessHome` is not a host mount. It cannot overlap the workspace working directory, the daemon
  control directory, or an extra mount target. No mount allowlist applies to the harness root. On
  Kubernetes the workspace root is an `emptyDir`; on Cloudflare the sandbox is kept alive while the
  lease is live and a stop sends SIGTERM so the daemon can flush.
- Harness files receive the existing capture policy. This option does not add automatic credential
  discovery or change which daemon credentials are excluded from captures.
- `ownerMap` is optional: who owns what the restore writes, for an executor where each person is a
  Linux user of their own (Mend's per-person layout). It reaches the daemon as
  `SEALANT_CAPTURE_OWNER_MAP`; without it everything is restored root's and the executor keeps
  no-new-privileges, as before. See "Restoring a capture per person" below.
- **No restart in place**: the credential is discarded once the launch settles, so `restart()` is
  refused; create a replacement workspace with a fresh token.

### Restoring a capture per person

```ts
const workspace = await sealant.workspaces.create({
  source: {
    kind: "capture",
    endpoint: "https://mend.example.com/session/s1",
    worktreeId: "wt_1",
    token: sessionToken,
    harnessHome: "/workspace/harness-home",
    ownerMap: {
      gid: 40000, // the mend group
      worktreeUid: 40012, // the change's owner
      people: [
        { id: "acct_a", uid: 40012 }, // <harnessHome>/people/acct_a/
        { id: "acct_b", uid: 40031 },
      ],
    },
  },
  harness: claudeCode(),
});
```

- Each listed person's saved directory, `<harnessHome>/people/<id>/`, comes back owned by their uid
  and `gid`: the directory itself `0710`, its `conversations/` group-readable and -writable, the
  rest at its recorded mode. A directory whose id is not listed (a removed member's) comes back
  root's.
- The worktree and its git directory are given to the group: the root owned by `worktreeUid` and
  `gid`, setgid, every entry's owner bits copied to the group (a `0644` file comes back `0664`).
- A map that names at least one person makes the executor a per-person one: its daemon does not set
  no-new-privileges, so every person's passwordless `sudo` works (`runtime.getCapabilities` reports
  `noNewPrivileges: false`). A map with no people keeps it.
- Checked by the SDK and again by the control plane: `gid` 40000, every uid in 40001–49999, ids that
  are one directory name (letters, digits, `.`, `_`, `-`, at most 128, not starting with `.` or
  `-`), no id and no uid twice, at most 256 people.
- Launched only on an image whose probe reports `restore.owner_map` from its sealantd
  (`personLayout`); on any other image, or one with no probe, the launch fails with
  `owner-map-unsupported` before anything starts. Refused at create on Cloudflare, and on
  Kubernetes, whose Pods run with `allowPrivilegeEscalation: false`, so no person's `sudo` could
  work there.
- The map is the executor's for its life: the daemon reads it at boot. A standby's claim
  (`capture.replan()`) and a recovery restore under the map it booted with. Pass
  `capture.replan({ expectedOwnerMap })` (or `null` for none) to have the claim refused
  (`owner-map-mismatch`, nothing re-planned) on an executor launched with another map;
  `capture.status()` reports `ownerMap: true` for an executor that booted under one.
- It is the workspace creator's choice of privilege: whoever can create the workspace can set it,
  for that workspace, and turns off no-new-privileges there. Only this field sets it. Every capture
  launch sets `SEALANT_CAPTURE_OWNER_MAP` last, empty without a map, so neither `env`, `secretEnv`,
  a cluster ConfigMap nor an image `ENV` can, and an environment variable name holding `=` is
  refused.

## Dotfiles and shell

Bring your own environment: a login shell and dotfiles applied before the workspace accepts work.

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: codex(),
  shell: "zsh",
  dotfiles: {
    // A repo the platform clones and applies (chezmoi/stow layouts detected, else copied):
    repository: { url: "github.com/acme/dotfiles" },
    // And/or caller-resolved archives — a checkout cloned host-side with your own ssh identity,
    // or a scanned selection of home files, sent as gzipped tars (max 4, ~4MB decoded each):
    archives: [{ data: tarGzBase64, manager: "copy", bootstrap: false }],
  },
});
```

`shell` installs the shell package and switches the login shell, so `.zshrc`/`.fishrc` actually take
effect. The `repository` applies first, then each archive in order, so local selections override
repo files. A failing apply fails the launch loudly rather than handing the agent a half-prepared
home. Managed OS families only — with `baseImage`, `dotfiles` and non-bash `shell` are rejected
client-side with the platform's reasoning.

## Custom base images

Instead of a managed OS family, a workspace image can be built from any image reference you already
trust:

```ts
const workspace = await sealant.workspaces.create({
  repository: "github.com/acme/billing-service",
  harness: codex(),
  baseImage: "node:22-bookworm",
});
```

Distro package installs are skipped entirely — the build overlays only the `sealantd` supervisor
(PID 1), the harness CLIs (installed with `npm`), and a fully static `socat` (the control-socket
relay), all copied in as static binaries. This is the **base-image contract**, checked at build time
with readable failures:

- **Any Linux base, `amd64`/`arm64`**, with a **POSIX shell** at `/bin/sh`. Shells beyond that are
  not assumed: the workspace login shell is `/bin/sh`, and `defaultShell` selection is not supported
  with `baseImage`.
- **Node.js + npm** at or above the harness CLIs' floor (the CLIs are installed with
  `npm install -g` and run on the base's node).
- **git**, for clone-, mount- and capture-sourced workspaces.

`packages` still works: names pass through **verbatim** (no portable-name resolution) to the base's
own package manager — `apt`, `apk`, `dnf`, or `pacman`, autodetected — and the build fails with a
readable error when the base has none. Dotfiles are not supported with `baseImage`. `baseImage` and
`os` are mutually exclusive.

## Inference on connected accounts

Run short, tool-calling inference loops on the caller's own subscription — server-side, through the
official agent SDKs (never raw model-API calls on stored credentials), with the tool loop executed
on YOUR side:

```ts
let response = await sealant.inference.respond({
  prompt: "Compile a review brief from this run record.",
  tools: [
    {
      name: "get_timeline",
      inputSchema: { type: "object", properties: { runId: { type: "string" } } },
    },
  ],
  responseFormat: { type: "json", schema: briefSchema },
  credentials: { claude: true },
});

while (response.turn.type === "toolCalls") {
  const toolResults = await Promise.all(
    response.turn.calls.map(async (call) => ({
      toolCallId: call.toolCallId,
      content: await runTool(call.name, call.input),
    })),
  );
  response = await sealant.inference.respond({ sessionId: response.sessionId, toolResults });
}

response.turn.json; // schema-constrained result
```

Only account references cross the surface — the control plane resolves and decrypts server-side and
invokes the official Claude Agent SDK with the account's own subscription token. Claude accounts
only for now (Codex inference is a stated follow-up); sessions are held in memory by the control
plane and expire after a few idle minutes, so handle a 404 on continuation by restarting the
exchange.

## Status

The core loop is real: `workspaces.create()`/`get()`/`list()`, `ready()`, blocking `harness.run()`
and non-blocking `harness.start()` (run execution happens server-side; the SDK is a thin HTTP
client), deterministic `workspace.exec()`, `inference.respond()` (connected-account inference with a
caller-executed tool loop), `runs.get()`, and the record read surface — `replay()`, `timeline()`
(typed, kind-discriminated entries), `scrollback()`, `commands()`, `transcript()`, `stream()`
(poll-backed), `loss()`, `summary()`, plus captured `changes` (files + diff) settled by
`run()`/`wait()`. The Effect-native core ships at `@sealant/sdk/effect`.

Still typed stubs pending their read models / endpoints: `artifacts.get()` and the time-travel folds
`fileTreeAt()`/`processTreeAt()` (Phase 1), and `harness.session()` + workspace lifecycle
`stop()`/`restart()`/`expire()` (Phase 3).
