@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 +80 -0
- package/dist/effect/api-client.d.ts +6 -0
- package/dist/effect/operations.d.ts +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/internal/blueprint.d.ts +3 -0
- package/dist/internal/blueprint.js +41 -15
- package/dist/types.d.ts +30 -0
- package/package.json +2 -2
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
|
-
...(
|
|
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.
|
|
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.
|
|
29
|
+
"@sealant/api-contracts": "^0.19.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@effect/vitest": "4.0.0-beta.85",
|