@sealant/sdk 0.18.1 → 0.19.0

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/README.md CHANGED
@@ -124,6 +124,86 @@ const workspace = await sealant.workspaces.create({
124
124
  rootless daemon at launch. The workspace receives `DOCKER_HOST`; Sealant never mounts the host
125
125
  Docker socket. GitHub credentials provide both `GH_TOKEN` and `GITHUB_TOKEN` to the workspace.
126
126
 
127
+ ## Workspace environment variables
128
+
129
+ Ordinary (non-secret) configuration set on the workspace at creation and inherited by every process
130
+ the platform starts inside it — the harness, later shells, exec'd commands, and their descendants:
131
+
132
+ ```ts
133
+ const workspace = await sealant.workspaces.create({
134
+ repository: "github.com/acme/billing-service",
135
+ harness: codex(),
136
+ env: { APP_MODE: "review", FEATURE_FLAGS: "checkout,invoices" },
137
+ });
138
+ ```
139
+
140
+ The contract, stated plainly:
141
+
142
+ - **Not for secrets.** Values are persisted verbatim in the durable workspace spec and are returned
143
+ by workspace-details APIs to authorized clients for the life of that record. Use `credentials` for
144
+ connected-account material. There is no partial support: secret-looking names — containing
145
+ `TOKEN`, `SECRET`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, or `APIKEY` (as a substring, so
146
+ `TOKENIZER_PATH` counts), ending in `_KEY`, or exactly `KEY` — are rejected at create, because the
147
+ workspace runtime's secret filter would silently drop them before any process could see them. A
148
+ loud rejection beats a variable that never arrives.
149
+ - **Validated, client-side and server-side, with the same policy.** Names are
150
+ `[A-Za-z_][A-Za-z0-9_]*` (max 128 chars); values are any UTF-8 up to 4 KiB (empty and multiline
151
+ included, NUL excluded); at most 128 entries and 32 KiB total per workspace. Platform-owned names
152
+ (`SEALANT_*`, `HOME`, `PATH`, `TERM`, `DOCKER_HOST`, proxy variables, loader/shell/
153
+ runtime-injection controls like `LD_*`, `BASH_ENV`, `NODE_OPTIONS`, `PYTHONPATH`, and Git/SSH
154
+ config controls) are rejected. The policy is exported (`parseWorkspaceEnv`,
155
+ `findWorkspaceEnvReservedRule`, `formatWorkspaceEnvIssue`, `WORKSPACE_ENV_*` constants) so your
156
+ own settings surface can validate with the platform's exact rules.
157
+ - **Fixed at creation.** A live workspace is never mutated; a platform-side restart reuses the
158
+ stored spec. Caller values can never override platform controls or injected connected-account
159
+ credentials.
160
+ - **No nested-container injection.** Docker Compose or `docker run` inside the workspace can use the
161
+ values for interpolation, but child containers receive only what the Compose file or the command
162
+ explicitly passes (`environment`, `env_file`, `-e`). Docker runtime only.
163
+
164
+ ## Secret environment variables
165
+
166
+ The half of a real `.env` that `env` deliberately refuses — API keys, database URLs with passwords —
167
+ goes through the **transient secret channel**:
168
+
169
+ ```ts
170
+ const workspace = await sealant.workspaces.create({
171
+ repository: "github.com/acme/billing-service",
172
+ harness: codex(),
173
+ env: { APP_MODE: "review" },
174
+ secretEnv: {
175
+ DATABASE_URL: "postgres://app:s3cret@db.internal/billing",
176
+ STRIPE_API_KEY: "sk_live_…",
177
+ },
178
+ });
179
+ ```
180
+
181
+ What the platform guarantees for `secretEnv`, and how it differs from `env`:
182
+
183
+ - **Same grammar and size bounds** (`parseWorkspaceSecretEnv`, exported), and the same
184
+ platform-owned names are reserved — but secret-shaped names are exactly what belongs here.
185
+ Connected-account names (`GITHUB_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, `GH_TOKEN`) stay reserved:
186
+ attach those through `credentials`.
187
+ - **Never persisted in the clear.** The map rides the create request beside the spec, is sealed with
188
+ the install's credential key on the build job, is decrypted by the worker just before launch, and
189
+ the sealed row is cleared once the launch settles. It is never in the blueprint, the attempt
190
+ snapshot, `WorkspaceDetails`, or any read API.
191
+ - **Never in `docker run` argv or container env.** The worker stages a `0600` file the workspace
192
+ daemon reads once at boot and removes it the moment the workspace is ready. `docker inspect` shows
193
+ a file path, not values.
194
+ - **Inherited by every process the platform starts in the workspace**, winning over `env` and
195
+ container env for the same name — the harness, later shells, exec'd commands, Services.
196
+ - **Masked in captured output.** Every value seeds the daemon's redactor regardless of its name, so
197
+ a `DATABASE_URL` a process echoes is recorded as `***REDACTED***`, like a token.
198
+ - **Fixed at creation.** A platform-side _restart_ of the workspace runs **without** secret env (the
199
+ sealed copy is gone by design); create a new workspace to re-supply it. Docker runtime only;
200
+ nested containers started by Compose or `docker run` inside the workspace still receive only what
201
+ you explicitly pass.
202
+
203
+ Not covered, and worth saying plainly: a process that _deliberately_ writes a secret to a file in
204
+ the repository or to a mount is producing ordinary workspace state, and the redactor masks captured
205
+ I/O, not files.
206
+
127
207
  ## Dotfiles and shell
128
208
 
129
209
  Bring your own environment: a login shell and dotfiles applied before the workspace accepts work.
@@ -1063,6 +1063,9 @@ declare const buildControlPlaneClient: (config: SealantInternalConfig) => Effect
1063
1063
  readonly github?: string | undefined;
1064
1064
  } | undefined;
1065
1065
  readonly spec: unknown;
1066
+ readonly secretEnv?: {
1067
+ readonly [x: string]: string;
1068
+ } | undefined;
1066
1069
  readonly ttlSeconds?: number | undefined;
1067
1070
  };
1068
1071
  readonly responseMode?: Mode;
@@ -2381,6 +2384,9 @@ declare const SealantApiClient_base: Context.ServiceClass<SealantApiClient, "@se
2381
2384
  readonly github?: string | undefined;
2382
2385
  } | undefined;
2383
2386
  readonly spec: unknown;
2387
+ readonly secretEnv?: {
2388
+ readonly [x: string]: string;
2389
+ } | undefined;
2384
2390
  readonly ttlSeconds?: number | undefined;
2385
2391
  };
2386
2392
  readonly responseMode?: Mode;
@@ -25,6 +25,9 @@ export declare const createWorkspaceOp: (payload: {
25
25
  readonly github?: string | undefined;
26
26
  } | undefined;
27
27
  readonly spec: unknown;
28
+ readonly secretEnv?: {
29
+ readonly [x: string]: string;
30
+ } | undefined;
28
31
  readonly ttlSeconds?: number | undefined;
29
32
  }, idempotencyKey?: string | undefined) => Effect.Effect<{
30
33
  readonly workspaceId: string;
package/dist/index.d.ts CHANGED
@@ -18,3 +18,5 @@ export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode } from "./harness.js";
19
19
  export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
20
  export type * from "./types.js";
21
+ export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
22
+ export type { WorkspaceEnvIssue, WorkspaceEnvParseResult, WorkspaceEnvReservedRule, } from "@sealant/api-contracts/workspace-environment";
package/dist/index.js CHANGED
@@ -17,3 +17,6 @@
17
17
  export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode } from "./harness.js";
19
19
  export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
+ // The workspace environment policy (`CreateOptions.env` validation) is public API: downstream
21
+ // products validate at their own boundaries with the exact rules the platform enforces.
22
+ export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
@@ -26,6 +26,9 @@ export declare const buildCreateWorkspaceRequest: (options: CreateOptions, confi
26
26
  readonly github?: string | undefined;
27
27
  } | undefined;
28
28
  readonly spec: unknown;
29
+ readonly secretEnv?: {
30
+ readonly [x: string]: string;
31
+ } | undefined;
29
32
  readonly ttlSeconds?: number | undefined;
30
33
  };
31
34
  };
@@ -11,6 +11,7 @@
11
11
  * plane resolves those account references server-side (never secret material over this path).
12
12
  */
13
13
  import { randomUUID } from "node:crypto";
14
+ import { formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, } from "@sealant/api-contracts/workspace-environment";
14
15
  import { SealantError } from "../errors.js";
15
16
  import { mapWorkspaceCredentials } from "./credentials.js";
16
17
  import { parseTtlSeconds } from "./duration.js";
@@ -85,6 +86,44 @@ export const buildCreateWorkspaceRequest = (options, config) => {
85
86
  };
86
87
  const dotfilesRepository = options.dotfiles?.repository;
87
88
  const dotfilesArchives = options.dotfiles?.archives ?? [];
89
+ // Client-side rejection with the exact policy the control plane re-applies on parse: same
90
+ // module, same messages, so a bad name fails here instead of as an opaque 400.
91
+ const envResult = options.env === undefined ? undefined : parseWorkspaceEnv(options.env);
92
+ if (envResult !== undefined && !envResult.ok) {
93
+ throw new SealantError(`workspaces.create \`env\` was rejected: ${envResult.issues
94
+ .map(formatWorkspaceEnvIssue)
95
+ .join("; ")}`, { code: "invalid_workspace_env" });
96
+ }
97
+ const userEnv = envResult === undefined ? undefined : envResult.env;
98
+ const secretEnvResult = options.secretEnv === undefined ? undefined : parseWorkspaceSecretEnv(options.secretEnv);
99
+ if (secretEnvResult !== undefined && !secretEnvResult.ok) {
100
+ throw new SealantError(`workspaces.create \`secretEnv\` was rejected: ${secretEnvResult.issues
101
+ .map(formatWorkspaceEnvIssue)
102
+ .join("; ")}`, { code: "invalid_workspace_secret_env" });
103
+ }
104
+ // Secrets ride the request TOP LEVEL, never the spec: the spec is the durable, API-visible
105
+ // blueprint; the transient channel is a separate field the control plane seals until launch.
106
+ const secretEnv = secretEnvResult === undefined || Object.keys(secretEnvResult.env).length === 0
107
+ ? undefined
108
+ : secretEnvResult.env;
109
+ // One `runtime` object for every runtime-scoped field: two conditional `runtime:` spreads in the
110
+ // spec literal would let the later one silently clobber the earlier.
111
+ const runtime = {
112
+ ...(dotfilesArchives.length === 0
113
+ ? {}
114
+ : {
115
+ dotfilesArchives: dotfilesArchives.map((archive) => ({
116
+ data: archive.data,
117
+ ...(archive.manager === undefined ? {} : { manager: archive.manager }),
118
+ ...(archive.target === undefined ? {} : { target: archive.target }),
119
+ ...(archive.bootstrap === undefined ? {} : { bootstrap: archive.bootstrap }),
120
+ ...(archive.bootstrapCommand === undefined
121
+ ? {}
122
+ : { bootstrapCommand: archive.bootstrapCommand }),
123
+ })),
124
+ }),
125
+ ...(userEnv === undefined || Object.keys(userEnv).length === 0 ? {} : { userEnv }),
126
+ };
88
127
  const spec = {
89
128
  version: "1",
90
129
  sources: {
@@ -138,21 +177,7 @@ export const buildCreateWorkspaceRequest = (options, config) => {
138
177
  ? {}
139
178
  : { dotfilesBootstrapCommand: dotfilesRepository.bootstrapCommand }),
140
179
  },
141
- ...(dotfilesArchives.length === 0
142
- ? {}
143
- : {
144
- runtime: {
145
- dotfilesArchives: dotfilesArchives.map((archive) => ({
146
- data: archive.data,
147
- ...(archive.manager === undefined ? {} : { manager: archive.manager }),
148
- ...(archive.target === undefined ? {} : { target: archive.target }),
149
- ...(archive.bootstrap === undefined ? {} : { bootstrap: archive.bootstrap }),
150
- ...(archive.bootstrapCommand === undefined
151
- ? {}
152
- : { bootstrapCommand: archive.bootstrapCommand }),
153
- })),
154
- },
155
- }),
180
+ ...(Object.keys(runtime).length === 0 ? {} : { runtime }),
156
181
  target: {
157
182
  os: options.baseImage !== undefined
158
183
  ? { family: "custom", mode: "require", baseImage: options.baseImage }
@@ -174,6 +199,7 @@ export const buildCreateWorkspaceRequest = (options, config) => {
174
199
  ...(options.name === undefined ? {} : { name: options.name }),
175
200
  ...(options.ttl === undefined ? {} : { ttlSeconds: parseTtlSeconds(options.ttl) }),
176
201
  spec,
202
+ ...(secretEnv === undefined ? {} : { secretEnv }),
177
203
  },
178
204
  };
179
205
  };
package/dist/types.d.ts CHANGED
@@ -215,6 +215,36 @@ export interface CreateOptions {
215
215
  readonly shell?: "bash" | "zsh" | "fish";
216
216
  /** Dotfiles applied before the workspace accepts work (see `WorkspaceDotfilesOptions`). */
217
217
  readonly dotfiles?: WorkspaceDotfilesOptions;
218
+ /**
219
+ * Ordinary (non-secret) environment variables set on the workspace container and inherited by
220
+ * every process the platform starts inside it — the harness, later shells, exec'd commands, and
221
+ * their descendants. Validated client-side against the public policy re-exported from this
222
+ * package (`parseWorkspaceEnv`): names are `[A-Za-z_][A-Za-z0-9_]*`, platform-owned and
223
+ * secret-looking names are rejected loudly (the workspace runtime filters names containing
224
+ * `TOKEN`/`SECRET`/`PASSWORD`/`PASSWD`/`CREDENTIAL`/`APIKEY`, ending in `_KEY`, or exactly
225
+ * `KEY` — a value under such a name would silently never arrive). Not for secrets: values are
226
+ * persisted verbatim in the durable workspace spec and returned by workspace-details APIs; use
227
+ * `credentials` for connected-account material. The map is fixed at creation — a live workspace
228
+ * is never mutated, and a platform-side restart reuses the stored spec. Containers started
229
+ * INSIDE the workspace by Docker Compose or `docker run` receive only what the Compose file or
230
+ * command explicitly passes. Docker runtime only.
231
+ */
232
+ readonly env?: Readonly<Record<string, string>>;
233
+ /**
234
+ * SECRET environment variables for the workspace — API keys, database URLs with passwords,
235
+ * anything a dev server needs that must not be persisted or echoed. Same grammar and size
236
+ * bounds as `env`, validated client-side by `parseWorkspaceSecretEnv`; secret-shaped names are
237
+ * exactly what belongs here, while platform-owned names and connected-account names
238
+ * (`GITHUB_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`) stay reserved. Delivered through the transient
239
+ * secret channel: encrypted at rest on the build job until launch, handed to the workspace
240
+ * daemon as a boot file that is removed once the workspace is ready, never written to the
241
+ * blueprint, the attempt snapshot, `docker inspect`, or any read API — and every value is
242
+ * masked in captured process output. Inherited by every process the platform starts in the
243
+ * workspace, winning over `env` and container env for the same name. Fixed at creation; a
244
+ * platform-side RESTART of the workspace runs without secret env (create a new workspace
245
+ * instead). Docker runtime only.
246
+ */
247
+ readonly secretEnv?: Readonly<Record<string, string>>;
218
248
  /** Runtime-managed services that need more than installing an OS package. */
219
249
  readonly services?: WorkspaceServicesOptions;
220
250
  /** When true (default), resolve only once the workspace runtime is live. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/sdk",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "The fluent public SDK for Sealant — create a workspace, run a harness, replay the record.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@sealant/api-contracts": "^0.18.1"
29
+ "@sealant/api-contracts": "^0.19.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@effect/vitest": "4.0.0-beta.85",