@workerdeck/server 2.0.0 → 2.2.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/build/index.d.mts CHANGED
@@ -160,6 +160,11 @@ type WorkerServerOptions = {
160
160
  maxEntries?: number;
161
161
  ignore?: string[];
162
162
  };
163
+ shell?: {
164
+ enabled?: boolean;
165
+ timeoutMs?: number;
166
+ maxOutputBytes?: number;
167
+ };
163
168
  attachments?: {
164
169
  maxFileBytes?: number;
165
170
  maxSessionBytes?: number;
@@ -175,6 +180,7 @@ type WorkerServerOptions = {
175
180
  };
176
181
  maxBodyBytes?: number;
177
182
  disableBypassPermissions?: boolean;
183
+ approvalTimeoutMs?: number | null;
178
184
  requireApiKey?: boolean;
179
185
  checkCredentials?: boolean | {
180
186
  probe?: ClaudeAuthProbe;
@@ -270,7 +276,7 @@ type ProviderRunnerOptions = {
270
276
  toolName: string;
271
277
  input: unknown;
272
278
  }) => boolean;
273
- approvalTimeoutMs?: number;
279
+ approvalTimeoutMs?: number | null;
274
280
  onClose?: () => void | Promise<void>;
275
281
  };
276
282
  declare function createProviderRunner(ctx: EngineRunnerContext, options: ProviderRunnerOptions): Promise<Runner>;
package/build/index.mjs CHANGED
@@ -2,12 +2,13 @@ import { createServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
3
  import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
4
4
  import { JobQueue } from "@workerdeck/queue";
5
- import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, imagePartRef, sessionState, supportsPermissionMode } from "@workerdeck/protocol";
5
+ import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, SHELL_COMMAND_MAX, imagePartRef, sessionState, supportsPermissionMode } from "@workerdeck/protocol";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
8
- import { homedir } from "node:os";
8
+ import { constants as constants$1, homedir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
11
+ import { spawn } from "node:child_process";
11
12
  //#region src/lib/http.ts
12
13
  const CONTENT_TYPES = {
13
14
  json: "application/json; charset=utf-8",
@@ -1626,9 +1627,152 @@ async function handleSessions(ctx, req, res, route, auth) {
1626
1627
  }
1627
1628
  json(res, 405, { error: "method not allowed" });
1628
1629
  }
1630
+ const TIMEOUT_EXIT_CODE = 124;
1631
+ const SPAWN_FAILURE_EXIT_CODE = 127;
1632
+ const KILLED_EXIT_CODE = 128 + constants$1.signals.SIGKILL;
1633
+ function shellPermitted(shell, runner, operator) {
1634
+ if (shell === null || !operator) return false;
1635
+ const info = runner.info();
1636
+ return (info.capabilities ?? (info.engine ? ENGINE_CAPABILITIES[info.engine] : void 0))?.hostCwd === true;
1637
+ }
1638
+ function shellChildEnv(base) {
1639
+ const env = {};
1640
+ for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
1641
+ return env;
1642
+ }
1643
+ function createShellService(options = {}) {
1644
+ const timeoutMs = options.timeoutMs ?? 12e4;
1645
+ const maxOutputBytes = options.maxOutputBytes ?? 32768;
1646
+ const active = /* @__PURE__ */ new Map();
1647
+ const run = async (sessionId, cwd, command) => {
1648
+ if (active.has(sessionId)) throw new Error("a shell command is still running on this session");
1649
+ const child = spawnShell({
1650
+ command,
1651
+ cwd,
1652
+ env: shellChildEnv(process.env),
1653
+ timeoutMs,
1654
+ maxOutputBytes
1655
+ });
1656
+ active.set(sessionId, child);
1657
+ try {
1658
+ return await child.result;
1659
+ } finally {
1660
+ if (active.get(sessionId) === child) active.delete(sessionId);
1661
+ }
1662
+ };
1663
+ const kill = (sessionId, reason = "session closed") => {
1664
+ active.get(sessionId)?.kill(reason);
1665
+ };
1666
+ const watch = (runner) => runner.subscribe((event) => {
1667
+ if (event.type === "session_closed") kill(runner.id, "session closed");
1668
+ else if (event.type === "status_changed" && event.status === "parked") kill(runner.id, "session parked");
1669
+ }, runner.info().lastSeq);
1670
+ const killAll = () => {
1671
+ for (const sessionId of active.keys()) kill(sessionId, "server shutting down");
1672
+ };
1673
+ const running = (sessionId) => active.has(sessionId);
1674
+ return {
1675
+ run,
1676
+ kill,
1677
+ watch,
1678
+ killAll,
1679
+ running,
1680
+ timeoutMs,
1681
+ maxOutputBytes
1682
+ };
1683
+ }
1684
+ var BoundedOutput = class {
1685
+ #chunks = [];
1686
+ #kept = 0;
1687
+ #dropped = 0;
1688
+ #budget;
1689
+ constructor(budget) {
1690
+ this.#budget = budget;
1691
+ }
1692
+ push(chunk) {
1693
+ const take = Math.min(chunk.length, this.#budget.remaining);
1694
+ if (take > 0) {
1695
+ this.#chunks.push(take === chunk.length ? chunk : chunk.subarray(0, take));
1696
+ this.#kept += take;
1697
+ this.#budget.remaining -= take;
1698
+ }
1699
+ this.#dropped += chunk.length - take;
1700
+ }
1701
+ text() {
1702
+ const head = Buffer.concat(this.#chunks, this.#kept).toString("utf8");
1703
+ return this.#dropped > 0 ? `${head}\n[output truncated: ${this.#dropped} more bytes dropped]` : head;
1704
+ }
1705
+ };
1706
+ function spawnShell(input) {
1707
+ const startedAt = Date.now();
1708
+ const budget = { remaining: input.maxOutputBytes };
1709
+ const stdout = new BoundedOutput(budget);
1710
+ const stderr = new BoundedOutput(budget);
1711
+ let settle;
1712
+ const result = new Promise((resolve) => {
1713
+ settle = resolve;
1714
+ });
1715
+ let done = false;
1716
+ const finish = (exitCode, note) => {
1717
+ if (done) return;
1718
+ done = true;
1719
+ clearTimeout(timer);
1720
+ const captured = stderr.text();
1721
+ const stderrText = note ? captured ? `${captured}\n[${note}]` : `[${note}]` : captured;
1722
+ settle?.({
1723
+ command: input.command,
1724
+ stdout: stdout.text(),
1725
+ stderr: stderrText,
1726
+ exitCode,
1727
+ durationMs: Date.now() - startedAt
1728
+ });
1729
+ };
1730
+ const child = spawn("/bin/sh", ["-c", input.command], {
1731
+ cwd: input.cwd,
1732
+ env: {
1733
+ ...input.env,
1734
+ PWD: input.cwd
1735
+ },
1736
+ stdio: [
1737
+ "ignore",
1738
+ "pipe",
1739
+ "pipe"
1740
+ ],
1741
+ detached: true
1742
+ });
1743
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
1744
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
1745
+ const killGroup = () => {
1746
+ if (child.pid === void 0) return;
1747
+ try {
1748
+ process.kill(-child.pid, "SIGKILL");
1749
+ } catch {
1750
+ try {
1751
+ child.kill("SIGKILL");
1752
+ } catch {}
1753
+ }
1754
+ child.stdout.destroy();
1755
+ child.stderr.destroy();
1756
+ };
1757
+ const kill = (reason, exitCode = KILLED_EXIT_CODE) => {
1758
+ if (done) return;
1759
+ killGroup();
1760
+ finish(exitCode, `killed: ${reason}`);
1761
+ };
1762
+ const timer = setTimeout(() => kill(`timed out after ${Math.round(input.timeoutMs / 1e3)}s`, TIMEOUT_EXIT_CODE), input.timeoutMs);
1763
+ child.on("error", (error) => finish(SPAWN_FAILURE_EXIT_CODE, error.message));
1764
+ child.on("close", (code, signal) => {
1765
+ const signalCode = signal ? constants$1.signals[signal] ?? 0 : 0;
1766
+ finish(code ?? (signalCode ? 128 + signalCode : 1), signal ? `killed by ${signal}` : void 0);
1767
+ });
1768
+ return {
1769
+ result,
1770
+ kill: (reason) => kill(reason)
1771
+ };
1772
+ }
1629
1773
  //#endregion
1630
1774
  //#region src/routes/ws.ts
1631
- function attachClient(ctx, ws, runner, req) {
1775
+ function attachClient(ctx, ws, runner, req, access) {
1632
1776
  const { bridge, parking } = ctx;
1633
1777
  const url = new URL(req.url ?? "/", "http://internal");
1634
1778
  const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0") || 0;
@@ -1641,7 +1785,8 @@ function attachClient(ctx, ws, runner, req) {
1641
1785
  type: "attached",
1642
1786
  protocolVersion: PROTOCOL_VERSION,
1643
1787
  session: ctx.projects.withProject(runner.info()),
1644
- replayingFrom: afterSeq
1788
+ replayingFrom: afterSeq,
1789
+ ...shellPermitted(ctx.shell, runner, access.operator) ? { shell: true } : {}
1645
1790
  });
1646
1791
  const unsubscribe = runner.subscribe((event) => send({
1647
1792
  type: "event",
@@ -1663,7 +1808,7 @@ function attachClient(ctx, ws, runner, req) {
1663
1808
  });
1664
1809
  return;
1665
1810
  }
1666
- handleCommand(ctx, frame, runner).catch((error) => {
1811
+ handleCommand(ctx, frame, runner, access).catch((error) => {
1667
1812
  send({
1668
1813
  type: "protocol_error",
1669
1814
  message: error instanceof Error ? error.message : "command failed"
@@ -1676,7 +1821,7 @@ function attachClient(ctx, ws, runner, req) {
1676
1821
  parking.onDetach(runner.id);
1677
1822
  });
1678
1823
  }
1679
- async function handleCommand(ctx, frame, runner) {
1824
+ async function handleCommand(ctx, frame, runner, access) {
1680
1825
  const { attachmentStore, bridge } = ctx;
1681
1826
  switch (frame.type) {
1682
1827
  case "user_message": {
@@ -1727,6 +1872,16 @@ async function handleCommand(ctx, frame, runner) {
1727
1872
  logs: frame.logs
1728
1873
  });
1729
1874
  return;
1875
+ case "shell_command": {
1876
+ if (!shellPermitted(ctx.shell, runner, access.operator) || ctx.shell === null) throw new Error("shell commands are not available on this session");
1877
+ if (typeof frame.command !== "string" || frame.command.includes("\0")) throw new Error("shell command must be a string");
1878
+ if (frame.command.length > SHELL_COMMAND_MAX) throw new Error(`shell command exceeds ${SHELL_COMMAND_MAX} characters`);
1879
+ if (frame.command.trim() === "") throw new Error("shell command is empty");
1880
+ if (!runner.queueLocalCommand) throw new Error(`the ${runner.info().engine ?? "claude"} engine cannot take shell output`);
1881
+ const result = await ctx.shell.run(runner.id, runner.info().cwd, frame.command);
1882
+ runner.queueLocalCommand(result);
1883
+ return;
1884
+ }
1730
1885
  case "close":
1731
1886
  runner.close("client");
1732
1887
  return;
@@ -2781,14 +2936,18 @@ function createSessionFactory(deps) {
2781
2936
  ...config,
2782
2937
  scope
2783
2938
  };
2939
+ const withApprovalDefault = (config) => deps.approvalTimeoutMs === void 0 ? config : {
2940
+ ...config,
2941
+ defaultApprovalTimeoutMs: deps.approvalTimeoutMs
2942
+ };
2784
2943
  const buildRunnerConfig = (req) => {
2785
2944
  const profile = req.profile !== void 0 ? profiles.get(req.profile) : void 0;
2786
- if (!profile) return withScope(deps.hostBuildRunnerConfig(req), req.scope);
2787
- const config = withScope(deps.hostBuildRunnerConfig({
2945
+ if (!profile) return withApprovalDefault(withScope(deps.hostBuildRunnerConfig(req), req.scope));
2946
+ const config = withApprovalDefault(withScope(deps.hostBuildRunnerConfig({
2788
2947
  ...req,
2789
2948
  model: req.model ?? profile.defaults?.model ?? profile.provider?.model,
2790
2949
  permissionMode: req.permissionMode ?? profile.defaults?.permissionMode
2791
- }), req.scope);
2950
+ }), req.scope));
2792
2951
  if (engineOf(profile) !== "claude") return config;
2793
2952
  const base = config.env ?? process.env;
2794
2953
  const env = claudeSessionEnv(profile, base);
@@ -2953,6 +3112,7 @@ function createWorkerServer(options = {}) {
2953
3112
  createEngineRunner: options.createEngineRunner,
2954
3113
  allowedCwdRoots: options.allowedCwdRoots,
2955
3114
  disableBypassPermissions: options.disableBypassPermissions,
3115
+ approvalTimeoutMs: options.approvalTimeoutMs,
2956
3116
  requireApiKey: options.requireApiKey,
2957
3117
  refs
2958
3118
  });
@@ -2968,10 +3128,12 @@ function createWorkerServer(options = {}) {
2968
3128
  decorateInfo: (info) => projects.withProject(info)
2969
3129
  });
2970
3130
  const producedFiles = new ProducedFileStore();
3131
+ const shell = options.shell?.enabled === true ? createShellService(options.shell) : null;
2971
3132
  const registry = new SessionRegistry({ onRegister: (runner) => {
2972
3133
  notifier.watch(runner);
2973
3134
  producedFiles.watch(runner);
2974
3135
  profileUsage.watch(runner);
3136
+ shell?.watch(runner);
2975
3137
  const profile = runner.info().profile;
2976
3138
  if (!profile) return;
2977
3139
  runner.subscribe((event) => {
@@ -3073,7 +3235,8 @@ function createWorkerServer(options = {}) {
3073
3235
  hostFiles,
3074
3236
  hostFilesWritable: options.hostFiles?.write === true,
3075
3237
  maxHostFileBytes: options.hostFiles?.maxFileBytes ?? 1048576,
3076
- maxHostDirEntries: options.hostFiles?.maxEntries ?? 5e3
3238
+ maxHostDirEntries: options.hostFiles?.maxEntries ?? 5e3,
3239
+ shell
3077
3240
  };
3078
3241
  const handleRequest = async (req, res) => {
3079
3242
  const pathname = new URL(req.url ?? "/", "http://internal").pathname;
@@ -3231,7 +3394,7 @@ function createWorkerServer(options = {}) {
3231
3394
  return;
3232
3395
  }
3233
3396
  wss.handleUpgrade(req, socket, head, (ws) => {
3234
- attachClient(ctx, ws, runner, req);
3397
+ attachClient(ctx, ws, runner, req, { operator: auth.isOperator(authCtx) });
3235
3398
  });
3236
3399
  })().catch(() => socket.destroy());
3237
3400
  });
@@ -3278,6 +3441,7 @@ function createWorkerServer(options = {}) {
3278
3441
  queue?.close();
3279
3442
  parking.close();
3280
3443
  registry.closeAll();
3444
+ shell?.killAll();
3281
3445
  for (const ws of wss.clients) ws.close(1001, "server shutting down");
3282
3446
  queueSockets.clear();
3283
3447
  const force = setTimeout(() => {
@@ -3342,7 +3506,7 @@ async function createProviderRunner(ctx, options) {
3342
3506
  instructions: options.instructions,
3343
3507
  executionLimits: options.executionLimits,
3344
3508
  shouldApprove: options.shouldApprove,
3345
- approvalTimeoutMs: options.approvalTimeoutMs,
3509
+ approvalTimeoutMs: config.defaultApprovalTimeoutMs === void 0 ? options.approvalTimeoutMs : config.defaultApprovalTimeoutMs,
3346
3510
  seedVfs: options.seedVfs
3347
3511
  });
3348
3512
  }