@workerdeck/server 2.7.1 → 2.8.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 +28 -28
- package/build/index.mjs +10 -10
- package/build/index.mjs.map +1 -1
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
The WorkerDeck gateway: HTTP + WebSocket session server over
|
|
4
4
|
[`@workerdeck/core`](https://www.npmjs.com/package/@workerdeck/core). Session registry
|
|
5
5
|
(create/list/attach/interrupt/kill), pluggable auth hook, replay-from-seq attach, profiles,
|
|
6
|
-
parked-session storage, optional job-queue routes. Runs anywhere Node runs
|
|
6
|
+
parked-session storage, optional job-queue routes. Runs anywhere Node runs - needs a real
|
|
7
7
|
filesystem (no serverless).
|
|
8
8
|
|
|
9
9
|
Want the whole thing running rather than embedded? [`workerdeck`](https://www.npmjs.com/package/workerdeck)
|
|
@@ -24,22 +24,22 @@ npm install @workerdeck/server
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Node ≥ 22. The Agent SDK spawns the Claude Code CLI as a long-running subprocess with filesystem
|
|
27
|
-
state
|
|
27
|
+
state - edge/serverless functions cannot host this; realistic targets are a VM or a container.
|
|
28
28
|
The server implements no Anthropic auth: the SDK/CLI resolves credentials from the operator's
|
|
29
29
|
environment (`ANTHROPIC_API_KEY`, Bedrock/Vertex, or a personal `claude login`).
|
|
30
30
|
|
|
31
31
|
## Usage
|
|
32
32
|
|
|
33
|
-
The host app supplies the authenticator
|
|
33
|
+
The host app supplies the authenticator - return a truthy principal to accept, null/undefined to
|
|
34
34
|
reject with 401. `createWorkerServer` refuses to start without `authenticate` unless you
|
|
35
|
-
explicitly pass `allowUnauthenticated: true` (loopback dev only
|
|
35
|
+
explicitly pass `allowUnauthenticated: true` (loopback dev only - never expose that):
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
38
|
import { createWorkerServer } from '@workerdeck/server'
|
|
39
39
|
|
|
40
40
|
const worker = createWorkerServer({
|
|
41
41
|
authenticate: async (req) => verifyMyAppToken(req.headers.authorization),
|
|
42
|
-
allowedCwdRoots: ['/srv/checkouts'], // where sessions may run
|
|
42
|
+
allowedCwdRoots: ['/srv/checkouts'], // where sessions may run - and what /fs serves
|
|
43
43
|
hostFiles: { write: true }, // /fs reads follow the roots above; writing opts in
|
|
44
44
|
buildRunnerConfig: (req) => ({ ...req, env: { ...process.env } }),
|
|
45
45
|
requireApiKey: true, // fail closed on subscription credentials
|
|
@@ -60,22 +60,22 @@ Routes (default `basePath: '/v1'`):
|
|
|
60
60
|
| `GET /v1/sdk-sessions?dir=…` | List the Agent SDK's on-disk sessions to offer resume |
|
|
61
61
|
| `GET /v1/sessions/:id/files`, `…/files/<path>` | List and download a session's scratch-filesystem deliverables |
|
|
62
62
|
| `GET /v1/fs/roots`, `/fs/list?path=`, `/fs/read?path=` | Browse and read the **host's** real tree (the `allowedCwdRoots` trees, unless `hostFiles.roots` narrows them; 404 when neither is set) |
|
|
63
|
-
| `GET /v1/fs/find?path=&q=` | Recursive fuzzy file search under one directory
|
|
64
|
-
| `PUT /v1/fs/write` | Save a host file
|
|
63
|
+
| `GET /v1/fs/find?path=&q=` | Recursive fuzzy file search under one directory - what backs `@file` completion |
|
|
64
|
+
| `PUT /v1/fs/write` | Save a host file - needs `hostFiles.write`, and always carries the hash it replaces |
|
|
65
65
|
| `POST /v1/executions/:executionId/result` | Deliver a deferred execution's result, waking a parked session |
|
|
66
66
|
| `GET /v1/profiles`, `GET /v1/profiles/:name` | What sessions may run as (+ a view-only config snapshot) |
|
|
67
67
|
| `GET/POST /v1/jobs`, `GET/DELETE /v1/jobs/:id` | Job queue (when `queue` is configured) |
|
|
68
68
|
| `GET /v1/queue`, `WS /v1/queue/ws` | Queue stats / one-way live stream of job events + stats |
|
|
69
69
|
|
|
70
|
-
Requests outside `basePath` fall through to the optional `fallback` hook
|
|
70
|
+
Requests outside `basePath` fall through to the optional `fallback` hook - which is how the
|
|
71
71
|
turnkey instance serves a dashboard from the same origin, so a browser's WebSocket attach can
|
|
72
72
|
present a cookie.
|
|
73
73
|
|
|
74
74
|
## Profiles and the second engine
|
|
75
75
|
|
|
76
76
|
A **profile** is what a session runs as, and which engine runs it. Declared at startup (or managed
|
|
77
|
-
over the API with a `profileStore`), each one names either a Claude Code config directory
|
|
78
|
-
as that session's `CLAUDE_CONFIG_DIR`
|
|
77
|
+
over the API with a `profileStore`), each one names either a Claude Code config directory - applied
|
|
78
|
+
as that session's `CLAUDE_CONFIG_DIR` - or a model provider for the model-agnostic engine:
|
|
79
79
|
|
|
80
80
|
```ts
|
|
81
81
|
createWorkerServer({
|
|
@@ -88,19 +88,19 @@ createWorkerServer({
|
|
|
88
88
|
{
|
|
89
89
|
name: 'kimi',
|
|
90
90
|
engine: 'provider',
|
|
91
|
-
// A variable NAME
|
|
91
|
+
// A variable NAME - no credential is stored here or served by GET /profiles.
|
|
92
92
|
provider: { id: 'moonshotai', model: 'kimi-k3', apiKeyEnv: 'MOONSHOT_API_KEY' },
|
|
93
93
|
session: { capabilities: ['web_fetch', 'deliver_file'] },
|
|
94
94
|
},
|
|
95
95
|
],
|
|
96
|
-
// The one place a model SDK and its credentials are resolved
|
|
96
|
+
// The one place a model SDK and its credentials are resolved - this package imports neither.
|
|
97
97
|
createEngineRunner: ({ config, profile, bridge }) => buildProviderRunner({ /* … */ }),
|
|
98
98
|
})
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
With more than one profile declared, every create must name its `profile`; with exactly one it is
|
|
102
102
|
implicit, and with the option unset a `default` is auto-detected from `~/.claude`.
|
|
103
|
-
`allowedProfiles` on the principal scopes who may run as what
|
|
103
|
+
`allowedProfiles` on the principal scopes who may run as what - the line between one worker serving
|
|
104
104
|
several people and account pooling. `SessionInfo.engine` and `supportsPermissionMode` let a UI hide
|
|
105
105
|
affordances the provider engine doesn't have; asking for one anyway is a 400 rather than a silent
|
|
106
106
|
coercion.
|
|
@@ -108,7 +108,7 @@ coercion.
|
|
|
108
108
|
## Parked sessions
|
|
109
109
|
|
|
110
110
|
A session whose tool call can't answer in the next few seconds **parks**: the runner is torn down,
|
|
111
|
-
its snapshot goes to a `SessionStore`, and `POST /executions/:id/result` rebuilds it
|
|
111
|
+
its snapshot goes to a `SessionStore`, and `POST /executions/:id/result` rebuilds it - same id,
|
|
112
112
|
same event log, same seq numbering, mid-turn. Results are idempotent by `executionId`. The default
|
|
113
113
|
store is in-memory (a park survives a client disconnect, not a restart); the bundled file store
|
|
114
114
|
survives both, on one host:
|
|
@@ -123,12 +123,12 @@ createWorkerServer({
|
|
|
123
123
|
})
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
That directory holds each parked session's whole transcript in plaintext
|
|
126
|
+
That directory holds each parked session's whole transcript in plaintext - protect it like the
|
|
127
127
|
SDK's own `~/.claude/projects`. Credentials never reach it. One process per directory.
|
|
128
128
|
|
|
129
129
|
## Job queue
|
|
130
130
|
|
|
131
|
-
Pass `queue` options to mount the job routes
|
|
131
|
+
Pass `queue` options to mount the job routes - one-shot unattended runs with bounded concurrency,
|
|
132
132
|
token budgets, retries, and webhook delivery:
|
|
133
133
|
|
|
134
134
|
```ts
|
|
@@ -145,7 +145,7 @@ const worker = createWorkerServer({
|
|
|
145
145
|
})
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
Job sessions are ordinary registry sessions
|
|
148
|
+
Job sessions are ordinary registry sessions - attachable over the sessions WS - and go through
|
|
149
149
|
the same `buildRunnerConfig` hook and auth-provenance watcher as client sessions. The in-memory
|
|
150
150
|
adapter is single-process and non-persistent; implement `QueueAdapter` against a shared store for
|
|
151
151
|
anything beyond one trusted host.
|
|
@@ -153,7 +153,7 @@ anything beyond one trusted host.
|
|
|
153
153
|
## Session notifications
|
|
154
154
|
|
|
155
155
|
The live WebSocket only reaches someone who has one open. `notifications` is the outbound
|
|
156
|
-
channel for everyone else
|
|
156
|
+
channel for everyone else - the four moments a person acts on, POSTed to a URL you control:
|
|
157
157
|
|
|
158
158
|
```ts
|
|
159
159
|
const worker = createWorkerServer({
|
|
@@ -166,13 +166,13 @@ const worker = createWorkerServer({
|
|
|
166
166
|
})
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
`permission_requested`, `turn_completed`, `session_error`, `session_closed`
|
|
169
|
+
`permission_requested`, `turn_completed`, `session_error`, `session_closed` - delivered as a
|
|
170
170
|
`SessionNotification`, ordered per session, retried with exponential backoff. Server-wide, unlike
|
|
171
171
|
the queue's per-job `webhook`: the point is hearing about sessions you neither created nor are
|
|
172
172
|
attached to, and every registry session qualifies (job runs and rebuilt parked sessions included).
|
|
173
173
|
|
|
174
174
|
`permission_requested` carries the whole `PermissionRequest`, so a consumer can answer it with
|
|
175
|
-
`POST /sessions/:id/permissions/:requestId`
|
|
175
|
+
`POST /sessions/:id/permissions/:requestId` - the mechanism behind an Approve button in a chat
|
|
176
176
|
message or on a phone's lock screen. The server holds no push credentials and knows nothing about
|
|
177
177
|
APNs, FCM or Slack; forwarding to one of those is a separate process's job.
|
|
178
178
|
|
|
@@ -180,10 +180,10 @@ APNs, FCM or Slack; forwarding to one of those is a separate process's job.
|
|
|
180
180
|
|
|
181
181
|
Each session's credential provenance surfaces as `apiKeySource` on `SessionInfo` and the
|
|
182
182
|
`system_init` event; `'oauth'` means claude.ai subscription credentials. With
|
|
183
|
-
`requireApiKey: true` such sessions are terminated with a `session_error`
|
|
183
|
+
`requireApiKey: true` such sessions are terminated with a `session_error` - recommended for
|
|
184
184
|
services and any unattended use. Without it the server logs a one-time notice instead
|
|
185
185
|
(appropriate only for personal single-user deployments). WorkerDeck never implements claude.ai
|
|
186
|
-
OAuth, never reads or forwards tokens
|
|
186
|
+
OAuth, never reads or forwards tokens - see the repo README's
|
|
187
187
|
["Auth & Anthropic's terms"](https://github.com/workerdeck/workerdeck#auth--anthropics-terms).
|
|
188
188
|
|
|
189
189
|
## Rules you cannot infer from the types
|
|
@@ -192,7 +192,7 @@ OAuth, never reads or forwards tokens — see the repo README's
|
|
|
192
192
|
caller's business, so the out-of-scope answer is byte-identical to the unknown-id one.
|
|
193
193
|
- **Visibility is full control.** There is no read-only attach: a client that can see a session can
|
|
194
194
|
send `user_message`, `permission_decision`, `interrupt` and `close`. `SessionPanel`'s `readOnly`
|
|
195
|
-
removes the affordance, not the authority
|
|
195
|
+
removes the affordance, not the authority - enforce at the gateway or not at all.
|
|
196
196
|
- **`authorizeSession` is synchronous on purpose.** It runs per route and per row of every list. An
|
|
197
197
|
expensive lookup belongs in `authenticate`, where it happens once and lands on the principal.
|
|
198
198
|
- **`sandboxedProviderProfile()`'s empty arrays are load-bearing.** `capabilities: []` and
|
|
@@ -202,15 +202,15 @@ OAuth, never reads or forwards tokens — see the repo README's
|
|
|
202
202
|
variable at all moves the CLI off the macOS Keychain, so pinning the default directory breaks a
|
|
203
203
|
working `claude login`. "Pin the default dir" and "don't pin" are different logins.
|
|
204
204
|
- **`checkCredentials` is display-only** unless you also set `requireAvailableProfile`. A create
|
|
205
|
-
against an unavailable profile otherwise proceeds and fails with the engine's own error
|
|
205
|
+
against an unavailable profile otherwise proceeds and fails with the engine's own error - right
|
|
206
206
|
for an operator (the probe can be stale), wrong in front of an end user.
|
|
207
207
|
- **`createEngineRunner` has four invisible obligations**: forward `restore`, adopt `id`, seed the
|
|
208
208
|
VFS only when *not* restoring, and dispose per-session resources via `onClose`. Every one is a
|
|
209
209
|
runtime-only failure. `createProviderRunner()` does all four; reach for the raw hook only when it
|
|
210
210
|
genuinely doesn't fit.
|
|
211
211
|
- **`parking.persistLive` is how a *provider* session survives a restart, and it needs a durable
|
|
212
|
-
store to mean anything.** Claude and codex go dormant
|
|
213
|
-
resumed from the engine's own store
|
|
212
|
+
store to mean anything.** Claude and codex go dormant - remembered by engine session id and
|
|
213
|
+
resumed from the engine's own store - which a provider session cannot do, so its record carries
|
|
214
214
|
the state itself, written through after every turn. With the default in-memory store the option
|
|
215
215
|
does nothing and says nothing. It is off by default: a library must not start writing sessions'
|
|
216
216
|
transcripts to disk because someone upgraded, and the record holds the whole transcript in
|
|
@@ -220,9 +220,9 @@ OAuth, never reads or forwards tokens — see the repo README's
|
|
|
220
220
|
implement a `SessionStore`, do not "tidy up" a record on read.
|
|
221
221
|
- **One origin is not a convenience.** A browser cannot put an `Authorization` header on a
|
|
222
222
|
WebSocket upgrade, so a cookie is the only credential a tab can present on an attach, and a
|
|
223
|
-
cookie is per-origin. That is what `fallback` is for
|
|
223
|
+
cookie is per-origin. That is what `fallback` is for - an app served from the gateway's own port.
|
|
224
224
|
|
|
225
225
|
## License
|
|
226
226
|
|
|
227
|
-
MIT © Tobias Strebitzer
|
|
227
|
+
MIT © Tobias Strebitzer -
|
|
228
228
|
[LICENSE](https://github.com/workerdeck/workerdeck/blob/master/LICENSE)
|
package/build/index.mjs
CHANGED
|
@@ -712,7 +712,7 @@ async function handleHostFiles(ctx, req, res, pathname) {
|
|
|
712
712
|
}
|
|
713
713
|
const existing = current.ok ? current.data : null;
|
|
714
714
|
if (existing && !body.expectedHash) {
|
|
715
|
-
json(res, 409, { error: "file exists
|
|
715
|
+
json(res, 409, { error: "file exists - pass expectedHash to overwrite it" });
|
|
716
716
|
return;
|
|
717
717
|
}
|
|
718
718
|
if (existing && hashBytes(existing) !== body.expectedHash) {
|
|
@@ -1068,7 +1068,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1068
1068
|
path,
|
|
1069
1069
|
op: "save"
|
|
1070
1070
|
});
|
|
1071
|
-
throw new Error(`parked session '${record.id}' is not JSON-serializable
|
|
1071
|
+
throw new Error(`parked session '${record.id}' is not JSON-serializable - a host-injected value reached its config or snapshot: ${String(error)}`, { cause: error });
|
|
1072
1072
|
}
|
|
1073
1073
|
try {
|
|
1074
1074
|
await mkdir(dir, {
|
|
@@ -2574,7 +2574,7 @@ var SessionParkManager = class {
|
|
|
2574
2574
|
}
|
|
2575
2575
|
if (runner.id !== id) {
|
|
2576
2576
|
runner.close("error");
|
|
2577
|
-
const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}'
|
|
2577
|
+
const error = /* @__PURE__ */ new Error(`rebuilt session has id '${runner.id}', expected '${id}' - the engine factory must forward EngineRunnerContext.restore (or, without a snapshot, the session id) to the runner config`);
|
|
2578
2578
|
this.#options.onError?.(error, {
|
|
2579
2579
|
sessionId: id,
|
|
2580
2580
|
phase: "resume"
|
|
@@ -2682,7 +2682,7 @@ var ProfileService = class {
|
|
|
2682
2682
|
if (!hasEngineRunnerFactory) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
|
|
2683
2683
|
} else if (p.engine === "codex") {
|
|
2684
2684
|
if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
|
|
2685
|
-
if (p.session?.instructions) return `profile '${p.name}' declares session.instructions, which the codex engine cannot deliver
|
|
2685
|
+
if (p.session?.instructions) return `profile '${p.name}' declares session.instructions, which the codex engine cannot deliver - put instructions in the target repo’s AGENTS.md instead`;
|
|
2686
2686
|
} else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
|
|
2687
2687
|
if (disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
|
|
2688
2688
|
const fallbackMode = p.defaults?.permissionMode;
|
|
@@ -2745,7 +2745,7 @@ var ProfileService = class {
|
|
|
2745
2745
|
declaredGuard(profile) {
|
|
2746
2746
|
return this.#declaredByName.has(profile.name) ? {
|
|
2747
2747
|
status: 403,
|
|
2748
|
-
error: `profile '${profile.name}' is declared in server options and cannot be changed over the API
|
|
2748
|
+
error: `profile '${profile.name}' is declared in server options and cannot be changed over the API - edit the \`profiles\` option instead`
|
|
2749
2749
|
} : null;
|
|
2750
2750
|
}
|
|
2751
2751
|
configDirGuard(profile) {
|
|
@@ -2895,13 +2895,13 @@ function createSessionFactory(deps) {
|
|
|
2895
2895
|
};
|
|
2896
2896
|
const checkPermissionMode = (mode, profile) => {
|
|
2897
2897
|
if (mode === void 0 || supportsPermissionMode(profile?.engine, mode)) return null;
|
|
2898
|
-
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}')
|
|
2898
|
+
return `permission mode '${mode}' is not supported by profile '${profile.name}' (engine '${engineOf(profile)}') - supported: ` + adapterFor(profile?.engine).capabilities.permissionModes.join(", ");
|
|
2899
2899
|
};
|
|
2900
2900
|
const checkEngineGrants = (req, profile) => {
|
|
2901
2901
|
const engine = engineOf(profile);
|
|
2902
2902
|
const caps = adapterFor(profile?.engine).capabilities;
|
|
2903
2903
|
const name = profile?.name ?? "default";
|
|
2904
|
-
if (!caps.sessionMcpServers && req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${name}' runs the ${engine} engine, whose MCP servers are declared outside the session request
|
|
2904
|
+
if (!caps.sessionMcpServers && req.mcpServers && Object.keys(req.mcpServers).length > 0) return `profile '${name}' runs the ${engine} engine, whose MCP servers are declared outside the session request - a request cannot add its own`;
|
|
2905
2905
|
if (!caps.budgets && (req.maxTurns !== void 0 || req.maxBudgetUsd !== void 0)) return `the ${engine} engine does not honor maxTurns/maxBudgetUsd`;
|
|
2906
2906
|
if (!caps.settingSources && req.settingSources !== void 0) return `the ${engine} engine does not load settingSources`;
|
|
2907
2907
|
if (!caps.resume && req.resume !== void 0) return `the ${engine} engine cannot resume a session`;
|
|
@@ -2912,7 +2912,7 @@ function createSessionFactory(deps) {
|
|
|
2912
2912
|
if (!req.capabilities || !granted) return null;
|
|
2913
2913
|
const ungranted = req.capabilities.filter((c) => !granted.includes(c));
|
|
2914
2914
|
if (ungranted.length === 0) return null;
|
|
2915
|
-
return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"})
|
|
2915
|
+
return `profile '${profile.name}' does not grant: ${ungranted.join(", ")} (granted: ${granted.join(", ") || "none"}) - a request may narrow capabilities, not widen them`;
|
|
2916
2916
|
};
|
|
2917
2917
|
const stripInertFields = (req, profile) => {
|
|
2918
2918
|
if (!adapterFor(profile?.engine).capabilities.interactiveApprovals) delete req.questionBehavior;
|
|
@@ -3006,7 +3006,7 @@ function createSessionFactory(deps) {
|
|
|
3006
3006
|
id
|
|
3007
3007
|
});
|
|
3008
3008
|
const reported = runner.info().scope;
|
|
3009
|
-
if (!sameScope(reported, config.scope)) throw new Error(`runner for session ${runner.id} reports scope ${JSON.stringify(reported)}, expected ${JSON.stringify(config.scope)}
|
|
3009
|
+
if (!sameScope(reported, config.scope)) throw new Error(`runner for session ${runner.id} reports scope ${JSON.stringify(reported)}, expected ${JSON.stringify(config.scope)} - echo config.scope from info()`);
|
|
3010
3010
|
return runner;
|
|
3011
3011
|
};
|
|
3012
3012
|
const createRunner = async (config) => {
|
|
@@ -3062,7 +3062,7 @@ function createSessionFactory(deps) {
|
|
|
3062
3062
|
if (subscriptionNoticeShown.has(profileName)) return;
|
|
3063
3063
|
subscriptionNoticeShown.add(profileName);
|
|
3064
3064
|
const scope = profileName ? `Sessions under profile '${profileName}'` : "Sessions";
|
|
3065
|
-
console.warn(`[workerdeck] ${scope} are using claude.ai subscription credentials (apiKeySource 'oauth'), not an API key. That is only appropriate for personal, single-user use of your own account. Unattended/scheduled or multi-user use requires an API key under Anthropic's terms
|
|
3065
|
+
console.warn(`[workerdeck] ${scope} are using claude.ai subscription credentials (apiKeySource 'oauth'), not an API key. That is only appropriate for personal, single-user use of your own account. Unattended/scheduled or multi-user use requires an API key under Anthropic's terms - set ANTHROPIC_API_KEY in the server environment, or set requireApiKey: true to fail closed.`);
|
|
3066
3066
|
}
|
|
3067
3067
|
});
|
|
3068
3068
|
};
|