@workerdeck/server 2.12.0 → 2.14.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 +101 -5
- package/build/index.mjs +1342 -203
- package/build/index.mjs.map +1 -1
- package/package.json +8 -5
package/build/index.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
1
2
|
import { createServer } from "node:http";
|
|
2
3
|
import { WebSocketServer } from "ws";
|
|
3
|
-
import { BrowserBridgeExecutor, PEER_MESSAGE_MAX_CHARS, PEER_RECENT_DEFAULT, PEER_RECENT_MAX, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, installPeerDirectory, isSlashCommand, normalizeMediaType, peerDirectoryHandle, peerSummary, recentLines } from "@workerdeck/core";
|
|
4
|
+
import { BrowserBridgeExecutor, PEER_MESSAGE_MAX_CHARS, PEER_RECENT_DEFAULT, PEER_RECENT_MAX, SHELL_REFUSAL, SessionRunner, attachmentKind, checkClaudeAuth, clampShellTail, createEngineSession, getEngineAdapter, installPeerDirectory, installShellDirectory, isSlashCommand, normalizeMediaType, peerDirectoryHandle, peerSummary, recentLines, shellDirectoryHandle, shellSummary, shellTail, ttyText } from "@workerdeck/core";
|
|
4
5
|
import { JobQueue } from "@workerdeck/queue";
|
|
5
|
-
import { ENGINE_CAPABILITIES, PEER_MENTION_MAX, PROTOCOL_VERSION, SHELL_COMMAND_MAX, costOfByModel, imagePartRef, isJobRun, mergeByModel, peerMentionKey, peerMentionSlug, scanPeerMentions, sessionState, setPricingOverrides, subscriptionComparison, supportsPermissionMode } from "@workerdeck/protocol";
|
|
6
|
-
import {
|
|
6
|
+
import { ENGINE_CAPABILITIES, PEER_MENTION_MAX, PROTOCOL_VERSION, SHELL_ARTIFACT_MAX_BYTES, SHELL_ARTIFACT_TTL_MS, SHELL_ATTACH_REPLAY_BYTES, SHELL_COLS, SHELL_COMMAND_MAX, SHELL_INPUT_MAX, SHELL_LABEL_MAX, SHELL_LINGER_MS, SHELL_MAX_COLS, SHELL_MAX_ROWS, SHELL_MAX_RUNNING_PER_SESSION, SHELL_MAX_RUNNING_TOTAL, SHELL_MIN_COLS, SHELL_MIN_ROWS, SHELL_ROWS, SHELL_SPILL_BYTES, SHELL_TAIL_FLUSH_MS, SHELL_TAIL_RING_BYTES, costOfByModel, imagePartRef, isJobRun, mergeByModel, peerMentionKey, peerMentionSlug, pickCreateSessionRequest, scanPeerMentions, sessionState, setPricingOverrides, subscriptionComparison, supportsPermissionMode } from "@workerdeck/protocol";
|
|
7
|
+
import { arch, homedir, hostname, platform, tmpdir } from "node:os";
|
|
7
8
|
import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { constants as constants$1, homedir } from "node:os";
|
|
9
9
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
-
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
-
import {
|
|
10
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
12
|
//#region src/lib/http.ts
|
|
13
13
|
const CONTENT_TYPES = {
|
|
14
14
|
json: "application/json; charset=utf-8",
|
|
@@ -61,6 +61,18 @@ function contentTypeFor(filename) {
|
|
|
61
61
|
return CONTENT_TYPES[ext] ?? "text/plain; charset=utf-8";
|
|
62
62
|
}
|
|
63
63
|
//#endregion
|
|
64
|
+
//#region src/lib/machine-id.ts
|
|
65
|
+
let cached;
|
|
66
|
+
function machineId() {
|
|
67
|
+
cached ??= createHash("sha256").update([
|
|
68
|
+
hostname(),
|
|
69
|
+
platform(),
|
|
70
|
+
arch(),
|
|
71
|
+
homedir()
|
|
72
|
+
].join("\0")).digest("hex").slice(0, 32);
|
|
73
|
+
return cached;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
64
76
|
//#region src/lib/profile-env.ts
|
|
65
77
|
function isProviderProfile(profile) {
|
|
66
78
|
return profile.engine === "provider";
|
|
@@ -183,6 +195,16 @@ function parseSessionRoute(basePath, url) {
|
|
|
183
195
|
produced: true,
|
|
184
196
|
producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
185
197
|
};
|
|
198
|
+
if (parts[1] === "shells" && parts.length <= 4) {
|
|
199
|
+
const action = parts[3];
|
|
200
|
+
if (action !== void 0 && action !== "output" && action !== "kill") return null;
|
|
201
|
+
return {
|
|
202
|
+
id: decodeURIComponent(parts[0]),
|
|
203
|
+
shells: true,
|
|
204
|
+
shellId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2]),
|
|
205
|
+
shellAction: action
|
|
206
|
+
};
|
|
207
|
+
}
|
|
186
208
|
if (parts.length === 3 && parts[1] === "project" && parts[2] === "icon") return {
|
|
187
209
|
id: decodeURIComponent(parts[0]),
|
|
188
210
|
projectIcon: true
|
|
@@ -751,34 +773,90 @@ async function handleHostFiles(ctx, req, res, pathname) {
|
|
|
751
773
|
}
|
|
752
774
|
json(res, 404, { error: "not found" });
|
|
753
775
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
776
|
+
const HOST_ONLY_KEYS = new Set(Object.keys({
|
|
777
|
+
epoch: true,
|
|
778
|
+
queryFn: true,
|
|
779
|
+
env: true,
|
|
780
|
+
pathToClaudeCodeExecutable: true,
|
|
781
|
+
extraOptions: true,
|
|
782
|
+
instructions: true,
|
|
783
|
+
defaultApprovalTimeoutMs: true,
|
|
784
|
+
backfillHistory: true,
|
|
785
|
+
historyFn: true,
|
|
786
|
+
sessionInfoFn: true,
|
|
787
|
+
peers: true,
|
|
788
|
+
shells: true,
|
|
789
|
+
connectFn: true,
|
|
790
|
+
codexHome: true,
|
|
791
|
+
codexPathOverride: true,
|
|
792
|
+
languageModel: true,
|
|
793
|
+
tools: true,
|
|
794
|
+
maxSteps: true,
|
|
795
|
+
executor: true,
|
|
796
|
+
executableTools: true,
|
|
797
|
+
vfs: true,
|
|
798
|
+
executionLimits: true,
|
|
799
|
+
executionBackend: true,
|
|
800
|
+
toolTitles: true,
|
|
801
|
+
shouldApprove: true,
|
|
802
|
+
resolveModel: true,
|
|
803
|
+
reportMcpServers: true,
|
|
804
|
+
onClose: true,
|
|
805
|
+
restore: true
|
|
806
|
+
}));
|
|
807
|
+
function vetCreateRequest(ctx, body, auth) {
|
|
808
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return {
|
|
809
|
+
ok: false,
|
|
810
|
+
status: 400,
|
|
811
|
+
error: "request body must be a JSON object"
|
|
812
|
+
};
|
|
813
|
+
const hostOnly = Object.keys(body).filter((key) => HOST_ONLY_KEYS.has(key));
|
|
814
|
+
if (hostOnly.length > 0) return {
|
|
815
|
+
ok: false,
|
|
816
|
+
status: 400,
|
|
817
|
+
error: `host-only runner config is not accepted on a session request: ${hostOnly.join(", ")} (set it from the gateway's buildRunnerConfig hook)`
|
|
818
|
+
};
|
|
819
|
+
const req = pickCreateSessionRequest(body);
|
|
757
820
|
const { availability, factory } = ctx;
|
|
758
821
|
const refusedScope = factory.applyScope(req, auth);
|
|
759
|
-
if (refusedScope) return
|
|
822
|
+
if (refusedScope) return {
|
|
823
|
+
ok: false,
|
|
824
|
+
...refusedScope
|
|
825
|
+
};
|
|
760
826
|
const refused = factory.applyBypassPolicy(req);
|
|
761
827
|
if (refused) return {
|
|
828
|
+
ok: false,
|
|
762
829
|
status: 403,
|
|
763
830
|
error: refused
|
|
764
831
|
};
|
|
765
832
|
const resolved = factory.resolveProfile(req.profile, auth.allowedProfiles);
|
|
766
833
|
if (!resolved.ok) return {
|
|
834
|
+
ok: false,
|
|
767
835
|
status: resolved.status,
|
|
768
836
|
error: resolved.error
|
|
769
837
|
};
|
|
770
838
|
const unavailable = availability.checkAvailable(resolved.profile);
|
|
771
|
-
if (unavailable) return
|
|
839
|
+
if (unavailable) return {
|
|
840
|
+
ok: false,
|
|
841
|
+
...unavailable
|
|
842
|
+
};
|
|
772
843
|
const refusedCwd = factory.checkCwd(req, resolved.profile);
|
|
773
|
-
if (refusedCwd) return
|
|
844
|
+
if (refusedCwd) return {
|
|
845
|
+
ok: false,
|
|
846
|
+
...refusedCwd
|
|
847
|
+
};
|
|
774
848
|
const badRequest = factory.checkPermissionMode(req.permissionMode, resolved.profile) ?? factory.checkEngineGrants(req, resolved.profile);
|
|
775
849
|
if (badRequest) return {
|
|
850
|
+
ok: false,
|
|
776
851
|
status: 400,
|
|
777
852
|
error: badRequest
|
|
778
853
|
};
|
|
779
854
|
factory.stripInertFields(req, resolved.profile);
|
|
780
855
|
req.profile = resolved.profile?.name;
|
|
781
|
-
return
|
|
856
|
+
return {
|
|
857
|
+
ok: true,
|
|
858
|
+
request: req
|
|
859
|
+
};
|
|
782
860
|
}
|
|
783
861
|
//#endregion
|
|
784
862
|
//#region src/routes/jobs.ts
|
|
@@ -808,21 +886,28 @@ async function handleJobs(ctx, req, res, pathname, auth) {
|
|
|
808
886
|
}
|
|
809
887
|
if (req.method === "POST") {
|
|
810
888
|
const body = await readJsonBody(req, ctx.maxBodyBytes);
|
|
811
|
-
if (!body
|
|
889
|
+
if (!body?.session || typeof body.session !== "object") {
|
|
812
890
|
json(res, 400, { error: "session is required" });
|
|
813
891
|
return;
|
|
814
892
|
}
|
|
815
|
-
|
|
893
|
+
const prompt = body.session.prompt;
|
|
894
|
+
if (!prompt || typeof prompt !== "string") {
|
|
816
895
|
json(res, 400, { error: "session.prompt is required" });
|
|
817
896
|
return;
|
|
818
897
|
}
|
|
819
|
-
const
|
|
820
|
-
if (
|
|
821
|
-
json(res,
|
|
898
|
+
const vetted = vetCreateRequest(ctx, body.session, auth);
|
|
899
|
+
if (!vetted.ok) {
|
|
900
|
+
json(res, vetted.status, { error: vetted.error });
|
|
822
901
|
return;
|
|
823
902
|
}
|
|
824
903
|
try {
|
|
825
|
-
json(res, 201, { job: await queue.submit(
|
|
904
|
+
json(res, 201, { job: await queue.submit({
|
|
905
|
+
...body,
|
|
906
|
+
session: {
|
|
907
|
+
...vetted.request,
|
|
908
|
+
prompt
|
|
909
|
+
}
|
|
910
|
+
}) });
|
|
826
911
|
} catch (error) {
|
|
827
912
|
json(res, 400, { error: error instanceof Error ? error.message : "invalid job" });
|
|
828
913
|
}
|
|
@@ -1028,7 +1113,8 @@ const EPHEMERAL_CONFIG_KEYS = [
|
|
|
1028
1113
|
"historyFn",
|
|
1029
1114
|
"extraOptions",
|
|
1030
1115
|
"env",
|
|
1031
|
-
"peers"
|
|
1116
|
+
"peers",
|
|
1117
|
+
"instructions"
|
|
1032
1118
|
];
|
|
1033
1119
|
function toDurableRecord(record) {
|
|
1034
1120
|
const config = { ...record.config };
|
|
@@ -1047,7 +1133,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1047
1133
|
try {
|
|
1048
1134
|
raw = await readFile(path, "utf8");
|
|
1049
1135
|
} catch (error) {
|
|
1050
|
-
if (isMissing(error)) return null;
|
|
1136
|
+
if (isMissing$1(error)) return null;
|
|
1051
1137
|
options.onError?.(error, {
|
|
1052
1138
|
path,
|
|
1053
1139
|
op: "read"
|
|
@@ -1102,7 +1188,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1102
1188
|
try {
|
|
1103
1189
|
names = await readdir(dir);
|
|
1104
1190
|
} catch (error) {
|
|
1105
|
-
if (isMissing(error)) return [];
|
|
1191
|
+
if (isMissing$1(error)) return [];
|
|
1106
1192
|
options.onError?.(error, {
|
|
1107
1193
|
path: dir,
|
|
1108
1194
|
op: "read"
|
|
@@ -1126,7 +1212,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1126
1212
|
await rm(path);
|
|
1127
1213
|
return true;
|
|
1128
1214
|
} catch (error) {
|
|
1129
|
-
if (isMissing(error)) return false;
|
|
1215
|
+
if (isMissing$1(error)) return false;
|
|
1130
1216
|
options.onError?.(error, {
|
|
1131
1217
|
path,
|
|
1132
1218
|
op: "delete"
|
|
@@ -1136,7 +1222,7 @@ function createFileSessionStore(options = {}) {
|
|
|
1136
1222
|
}
|
|
1137
1223
|
};
|
|
1138
1224
|
}
|
|
1139
|
-
function isMissing(error) {
|
|
1225
|
+
function isMissing$1(error) {
|
|
1140
1226
|
return error.code === "ENOENT";
|
|
1141
1227
|
}
|
|
1142
1228
|
function parseRecord(value) {
|
|
@@ -1296,17 +1382,19 @@ const DEFAULT_TTL_MS = 3e4;
|
|
|
1296
1382
|
const SWEEP_ABOVE = 256;
|
|
1297
1383
|
var ProjectInfoService = class {
|
|
1298
1384
|
#ttlMs;
|
|
1385
|
+
#decorate;
|
|
1299
1386
|
#byCwd = /* @__PURE__ */ new Map();
|
|
1300
1387
|
constructor(options = {}) {
|
|
1301
1388
|
this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
1389
|
+
this.#decorate = options.decorate ?? ((info) => info);
|
|
1302
1390
|
}
|
|
1303
1391
|
withProject(info) {
|
|
1304
|
-
if (!info.cwd) return info;
|
|
1392
|
+
if (!info.cwd) return this.#decorate(info);
|
|
1305
1393
|
const project = this.#resolve(info.cwd).project;
|
|
1306
|
-
return project ? {
|
|
1394
|
+
return this.#decorate(project ? {
|
|
1307
1395
|
...info,
|
|
1308
1396
|
project
|
|
1309
|
-
} : info;
|
|
1397
|
+
} : info);
|
|
1310
1398
|
}
|
|
1311
1399
|
iconFor(cwd) {
|
|
1312
1400
|
if (!cwd) return;
|
|
@@ -1455,6 +1543,1062 @@ function handleProjectIcon(projects, req, res, cwd) {
|
|
|
1455
1543
|
res.end(read.data);
|
|
1456
1544
|
}
|
|
1457
1545
|
//#endregion
|
|
1546
|
+
//#region src/services/process-tree.ts
|
|
1547
|
+
const PS_ARGS = ["-eo", "pid=,ppid=,pgid="];
|
|
1548
|
+
const PS_TIMEOUT_MS = 2e3;
|
|
1549
|
+
const PS_MAX_BUFFER = 67108864;
|
|
1550
|
+
const MAX_ROUNDS = 5;
|
|
1551
|
+
function readProcessTable() {
|
|
1552
|
+
const result = spawnSync("ps", PS_ARGS, {
|
|
1553
|
+
encoding: "utf8",
|
|
1554
|
+
timeout: PS_TIMEOUT_MS,
|
|
1555
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
1556
|
+
stdio: [
|
|
1557
|
+
"ignore",
|
|
1558
|
+
"pipe",
|
|
1559
|
+
"ignore"
|
|
1560
|
+
]
|
|
1561
|
+
});
|
|
1562
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") return null;
|
|
1563
|
+
return parseProcessTable(result.stdout);
|
|
1564
|
+
}
|
|
1565
|
+
function parseProcessTable(text) {
|
|
1566
|
+
const rows = [];
|
|
1567
|
+
for (const line of text.split("\n")) {
|
|
1568
|
+
const fields = line.trim().split(/\s+/);
|
|
1569
|
+
if (fields.length !== 3) continue;
|
|
1570
|
+
const [pid, ppid, pgid] = fields.map(Number);
|
|
1571
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid) && Number.isInteger(pgid)) rows.push({
|
|
1572
|
+
pid,
|
|
1573
|
+
ppid,
|
|
1574
|
+
pgid
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
return rows;
|
|
1578
|
+
}
|
|
1579
|
+
function killProcessTrees(roots, deps) {
|
|
1580
|
+
const { self } = deps;
|
|
1581
|
+
const signal = (pid, name) => {
|
|
1582
|
+
try {
|
|
1583
|
+
deps.signal(pid, name);
|
|
1584
|
+
return true;
|
|
1585
|
+
} catch {
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
const owned = (pid) => Number.isInteger(pid) && pid > 1 && pid !== self;
|
|
1590
|
+
const live = new Set(roots.filter(owned));
|
|
1591
|
+
const closure = /* @__PURE__ */ new Map();
|
|
1592
|
+
const rootRows = /* @__PURE__ */ new Map();
|
|
1593
|
+
const foreign = [];
|
|
1594
|
+
let scanned = false;
|
|
1595
|
+
let selfPgid;
|
|
1596
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
1597
|
+
const table = readTable(deps.table);
|
|
1598
|
+
if (table === null) break;
|
|
1599
|
+
const byPid = /* @__PURE__ */ new Map();
|
|
1600
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
1601
|
+
for (const row of table) {
|
|
1602
|
+
byPid.set(row.pid, row);
|
|
1603
|
+
const siblings = byParent.get(row.ppid);
|
|
1604
|
+
if (siblings) siblings.push(row);
|
|
1605
|
+
else byParent.set(row.ppid, [row]);
|
|
1606
|
+
}
|
|
1607
|
+
let grew = false;
|
|
1608
|
+
if (!scanned) {
|
|
1609
|
+
scanned = true;
|
|
1610
|
+
selfPgid = byPid.get(self)?.pgid;
|
|
1611
|
+
for (const root of live) {
|
|
1612
|
+
const row = byPid.get(root);
|
|
1613
|
+
if (row === void 0) continue;
|
|
1614
|
+
if (row.ppid !== self) {
|
|
1615
|
+
foreign.push(root);
|
|
1616
|
+
live.delete(root);
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
rootRows.set(root, row);
|
|
1620
|
+
signal(-root, "SIGSTOP");
|
|
1621
|
+
grew = true;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
for (const root of rootRows.keys()) {
|
|
1625
|
+
const queue = [root];
|
|
1626
|
+
const seen = /* @__PURE__ */ new Set([root]);
|
|
1627
|
+
while (queue.length > 0) {
|
|
1628
|
+
const parent = queue.shift();
|
|
1629
|
+
for (const child of byParent.get(parent) ?? []) {
|
|
1630
|
+
if (!owned(child.pid) || seen.has(child.pid)) continue;
|
|
1631
|
+
seen.add(child.pid);
|
|
1632
|
+
queue.push(child.pid);
|
|
1633
|
+
if (!closure.has(child.pid)) {
|
|
1634
|
+
closure.set(child.pid, child);
|
|
1635
|
+
signal(child.pid, "SIGSTOP");
|
|
1636
|
+
grew = true;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (!grew) break;
|
|
1642
|
+
}
|
|
1643
|
+
const unreached = [];
|
|
1644
|
+
const groups = /* @__PURE__ */ new Set();
|
|
1645
|
+
for (const root of live) {
|
|
1646
|
+
groups.add(root);
|
|
1647
|
+
if (!signal(-root, "SIGKILL")) unreached.push(root);
|
|
1648
|
+
}
|
|
1649
|
+
for (const pid of closure.keys()) signal(pid, "SIGKILL");
|
|
1650
|
+
if (selfPgid !== void 0) for (const row of [...rootRows.values(), ...closure.values()]) {
|
|
1651
|
+
const group = row.pgid;
|
|
1652
|
+
if (group <= 1 || group === self || group === selfPgid || groups.has(group)) continue;
|
|
1653
|
+
groups.add(group);
|
|
1654
|
+
signal(-group, "SIGKILL");
|
|
1655
|
+
}
|
|
1656
|
+
return {
|
|
1657
|
+
scanned,
|
|
1658
|
+
descendants: closure.size,
|
|
1659
|
+
foreign,
|
|
1660
|
+
unreached
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function readTable(table) {
|
|
1664
|
+
try {
|
|
1665
|
+
return table();
|
|
1666
|
+
} catch {
|
|
1667
|
+
return null;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/services/shells.ts
|
|
1672
|
+
const NOTIFY_MS = 250;
|
|
1673
|
+
const SWEEP_INTERVAL_MS = 36e5;
|
|
1674
|
+
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
|
|
1675
|
+
const ID_CHARS = 12;
|
|
1676
|
+
const TERM = "xterm-256color";
|
|
1677
|
+
const INDEX_SUFFIX = ".json";
|
|
1678
|
+
const INDEX_VERSION = 1;
|
|
1679
|
+
const CTTY_WRAPPER = "true <>/dev/tty 2>/dev/null; exec \"$0\" -c \"$1\"";
|
|
1680
|
+
let ptyModule;
|
|
1681
|
+
async function loadPty() {
|
|
1682
|
+
if (ptyModule !== void 0) return ptyModule;
|
|
1683
|
+
try {
|
|
1684
|
+
ptyModule = await import("@lydell/node-pty");
|
|
1685
|
+
} catch {
|
|
1686
|
+
ptyModule = null;
|
|
1687
|
+
}
|
|
1688
|
+
return ptyModule;
|
|
1689
|
+
}
|
|
1690
|
+
function createShellDirectory(registry) {
|
|
1691
|
+
return {
|
|
1692
|
+
list: async (from) => registry.list(from).map(shellSummary),
|
|
1693
|
+
read: async (from, shellId, options) => {
|
|
1694
|
+
const info = registry.get(from, shellId);
|
|
1695
|
+
if (!info) return;
|
|
1696
|
+
const text = await registry.output(from, shellId, { view: "text" });
|
|
1697
|
+
if (text === void 0) return;
|
|
1698
|
+
return {
|
|
1699
|
+
shell: shellSummary(info),
|
|
1700
|
+
...shellTail(text, clampShellTail(options?.tail))
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
function shellPermitted(shells, runner, operator) {
|
|
1706
|
+
if (shells === null || !operator) return false;
|
|
1707
|
+
const info = runner.info();
|
|
1708
|
+
return (info.capabilities ?? (info.engine ? ENGINE_CAPABILITIES[info.engine] : void 0))?.hostCwd === true;
|
|
1709
|
+
}
|
|
1710
|
+
function shellChildEnv(base) {
|
|
1711
|
+
const env = {};
|
|
1712
|
+
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
1713
|
+
return env;
|
|
1714
|
+
}
|
|
1715
|
+
function clampSize(size) {
|
|
1716
|
+
return {
|
|
1717
|
+
cols: clamp(size.cols, SHELL_MIN_COLS, SHELL_MAX_COLS),
|
|
1718
|
+
rows: clamp(size.rows, SHELL_MIN_ROWS, SHELL_MAX_ROWS)
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function loginShell(env) {
|
|
1722
|
+
const shell = env.SHELL;
|
|
1723
|
+
return shell && shell.startsWith("/") ? shell : "/bin/sh";
|
|
1724
|
+
}
|
|
1725
|
+
function createShellRegistry(options) {
|
|
1726
|
+
const { generation } = options;
|
|
1727
|
+
const dir = options.artifactDir;
|
|
1728
|
+
const base = dir ?? join(tmpdir(), `workerdeck-shells-${process.pid}`);
|
|
1729
|
+
const limits = {
|
|
1730
|
+
spill: options.spillBytes ?? SHELL_SPILL_BYTES,
|
|
1731
|
+
cap: options.artifactMaxBytes ?? SHELL_ARTIFACT_MAX_BYTES,
|
|
1732
|
+
ring: options.tailRingBytes ?? SHELL_TAIL_RING_BYTES
|
|
1733
|
+
};
|
|
1734
|
+
const tailFlushMs = options.tailFlushMs ?? SHELL_TAIL_FLUSH_MS;
|
|
1735
|
+
const ttlMs = options.artifactTtlMs ?? SHELL_ARTIFACT_TTL_MS;
|
|
1736
|
+
const perSession = options.maxRunningPerSession ?? SHELL_MAX_RUNNING_PER_SESSION;
|
|
1737
|
+
const total = options.maxRunningTotal ?? SHELL_MAX_RUNNING_TOTAL;
|
|
1738
|
+
const sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
|
|
1739
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1740
|
+
const loading = /* @__PURE__ */ new Map();
|
|
1741
|
+
let sweeper;
|
|
1742
|
+
let stopped = false;
|
|
1743
|
+
const report = (error, context) => options.onError?.(error, context);
|
|
1744
|
+
const indexPath = (sessionId) => join(base, `${encodeURIComponent(sessionId)}${INDEX_SUFFIX}`);
|
|
1745
|
+
const pathsFor = (sessionId, shellId) => {
|
|
1746
|
+
const encoded = encodeURIComponent(sessionId);
|
|
1747
|
+
const sessionDir = join(base, encoded);
|
|
1748
|
+
return {
|
|
1749
|
+
dir: sessionDir,
|
|
1750
|
+
raw: join(sessionDir, `${shellId}.raw`),
|
|
1751
|
+
tail: join(sessionDir, `${shellId}.tail.raw`),
|
|
1752
|
+
rel: `${encoded}/${shellId}.raw`
|
|
1753
|
+
};
|
|
1754
|
+
};
|
|
1755
|
+
const find = (sessionId, id) => sessions.get(sessionId)?.entries.get(id);
|
|
1756
|
+
const runningAll = () => [...sessions.values()].flatMap(runningIn);
|
|
1757
|
+
const persist = (state) => {
|
|
1758
|
+
if (!dir || state.pending) return;
|
|
1759
|
+
state.pending = true;
|
|
1760
|
+
state.chain = state.chain.then(async () => {
|
|
1761
|
+
state.pending = false;
|
|
1762
|
+
const payload = JSON.stringify(serialize(state));
|
|
1763
|
+
await mkdir(dir, {
|
|
1764
|
+
recursive: true,
|
|
1765
|
+
mode: 448
|
|
1766
|
+
});
|
|
1767
|
+
const path = indexPath(state.sessionId);
|
|
1768
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1769
|
+
await writeFile(temp, payload, { mode: 384 });
|
|
1770
|
+
await rename(temp, path);
|
|
1771
|
+
}).catch((error) => report(error, {
|
|
1772
|
+
op: "index",
|
|
1773
|
+
sessionId: state.sessionId
|
|
1774
|
+
}));
|
|
1775
|
+
};
|
|
1776
|
+
const readIndex = async (sessionId) => {
|
|
1777
|
+
const state = freshSession(sessionId);
|
|
1778
|
+
if (!dir) return state;
|
|
1779
|
+
let raw;
|
|
1780
|
+
try {
|
|
1781
|
+
raw = await readFile(indexPath(sessionId), "utf8");
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
if (!isMissing(error)) report(error, {
|
|
1784
|
+
op: "index",
|
|
1785
|
+
sessionId
|
|
1786
|
+
});
|
|
1787
|
+
return state;
|
|
1788
|
+
}
|
|
1789
|
+
let index;
|
|
1790
|
+
try {
|
|
1791
|
+
index = parseIndex(JSON.parse(raw));
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
report(error, {
|
|
1794
|
+
op: "index",
|
|
1795
|
+
sessionId
|
|
1796
|
+
});
|
|
1797
|
+
return state;
|
|
1798
|
+
}
|
|
1799
|
+
state.nextOrdinal = index.nextOrdinal;
|
|
1800
|
+
const now = Date.now();
|
|
1801
|
+
let dirty = false;
|
|
1802
|
+
for (const record of index.shells) {
|
|
1803
|
+
const { generation: recordGeneration, output, artifact, ...info } = record;
|
|
1804
|
+
if (info.status === "running" && recordGeneration !== generation) {
|
|
1805
|
+
info.status = "exited";
|
|
1806
|
+
info.endReason = "server_restarted";
|
|
1807
|
+
info.endedAt = now;
|
|
1808
|
+
delete info.exitCode;
|
|
1809
|
+
delete info.signal;
|
|
1810
|
+
dirty = true;
|
|
1811
|
+
}
|
|
1812
|
+
state.entries.set(info.id, {
|
|
1813
|
+
info,
|
|
1814
|
+
generation: recordGeneration,
|
|
1815
|
+
artifact: new StoredArtifact(pathsFor(sessionId, info.id), info, {
|
|
1816
|
+
output,
|
|
1817
|
+
artifact
|
|
1818
|
+
}),
|
|
1819
|
+
sinks: /* @__PURE__ */ new Set(),
|
|
1820
|
+
listeners: /* @__PURE__ */ new Set()
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
if (dirty) persist(state);
|
|
1824
|
+
return state;
|
|
1825
|
+
};
|
|
1826
|
+
const load = (sessionId) => {
|
|
1827
|
+
const held = sessions.get(sessionId);
|
|
1828
|
+
if (held) return Promise.resolve(held);
|
|
1829
|
+
let inflight = loading.get(sessionId);
|
|
1830
|
+
if (!inflight) {
|
|
1831
|
+
inflight = readIndex(sessionId).then((state) => {
|
|
1832
|
+
sessions.set(sessionId, state);
|
|
1833
|
+
loading.delete(sessionId);
|
|
1834
|
+
return state;
|
|
1835
|
+
});
|
|
1836
|
+
loading.set(sessionId, inflight);
|
|
1837
|
+
}
|
|
1838
|
+
return inflight;
|
|
1839
|
+
};
|
|
1840
|
+
const fire = (entry) => {
|
|
1841
|
+
for (const listener of entry.listeners) try {
|
|
1842
|
+
listener();
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
report(error, {
|
|
1845
|
+
op: "listener",
|
|
1846
|
+
sessionId: entry.info.sessionId,
|
|
1847
|
+
shellId: entry.info.id
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
const scheduleNotify = (entry) => {
|
|
1852
|
+
if (entry.notify) return;
|
|
1853
|
+
entry.notify = setTimeout(() => {
|
|
1854
|
+
entry.notify = void 0;
|
|
1855
|
+
if (entry.info.status === "running") fire(entry);
|
|
1856
|
+
}, NOTIFY_MS);
|
|
1857
|
+
entry.notify.unref();
|
|
1858
|
+
};
|
|
1859
|
+
const settle = (state, entry, reason) => {
|
|
1860
|
+
if (entry.info.status !== "running") return;
|
|
1861
|
+
entry.info.status = "exited";
|
|
1862
|
+
entry.info.endedAt = Date.now();
|
|
1863
|
+
entry.info.endReason = reason;
|
|
1864
|
+
clearTimeout(entry.clock);
|
|
1865
|
+
clearTimeout(entry.notify);
|
|
1866
|
+
clearInterval(entry.tailTimer);
|
|
1867
|
+
entry.clock = entry.notify = entry.tailTimer = void 0;
|
|
1868
|
+
entry.child = void 0;
|
|
1869
|
+
entry.pid = void 0;
|
|
1870
|
+
if (entry.artifact instanceof LiveArtifact) entry.artifact.close();
|
|
1871
|
+
for (const sink of entry.sinks) try {
|
|
1872
|
+
sink.end(reason);
|
|
1873
|
+
} catch {}
|
|
1874
|
+
entry.sinks.clear();
|
|
1875
|
+
persist(state);
|
|
1876
|
+
fire(entry);
|
|
1877
|
+
};
|
|
1878
|
+
const treeDeps = {
|
|
1879
|
+
table: options.processTable ?? readProcessTable,
|
|
1880
|
+
signal: (pid, name) => void process.kill(pid, name),
|
|
1881
|
+
self: process.pid
|
|
1882
|
+
};
|
|
1883
|
+
const killTrees = (entries) => {
|
|
1884
|
+
const live = entries.flatMap((entry) => entry.pid === void 0 ? [] : [{
|
|
1885
|
+
entry,
|
|
1886
|
+
pid: entry.pid
|
|
1887
|
+
}]);
|
|
1888
|
+
if (live.length === 0) return;
|
|
1889
|
+
const result = killProcessTrees(live.map(({ pid }) => pid), treeDeps);
|
|
1890
|
+
for (const { entry, pid } of live) {
|
|
1891
|
+
if (result.unreached.includes(pid)) try {
|
|
1892
|
+
entry.child?.kill("SIGKILL");
|
|
1893
|
+
} catch {}
|
|
1894
|
+
const context = {
|
|
1895
|
+
op: "kill",
|
|
1896
|
+
sessionId: entry.info.sessionId,
|
|
1897
|
+
shellId: entry.info.id
|
|
1898
|
+
};
|
|
1899
|
+
if (!result.scanned) report(/* @__PURE__ */ new Error("no process table (ps), only the process group was signalled"), context);
|
|
1900
|
+
else if (result.foreign.includes(pid)) report(/* @__PURE__ */ new Error(`pid ${pid} is no longer this shell's child and was left alone`), context);
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
const killEntries = (targets, reason) => {
|
|
1904
|
+
const running = targets.filter(({ entry }) => entry.info.status === "running");
|
|
1905
|
+
killTrees(running.map(({ entry }) => entry));
|
|
1906
|
+
for (const { state, entry } of running) settle(state, entry, reason);
|
|
1907
|
+
return running.length;
|
|
1908
|
+
};
|
|
1909
|
+
const kill = (state, entry, reason) => {
|
|
1910
|
+
killEntries([{
|
|
1911
|
+
state,
|
|
1912
|
+
entry
|
|
1913
|
+
}], reason);
|
|
1914
|
+
};
|
|
1915
|
+
const killSession = (sessionId, reason) => {
|
|
1916
|
+
const state = sessions.get(sessionId);
|
|
1917
|
+
if (!state) return;
|
|
1918
|
+
killEntries(runningIn(state).map((entry) => ({
|
|
1919
|
+
state,
|
|
1920
|
+
entry
|
|
1921
|
+
})), reason);
|
|
1922
|
+
};
|
|
1923
|
+
const spawn = async ({ runner, command, owner }) => {
|
|
1924
|
+
if (stopped) throw new Error("the gateway is shutting down");
|
|
1925
|
+
const sessionId = runner.id;
|
|
1926
|
+
const state = await load(sessionId);
|
|
1927
|
+
const pty = await loadPty();
|
|
1928
|
+
if (!pty) throw new Error(SHELL_REFUSAL);
|
|
1929
|
+
const cwd = runner.info().cwd;
|
|
1930
|
+
if (!(await stat(cwd).catch(() => void 0))?.isDirectory()) throw new Error(`the session's cwd is not a directory: ${cwd}`);
|
|
1931
|
+
const inSession = runningIn(state).length;
|
|
1932
|
+
if (inSession >= perSession) throw new Error(`this session already has ${inSession} shells running (the limit is ${perSession})`);
|
|
1933
|
+
const overall = runningAll().length;
|
|
1934
|
+
if (overall >= total) throw new Error(`the gateway already has ${overall} shells running (the limit is ${total})`);
|
|
1935
|
+
const id = mintShellId();
|
|
1936
|
+
const info = {
|
|
1937
|
+
id,
|
|
1938
|
+
sessionId,
|
|
1939
|
+
ordinal: state.nextOrdinal++,
|
|
1940
|
+
command,
|
|
1941
|
+
label: shellLabel(command),
|
|
1942
|
+
cwd,
|
|
1943
|
+
owner,
|
|
1944
|
+
status: "running",
|
|
1945
|
+
startedAt: Date.now(),
|
|
1946
|
+
bytes: 0,
|
|
1947
|
+
cols: SHELL_COLS,
|
|
1948
|
+
rows: SHELL_ROWS
|
|
1949
|
+
};
|
|
1950
|
+
const artifact = new LiveArtifact(pathsFor(sessionId, id), limits, (error) => report(error, {
|
|
1951
|
+
op: "artifact",
|
|
1952
|
+
sessionId,
|
|
1953
|
+
shellId: id
|
|
1954
|
+
}));
|
|
1955
|
+
const entry = {
|
|
1956
|
+
info,
|
|
1957
|
+
generation,
|
|
1958
|
+
artifact,
|
|
1959
|
+
sinks: /* @__PURE__ */ new Set(),
|
|
1960
|
+
listeners: /* @__PURE__ */ new Set()
|
|
1961
|
+
};
|
|
1962
|
+
state.entries.set(id, entry);
|
|
1963
|
+
const env = shellChildEnv(process.env);
|
|
1964
|
+
env.TERM = TERM;
|
|
1965
|
+
env.COLORTERM = "truecolor";
|
|
1966
|
+
env.PWD = cwd;
|
|
1967
|
+
let child;
|
|
1968
|
+
try {
|
|
1969
|
+
child = pty.spawn("/bin/sh", [
|
|
1970
|
+
"-c",
|
|
1971
|
+
CTTY_WRAPPER,
|
|
1972
|
+
loginShell(process.env),
|
|
1973
|
+
command
|
|
1974
|
+
], {
|
|
1975
|
+
name: TERM,
|
|
1976
|
+
cols: SHELL_COLS,
|
|
1977
|
+
rows: SHELL_ROWS,
|
|
1978
|
+
cwd,
|
|
1979
|
+
env
|
|
1980
|
+
});
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
settle(state, entry, "spawn_failed");
|
|
1983
|
+
throw error instanceof Error ? error : /* @__PURE__ */ new Error("failed to start the shell");
|
|
1984
|
+
}
|
|
1985
|
+
entry.child = child;
|
|
1986
|
+
entry.pid = child.pid;
|
|
1987
|
+
child.onData((data) => {
|
|
1988
|
+
if (entry.info.status !== "running") return;
|
|
1989
|
+
const changed = artifact.append(data);
|
|
1990
|
+
entry.info.bytes = artifact.bytes;
|
|
1991
|
+
if (changed) {
|
|
1992
|
+
if (artifact.capped && !entry.info.capped) {
|
|
1993
|
+
entry.info.capped = true;
|
|
1994
|
+
entry.tailTimer = setInterval(() => artifact.writeTail(), tailFlushMs);
|
|
1995
|
+
entry.tailTimer.unref();
|
|
1996
|
+
}
|
|
1997
|
+
persist(state);
|
|
1998
|
+
}
|
|
1999
|
+
for (const sink of entry.sinks) try {
|
|
2000
|
+
sink.write(data);
|
|
2001
|
+
} catch {
|
|
2002
|
+
entry.sinks.delete(sink);
|
|
2003
|
+
}
|
|
2004
|
+
scheduleNotify(entry);
|
|
2005
|
+
});
|
|
2006
|
+
child.onExit(({ exitCode, signal }) => {
|
|
2007
|
+
if (entry.info.status !== "running") return;
|
|
2008
|
+
if (signal) entry.info.signal = signal;
|
|
2009
|
+
else entry.info.exitCode = exitCode;
|
|
2010
|
+
settle(state, entry, "exit");
|
|
2011
|
+
});
|
|
2012
|
+
if (options.timeoutMs !== void 0 && options.timeoutMs > 0) {
|
|
2013
|
+
entry.clock = setTimeout(() => kill(state, entry, "timeout"), options.timeoutMs);
|
|
2014
|
+
entry.clock.unref();
|
|
2015
|
+
}
|
|
2016
|
+
persist(state);
|
|
2017
|
+
return {
|
|
2018
|
+
shell: { ...info },
|
|
2019
|
+
source: sourceFor(entry, artifact)
|
|
2020
|
+
};
|
|
2021
|
+
};
|
|
2022
|
+
const sweep = async () => {
|
|
2023
|
+
const now = Date.now();
|
|
2024
|
+
for (const state of sessions.values()) {
|
|
2025
|
+
let dirty = false;
|
|
2026
|
+
for (const entry of state.entries.values()) {
|
|
2027
|
+
const { status, endedAt } = entry.info;
|
|
2028
|
+
if (status !== "exited" || endedAt === void 0 || now - endedAt < ttlMs) continue;
|
|
2029
|
+
state.entries.delete(entry.info.id);
|
|
2030
|
+
dirty = true;
|
|
2031
|
+
try {
|
|
2032
|
+
await entry.artifact.remove();
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
report(error, {
|
|
2035
|
+
op: "sweep",
|
|
2036
|
+
sessionId: state.sessionId,
|
|
2037
|
+
shellId: entry.info.id
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
if (dirty) persist(state);
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
return {
|
|
2045
|
+
spawn,
|
|
2046
|
+
get: (sessionId, shellId) => {
|
|
2047
|
+
const entry = find(sessionId, shellId);
|
|
2048
|
+
return entry ? { ...entry.info } : void 0;
|
|
2049
|
+
},
|
|
2050
|
+
list: (sessionId) => {
|
|
2051
|
+
const state = sessions.get(sessionId);
|
|
2052
|
+
if (!state) return [];
|
|
2053
|
+
return [...state.entries.values()].map((entry) => ({ ...entry.info })).sort((a, b) => b.ordinal - a.ordinal);
|
|
2054
|
+
},
|
|
2055
|
+
running: () => runningAll().map((entry) => ({ ...entry.info })),
|
|
2056
|
+
kill: (sessionId, shellId, reason = "killed") => {
|
|
2057
|
+
const state = sessions.get(sessionId);
|
|
2058
|
+
const entry = state?.entries.get(shellId);
|
|
2059
|
+
if (!state || !entry) return;
|
|
2060
|
+
kill(state, entry, reason);
|
|
2061
|
+
return { ...entry.info };
|
|
2062
|
+
},
|
|
2063
|
+
killAll: (reason) => {
|
|
2064
|
+
stopped = true;
|
|
2065
|
+
if (sweeper) {
|
|
2066
|
+
clearInterval(sweeper);
|
|
2067
|
+
sweeper = void 0;
|
|
2068
|
+
}
|
|
2069
|
+
return killEntries([...sessions.values()].flatMap((state) => runningIn(state).map((entry) => ({
|
|
2070
|
+
state,
|
|
2071
|
+
entry
|
|
2072
|
+
}))), reason);
|
|
2073
|
+
},
|
|
2074
|
+
killAllSync: () => {
|
|
2075
|
+
killTrees(runningAll());
|
|
2076
|
+
},
|
|
2077
|
+
attach: async (sessionId, shellId, sink) => {
|
|
2078
|
+
const entry = find(sessionId, shellId);
|
|
2079
|
+
if (!entry) throw new Error("unknown shell");
|
|
2080
|
+
const pending = [];
|
|
2081
|
+
let live = false;
|
|
2082
|
+
let ended;
|
|
2083
|
+
const proxy = {
|
|
2084
|
+
write: (data) => {
|
|
2085
|
+
if (live) sink.write(data);
|
|
2086
|
+
else pending.push(data);
|
|
2087
|
+
},
|
|
2088
|
+
end: (reason) => {
|
|
2089
|
+
if (live) sink.end(reason);
|
|
2090
|
+
else ended = reason;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
if (entry.info.status === "running") entry.sinks.add(proxy);
|
|
2094
|
+
let replay;
|
|
2095
|
+
try {
|
|
2096
|
+
replay = await entry.artifact.replay(SHELL_ATTACH_REPLAY_BYTES);
|
|
2097
|
+
} catch (error) {
|
|
2098
|
+
entry.sinks.delete(proxy);
|
|
2099
|
+
throw error;
|
|
2100
|
+
}
|
|
2101
|
+
live = true;
|
|
2102
|
+
if (ended !== void 0) {
|
|
2103
|
+
const reason = ended;
|
|
2104
|
+
queueMicrotask(() => sink.end(reason));
|
|
2105
|
+
}
|
|
2106
|
+
return {
|
|
2107
|
+
shell: { ...entry.info },
|
|
2108
|
+
scrollback: replay.toString("utf8") + pending.join(""),
|
|
2109
|
+
detach: () => {
|
|
2110
|
+
entry.sinks.delete(proxy);
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
},
|
|
2114
|
+
write: (sessionId, shellId, data) => {
|
|
2115
|
+
const entry = find(sessionId, shellId);
|
|
2116
|
+
if (!entry) throw new Error("unknown shell");
|
|
2117
|
+
if (typeof data !== "string" || data.length > SHELL_INPUT_MAX) throw new Error(`shell input must be a string of at most ${SHELL_INPUT_MAX} characters`);
|
|
2118
|
+
if (!entry.child) throw new Error("the shell has exited");
|
|
2119
|
+
entry.child.write(data);
|
|
2120
|
+
},
|
|
2121
|
+
resize: (sessionId, shellId, size) => {
|
|
2122
|
+
const entry = find(sessionId, shellId);
|
|
2123
|
+
if (!entry) throw new Error("unknown shell");
|
|
2124
|
+
const next = clampSize(size);
|
|
2125
|
+
if (entry.child) {
|
|
2126
|
+
try {
|
|
2127
|
+
entry.child.resize(next.cols, next.rows);
|
|
2128
|
+
} catch {}
|
|
2129
|
+
entry.info.cols = next.cols;
|
|
2130
|
+
entry.info.rows = next.rows;
|
|
2131
|
+
}
|
|
2132
|
+
return next;
|
|
2133
|
+
},
|
|
2134
|
+
output: async (sessionId, shellId, query) => {
|
|
2135
|
+
const entry = find(sessionId, shellId);
|
|
2136
|
+
if (!entry) return;
|
|
2137
|
+
if (query.tail === void 0 && query.view === "text") return entry.artifact.textView();
|
|
2138
|
+
const text = (await entry.artifact.read(query.tail)).toString("utf8");
|
|
2139
|
+
return query.view === "raw" ? text : ttyText(text);
|
|
2140
|
+
},
|
|
2141
|
+
hydrate: async () => {
|
|
2142
|
+
if (dir) {
|
|
2143
|
+
let names = [];
|
|
2144
|
+
try {
|
|
2145
|
+
names = await readdir(dir);
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
if (!isMissing(error)) report(error, { op: "index" });
|
|
2148
|
+
}
|
|
2149
|
+
for (const name of names) {
|
|
2150
|
+
if (!name.endsWith(INDEX_SUFFIX)) continue;
|
|
2151
|
+
let sessionId;
|
|
2152
|
+
try {
|
|
2153
|
+
sessionId = decodeURIComponent(name.slice(0, -5));
|
|
2154
|
+
} catch {
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
await load(sessionId);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
await sweep();
|
|
2161
|
+
if (!sweeper && !stopped) {
|
|
2162
|
+
sweeper = setInterval(() => void sweep(), sweepIntervalMs);
|
|
2163
|
+
sweeper.unref();
|
|
2164
|
+
}
|
|
2165
|
+
},
|
|
2166
|
+
sweep,
|
|
2167
|
+
flush: async () => {
|
|
2168
|
+
const states = [...sessions.values()];
|
|
2169
|
+
await Promise.all(states.flatMap((state) => [...state.entries.values()].map((entry) => entry.artifact.idle())));
|
|
2170
|
+
await Promise.all(states.map((state) => state.chain));
|
|
2171
|
+
if (!dir && stopped) await rm(base, {
|
|
2172
|
+
recursive: true,
|
|
2173
|
+
force: true
|
|
2174
|
+
}).catch(() => {});
|
|
2175
|
+
},
|
|
2176
|
+
decorate: (info) => {
|
|
2177
|
+
const state = sessions.get(info.id);
|
|
2178
|
+
if (!state) return info;
|
|
2179
|
+
const now = Date.now();
|
|
2180
|
+
const shells = [...state.entries.values()].filter((entry) => {
|
|
2181
|
+
const { status, exitCode, endedAt } = entry.info;
|
|
2182
|
+
return status === "running" || typeof exitCode === "number" && exitCode !== 0 && endedAt !== void 0 && now - endedAt < SHELL_LINGER_MS;
|
|
2183
|
+
}).map((entry) => ({ ...entry.info }));
|
|
2184
|
+
return shells.length > 0 ? {
|
|
2185
|
+
...info,
|
|
2186
|
+
shells
|
|
2187
|
+
} : info;
|
|
2188
|
+
},
|
|
2189
|
+
watch: (runner) => runner.subscribe((event) => {
|
|
2190
|
+
if (event.type === "session_closed" || event.type === "status_changed" && event.status === "parked") killSession(runner.id, "killed");
|
|
2191
|
+
}, runner.info().lastSeq)
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
var LiveArtifact = class {
|
|
2195
|
+
#paths;
|
|
2196
|
+
#limits;
|
|
2197
|
+
#onError;
|
|
2198
|
+
#ring;
|
|
2199
|
+
#ringAt = 0;
|
|
2200
|
+
#ringLen = 0;
|
|
2201
|
+
#chunks = [];
|
|
2202
|
+
#chunkBytes = 0;
|
|
2203
|
+
#bytes = 0;
|
|
2204
|
+
#spilled = false;
|
|
2205
|
+
#capped = false;
|
|
2206
|
+
#closed = false;
|
|
2207
|
+
#broken = false;
|
|
2208
|
+
#io = Promise.resolve();
|
|
2209
|
+
#handle;
|
|
2210
|
+
#done = "";
|
|
2211
|
+
#pending = "";
|
|
2212
|
+
#textCache;
|
|
2213
|
+
constructor(paths, limits, onError) {
|
|
2214
|
+
this.#paths = paths;
|
|
2215
|
+
this.#limits = limits;
|
|
2216
|
+
this.#onError = onError;
|
|
2217
|
+
this.#ring = Buffer.alloc(Math.max(0, limits.ring));
|
|
2218
|
+
}
|
|
2219
|
+
get bytes() {
|
|
2220
|
+
return this.#bytes;
|
|
2221
|
+
}
|
|
2222
|
+
get capped() {
|
|
2223
|
+
return this.#capped;
|
|
2224
|
+
}
|
|
2225
|
+
append(data) {
|
|
2226
|
+
const chunk = Buffer.from(data, "utf8");
|
|
2227
|
+
const before = this.#bytes;
|
|
2228
|
+
this.#bytes += chunk.length;
|
|
2229
|
+
this.#ringPush(chunk);
|
|
2230
|
+
this.#textCache = void 0;
|
|
2231
|
+
if (this.#capped) return false;
|
|
2232
|
+
const room = Math.max(0, this.#limits.cap - before);
|
|
2233
|
+
const kept = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
2234
|
+
this.#textAppend(kept === chunk ? data : kept.toString("utf8"));
|
|
2235
|
+
let changed = false;
|
|
2236
|
+
if (this.#spilled) this.#write(kept);
|
|
2237
|
+
else {
|
|
2238
|
+
this.#chunks.push(kept);
|
|
2239
|
+
this.#chunkBytes += kept.length;
|
|
2240
|
+
if (this.#chunkBytes > this.#limits.spill) {
|
|
2241
|
+
this.#spill();
|
|
2242
|
+
changed = true;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
if (this.#bytes > this.#limits.cap) {
|
|
2246
|
+
this.#capped = true;
|
|
2247
|
+
changed = true;
|
|
2248
|
+
}
|
|
2249
|
+
return changed;
|
|
2250
|
+
}
|
|
2251
|
+
text() {
|
|
2252
|
+
if (this.#textCache === void 0) {
|
|
2253
|
+
let text = this.#done + ttyText(this.#pending);
|
|
2254
|
+
if (this.#capped) {
|
|
2255
|
+
const after = this.#bytes - this.#limits.cap;
|
|
2256
|
+
const kept = Math.min(after, this.#ringLen);
|
|
2257
|
+
if (kept > 0) text += (after > kept ? "\n" : "") + ttyText(this.tail(kept).toString("utf8"));
|
|
2258
|
+
}
|
|
2259
|
+
this.#textCache = text;
|
|
2260
|
+
}
|
|
2261
|
+
return this.#textCache;
|
|
2262
|
+
}
|
|
2263
|
+
textView() {
|
|
2264
|
+
return Promise.resolve(this.text());
|
|
2265
|
+
}
|
|
2266
|
+
tail(n = this.#ringLen) {
|
|
2267
|
+
const size = this.#ring.length;
|
|
2268
|
+
const take = Math.max(0, Math.min(n, this.#ringLen));
|
|
2269
|
+
const out = Buffer.allocUnsafe(take);
|
|
2270
|
+
if (take === 0) return out;
|
|
2271
|
+
const start = (this.#ringAt - take + size) % size;
|
|
2272
|
+
const first = Math.min(take, size - start);
|
|
2273
|
+
this.#ring.copy(out, 0, start, start + first);
|
|
2274
|
+
if (first < take) this.#ring.copy(out, first, 0, take - first);
|
|
2275
|
+
return out;
|
|
2276
|
+
}
|
|
2277
|
+
replay(max) {
|
|
2278
|
+
const want = Math.max(0, Math.floor(max));
|
|
2279
|
+
if (!this.#spilled) {
|
|
2280
|
+
const all = Buffer.concat(this.#chunks, this.#chunkBytes);
|
|
2281
|
+
return Promise.resolve(all.subarray(Math.max(0, all.length - want)));
|
|
2282
|
+
}
|
|
2283
|
+
if (this.#capped || want <= this.#ringLen) return Promise.resolve(this.tail(want));
|
|
2284
|
+
const end = this.#bytes;
|
|
2285
|
+
return this.#io.then(() => readRange(this.#paths.raw, Math.max(0, end - want), end));
|
|
2286
|
+
}
|
|
2287
|
+
async read(tail) {
|
|
2288
|
+
if (tail !== void 0) return this.replay(tail);
|
|
2289
|
+
if (!this.#spilled) return Buffer.concat(this.#chunks, this.#chunkBytes);
|
|
2290
|
+
await this.#io;
|
|
2291
|
+
return readWhole(this.#paths.raw);
|
|
2292
|
+
}
|
|
2293
|
+
stored() {
|
|
2294
|
+
return this.#spilled ? { artifact: this.#paths.rel } : { output: Buffer.concat(this.#chunks, this.#chunkBytes).toString("utf8") };
|
|
2295
|
+
}
|
|
2296
|
+
writeTail() {
|
|
2297
|
+
const tail = this.tail();
|
|
2298
|
+
this.#queue(async () => {
|
|
2299
|
+
await mkdir(this.#paths.dir, {
|
|
2300
|
+
recursive: true,
|
|
2301
|
+
mode: 448
|
|
2302
|
+
});
|
|
2303
|
+
const temp = `${this.#paths.tail}.${process.pid}.tmp`;
|
|
2304
|
+
await writeFile(temp, tail, { mode: 384 });
|
|
2305
|
+
await rename(temp, this.#paths.tail);
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
close() {
|
|
2309
|
+
if (this.#closed) return;
|
|
2310
|
+
this.#closed = true;
|
|
2311
|
+
if (this.#capped) this.writeTail();
|
|
2312
|
+
this.#io = this.#io.then(async () => {
|
|
2313
|
+
const handle = this.#handle;
|
|
2314
|
+
this.#handle = void 0;
|
|
2315
|
+
await handle?.close().catch(() => {});
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
idle() {
|
|
2319
|
+
return this.#io;
|
|
2320
|
+
}
|
|
2321
|
+
async remove() {
|
|
2322
|
+
this.close();
|
|
2323
|
+
await this.#io;
|
|
2324
|
+
await rm(this.#paths.raw, { force: true });
|
|
2325
|
+
await rm(this.#paths.tail, { force: true });
|
|
2326
|
+
}
|
|
2327
|
+
#textAppend(data) {
|
|
2328
|
+
const newline = data.lastIndexOf("\n");
|
|
2329
|
+
if (newline === -1) {
|
|
2330
|
+
this.#pending += data;
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
this.#done += ttyText(this.#pending + data.slice(0, newline + 1));
|
|
2334
|
+
this.#pending = data.slice(newline + 1);
|
|
2335
|
+
}
|
|
2336
|
+
#ringPush(chunk) {
|
|
2337
|
+
const size = this.#ring.length;
|
|
2338
|
+
if (size === 0 || chunk.length === 0) return;
|
|
2339
|
+
if (chunk.length >= size) {
|
|
2340
|
+
chunk.copy(this.#ring, 0, chunk.length - size);
|
|
2341
|
+
this.#ringAt = 0;
|
|
2342
|
+
this.#ringLen = size;
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const first = Math.min(chunk.length, size - this.#ringAt);
|
|
2346
|
+
chunk.copy(this.#ring, this.#ringAt, 0, first);
|
|
2347
|
+
if (first < chunk.length) chunk.copy(this.#ring, 0, first);
|
|
2348
|
+
this.#ringAt = (this.#ringAt + chunk.length) % size;
|
|
2349
|
+
this.#ringLen = Math.min(size, this.#ringLen + chunk.length);
|
|
2350
|
+
}
|
|
2351
|
+
#spill() {
|
|
2352
|
+
this.#spilled = true;
|
|
2353
|
+
const buffered = Buffer.concat(this.#chunks, this.#chunkBytes);
|
|
2354
|
+
this.#chunks = [];
|
|
2355
|
+
this.#chunkBytes = 0;
|
|
2356
|
+
this.#queue(async () => {
|
|
2357
|
+
await mkdir(this.#paths.dir, {
|
|
2358
|
+
recursive: true,
|
|
2359
|
+
mode: 448
|
|
2360
|
+
});
|
|
2361
|
+
this.#handle = await open(this.#paths.raw, "w", 384);
|
|
2362
|
+
await this.#handle.write(buffered);
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
#write(chunk) {
|
|
2366
|
+
if (chunk.length === 0) return;
|
|
2367
|
+
this.#queue(async () => {
|
|
2368
|
+
await this.#handle?.write(chunk);
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
#queue(task) {
|
|
2372
|
+
this.#io = this.#io.then(async () => {
|
|
2373
|
+
if (this.#broken) return;
|
|
2374
|
+
try {
|
|
2375
|
+
await task();
|
|
2376
|
+
} catch (error) {
|
|
2377
|
+
this.#broken = true;
|
|
2378
|
+
this.#onError(error);
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
var StoredArtifact = class {
|
|
2384
|
+
#paths;
|
|
2385
|
+
#bytes;
|
|
2386
|
+
#capped;
|
|
2387
|
+
#stored;
|
|
2388
|
+
constructor(paths, info, stored) {
|
|
2389
|
+
this.#paths = paths;
|
|
2390
|
+
this.#bytes = info.bytes;
|
|
2391
|
+
this.#capped = info.capped === true;
|
|
2392
|
+
this.#stored = stored.artifact !== void 0 ? { artifact: stored.artifact } : { output: stored.output ?? "" };
|
|
2393
|
+
}
|
|
2394
|
+
async read(tail) {
|
|
2395
|
+
if (tail !== void 0) return this.replay(tail);
|
|
2396
|
+
return this.#head();
|
|
2397
|
+
}
|
|
2398
|
+
async replay(max) {
|
|
2399
|
+
const want = Math.max(0, Math.floor(max));
|
|
2400
|
+
if (this.#capped) {
|
|
2401
|
+
const ring = await readWhole(this.#paths.tail);
|
|
2402
|
+
return ring.subarray(Math.max(0, ring.length - want));
|
|
2403
|
+
}
|
|
2404
|
+
if (this.#stored.output !== void 0) {
|
|
2405
|
+
const all = Buffer.from(this.#stored.output, "utf8");
|
|
2406
|
+
return all.subarray(Math.max(0, all.length - want));
|
|
2407
|
+
}
|
|
2408
|
+
const size = await stat(this.#paths.raw).then((s) => s.size, () => 0);
|
|
2409
|
+
return readRange(this.#paths.raw, Math.max(0, size - want), size);
|
|
2410
|
+
}
|
|
2411
|
+
async textView() {
|
|
2412
|
+
const head = await this.#head();
|
|
2413
|
+
let text = ttyText(head.toString("utf8"));
|
|
2414
|
+
if (this.#capped) {
|
|
2415
|
+
const ring = await readWhole(this.#paths.tail);
|
|
2416
|
+
const after = this.#bytes - head.length;
|
|
2417
|
+
const kept = Math.min(after, ring.length);
|
|
2418
|
+
if (kept > 0) text += (after > kept ? "\n" : "") + ttyText(ring.subarray(ring.length - kept).toString("utf8"));
|
|
2419
|
+
}
|
|
2420
|
+
return text;
|
|
2421
|
+
}
|
|
2422
|
+
stored() {
|
|
2423
|
+
return this.#stored;
|
|
2424
|
+
}
|
|
2425
|
+
idle() {
|
|
2426
|
+
return Promise.resolve();
|
|
2427
|
+
}
|
|
2428
|
+
async remove() {
|
|
2429
|
+
await rm(this.#paths.raw, { force: true });
|
|
2430
|
+
await rm(this.#paths.tail, { force: true });
|
|
2431
|
+
}
|
|
2432
|
+
#head() {
|
|
2433
|
+
return this.#stored.output !== void 0 ? Promise.resolve(Buffer.from(this.#stored.output, "utf8")) : readWhole(this.#paths.raw);
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
function serialize(state) {
|
|
2437
|
+
return {
|
|
2438
|
+
version: INDEX_VERSION,
|
|
2439
|
+
sessionId: state.sessionId,
|
|
2440
|
+
nextOrdinal: state.nextOrdinal,
|
|
2441
|
+
shells: [...state.entries.values()].map((entry) => ({
|
|
2442
|
+
...entry.info,
|
|
2443
|
+
generation: entry.generation,
|
|
2444
|
+
...entry.artifact.stored()
|
|
2445
|
+
}))
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
function parseIndex(value) {
|
|
2449
|
+
const index = value;
|
|
2450
|
+
if (!index || index.version !== INDEX_VERSION || typeof index.sessionId !== "string" || !Array.isArray(index.shells)) throw new Error("unrecognised shell index");
|
|
2451
|
+
const shells = index.shells.filter((record) => typeof record === "object" && record !== null && typeof record.id === "string");
|
|
2452
|
+
const nextOrdinal = typeof index.nextOrdinal === "number" && index.nextOrdinal > 0 ? index.nextOrdinal : 1;
|
|
2453
|
+
return {
|
|
2454
|
+
version: INDEX_VERSION,
|
|
2455
|
+
sessionId: index.sessionId,
|
|
2456
|
+
nextOrdinal,
|
|
2457
|
+
shells
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
function freshSession(sessionId) {
|
|
2461
|
+
return {
|
|
2462
|
+
sessionId,
|
|
2463
|
+
nextOrdinal: 1,
|
|
2464
|
+
entries: /* @__PURE__ */ new Map(),
|
|
2465
|
+
chain: Promise.resolve(),
|
|
2466
|
+
pending: false
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
function runningIn(state) {
|
|
2470
|
+
return [...state.entries.values()].filter((entry) => entry.info.status === "running");
|
|
2471
|
+
}
|
|
2472
|
+
function sourceFor(entry, artifact) {
|
|
2473
|
+
return {
|
|
2474
|
+
info: () => entry.info,
|
|
2475
|
+
text: () => artifact.text(),
|
|
2476
|
+
subscribe: (listener) => {
|
|
2477
|
+
entry.listeners.add(listener);
|
|
2478
|
+
return () => {
|
|
2479
|
+
entry.listeners.delete(listener);
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
function mintShellId() {
|
|
2485
|
+
const bytes = randomBytes(ID_CHARS);
|
|
2486
|
+
let id = "sh_";
|
|
2487
|
+
for (let i = 0; i < ID_CHARS; i++) id += ID_ALPHABET[bytes[i] & 31];
|
|
2488
|
+
return id;
|
|
2489
|
+
}
|
|
2490
|
+
function shellLabel(command) {
|
|
2491
|
+
return (command.split("\n")[0] ?? "").trim().slice(0, SHELL_LABEL_MAX);
|
|
2492
|
+
}
|
|
2493
|
+
function clamp(value, min, max) {
|
|
2494
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min;
|
|
2495
|
+
}
|
|
2496
|
+
function isMissing(error) {
|
|
2497
|
+
return error?.code === "ENOENT";
|
|
2498
|
+
}
|
|
2499
|
+
async function readWhole(path) {
|
|
2500
|
+
try {
|
|
2501
|
+
return await readFile(path);
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
if (isMissing(error)) return Buffer.alloc(0);
|
|
2504
|
+
throw error;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
async function readRange(path, start, end) {
|
|
2508
|
+
const length = Math.max(0, end - start);
|
|
2509
|
+
if (length === 0) return Buffer.alloc(0);
|
|
2510
|
+
let handle;
|
|
2511
|
+
try {
|
|
2512
|
+
handle = await open(path, "r");
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
if (isMissing(error)) return Buffer.alloc(0);
|
|
2515
|
+
throw error;
|
|
2516
|
+
}
|
|
2517
|
+
try {
|
|
2518
|
+
const out = Buffer.allocUnsafe(length);
|
|
2519
|
+
let got = 0;
|
|
2520
|
+
while (got < length) {
|
|
2521
|
+
const { bytesRead } = await handle.read(out, got, length - got, start + got);
|
|
2522
|
+
if (bytesRead === 0) break;
|
|
2523
|
+
got += bytesRead;
|
|
2524
|
+
}
|
|
2525
|
+
return got === length ? out : out.subarray(0, got);
|
|
2526
|
+
} finally {
|
|
2527
|
+
await handle.close();
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
//#endregion
|
|
2531
|
+
//#region src/routes/shells.ts
|
|
2532
|
+
async function handleShells(ctx, req, res, route, runner, operator) {
|
|
2533
|
+
const { shells } = ctx;
|
|
2534
|
+
const sessionId = route.id;
|
|
2535
|
+
if (!shells) {
|
|
2536
|
+
json(res, 404, { error: SHELL_REFUSAL });
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if (!operator) {
|
|
2540
|
+
json(res, 403, { error: SHELL_REFUSAL });
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (route.shellId === void 0) {
|
|
2544
|
+
if (req.method !== "GET") {
|
|
2545
|
+
json(res, 405, { error: "method not allowed" });
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
json(res, 200, { shells: shells.list(sessionId) });
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (route.shellAction === "kill") {
|
|
2552
|
+
if (req.method !== "POST") {
|
|
2553
|
+
json(res, 405, { error: "method not allowed" });
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
if (!runner || !shellPermitted(shells, runner, operator)) {
|
|
2557
|
+
json(res, 403, { error: SHELL_REFUSAL });
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
const shell = shells.kill(sessionId, route.shellId);
|
|
2561
|
+
if (!shell) {
|
|
2562
|
+
json(res, 404, { error: "shell not found" });
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
json(res, 200, { shell });
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
if (req.method !== "GET") {
|
|
2569
|
+
json(res, 405, { error: "method not allowed" });
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (route.shellAction === "output") {
|
|
2573
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
2574
|
+
const view = url.searchParams.get("view") === "raw" ? "raw" : "text";
|
|
2575
|
+
const tailParam = url.searchParams.get("tail");
|
|
2576
|
+
const tail = tailParam === null ? void 0 : Number(tailParam);
|
|
2577
|
+
if (tail !== void 0 && (!Number.isInteger(tail) || tail <= 0)) {
|
|
2578
|
+
json(res, 400, { error: "tail must be a positive integer" });
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
const output = await shells.output(sessionId, route.shellId, {
|
|
2582
|
+
view,
|
|
2583
|
+
tail
|
|
2584
|
+
});
|
|
2585
|
+
if (output === void 0) {
|
|
2586
|
+
json(res, 404, { error: "shell not found" });
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
const body = Buffer.from(output, "utf8");
|
|
2590
|
+
res.writeHead(200, untrustedDownloadHeaders(`${route.shellId}.txt`, "text/plain; charset=utf-8", body.length));
|
|
2591
|
+
res.end(body);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
const shell = shells.get(sessionId, route.shellId);
|
|
2595
|
+
if (!shell) {
|
|
2596
|
+
json(res, 404, { error: "shell not found" });
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
json(res, 200, { shell });
|
|
2600
|
+
}
|
|
2601
|
+
//#endregion
|
|
1458
2602
|
//#region src/routes/tool-results.ts
|
|
1459
2603
|
function handleToolResult(req, res, lookup, seq) {
|
|
1460
2604
|
if (req.method !== "GET") {
|
|
@@ -1517,13 +2661,12 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1517
2661
|
return;
|
|
1518
2662
|
}
|
|
1519
2663
|
if (req.method === "POST") {
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
json(res, refusal.status, { error: refusal.error });
|
|
2664
|
+
const vetted = vetCreateRequest(ctx, await readJsonBody(req, ctx.maxBodyBytes), auth);
|
|
2665
|
+
if (!vetted.ok) {
|
|
2666
|
+
json(res, vetted.status, { error: vetted.error });
|
|
1524
2667
|
return;
|
|
1525
2668
|
}
|
|
1526
|
-
const runner = await factory.createRunner(factory.buildRunnerConfig(
|
|
2669
|
+
const runner = await factory.createRunner(factory.buildRunnerConfig(vetted.request));
|
|
1527
2670
|
factory.watchAuthSource(runner);
|
|
1528
2671
|
json(res, 201, { session: projects.withProject(runner.info()) });
|
|
1529
2672
|
return;
|
|
@@ -1588,6 +2731,10 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1588
2731
|
await handleProducedFiles(ctx, req, res, route.id, route.producedFileId);
|
|
1589
2732
|
return;
|
|
1590
2733
|
}
|
|
2734
|
+
if (route.shells) {
|
|
2735
|
+
await handleShells(ctx, req, res, route, runner ?? null, authSvc.isOperator(auth));
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
1591
2738
|
if (route.projectIcon) {
|
|
1592
2739
|
handleProjectIcon(projects, req, res, (runner?.info() ?? parked.info).cwd);
|
|
1593
2740
|
return;
|
|
@@ -1611,7 +2758,15 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1611
2758
|
json(res, 409, { error: "session is parked (it has no pending permission requests)" });
|
|
1612
2759
|
return;
|
|
1613
2760
|
}
|
|
1614
|
-
|
|
2761
|
+
const decision = body.behavior === "allow" ? {
|
|
2762
|
+
behavior: "allow",
|
|
2763
|
+
updatedInput: body.updatedInput
|
|
2764
|
+
} : {
|
|
2765
|
+
behavior: "deny",
|
|
2766
|
+
message: body.message,
|
|
2767
|
+
interrupt: body.interrupt
|
|
2768
|
+
};
|
|
2769
|
+
if (!runner.resolvePermission(route.permissionId, decision)) {
|
|
1615
2770
|
json(res, 404, { error: "permission request not found (already resolved or expired)" });
|
|
1616
2771
|
return;
|
|
1617
2772
|
}
|
|
@@ -1654,151 +2809,7 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1654
2809
|
}
|
|
1655
2810
|
json(res, 405, { error: "method not allowed" });
|
|
1656
2811
|
}
|
|
1657
|
-
const
|
|
1658
|
-
const SPAWN_FAILURE_EXIT_CODE = 127;
|
|
1659
|
-
const KILLED_EXIT_CODE = 128 + constants$1.signals.SIGKILL;
|
|
1660
|
-
function shellPermitted(shell, runner, operator) {
|
|
1661
|
-
if (shell === null || !operator) return false;
|
|
1662
|
-
const info = runner.info();
|
|
1663
|
-
return (info.capabilities ?? (info.engine ? ENGINE_CAPABILITIES[info.engine] : void 0))?.hostCwd === true;
|
|
1664
|
-
}
|
|
1665
|
-
function shellChildEnv(base) {
|
|
1666
|
-
const env = {};
|
|
1667
|
-
for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
|
|
1668
|
-
return env;
|
|
1669
|
-
}
|
|
1670
|
-
function createShellService(options = {}) {
|
|
1671
|
-
const timeoutMs = options.timeoutMs ?? 12e4;
|
|
1672
|
-
const maxOutputBytes = options.maxOutputBytes ?? 32768;
|
|
1673
|
-
const active = /* @__PURE__ */ new Map();
|
|
1674
|
-
const run = async (sessionId, cwd, command) => {
|
|
1675
|
-
if (active.has(sessionId)) throw new Error("a shell command is still running on this session");
|
|
1676
|
-
const child = spawnShell({
|
|
1677
|
-
command,
|
|
1678
|
-
cwd,
|
|
1679
|
-
env: shellChildEnv(process.env),
|
|
1680
|
-
timeoutMs,
|
|
1681
|
-
maxOutputBytes
|
|
1682
|
-
});
|
|
1683
|
-
active.set(sessionId, child);
|
|
1684
|
-
try {
|
|
1685
|
-
return await child.result;
|
|
1686
|
-
} finally {
|
|
1687
|
-
if (active.get(sessionId) === child) active.delete(sessionId);
|
|
1688
|
-
}
|
|
1689
|
-
};
|
|
1690
|
-
const kill = (sessionId, reason = "session closed") => {
|
|
1691
|
-
active.get(sessionId)?.kill(reason);
|
|
1692
|
-
};
|
|
1693
|
-
const watch = (runner) => runner.subscribe((event) => {
|
|
1694
|
-
if (event.type === "session_closed") kill(runner.id, "session closed");
|
|
1695
|
-
else if (event.type === "status_changed" && event.status === "parked") kill(runner.id, "session parked");
|
|
1696
|
-
}, runner.info().lastSeq);
|
|
1697
|
-
const killAll = () => {
|
|
1698
|
-
for (const sessionId of active.keys()) kill(sessionId, "server shutting down");
|
|
1699
|
-
};
|
|
1700
|
-
const running = (sessionId) => active.has(sessionId);
|
|
1701
|
-
return {
|
|
1702
|
-
run,
|
|
1703
|
-
kill,
|
|
1704
|
-
watch,
|
|
1705
|
-
killAll,
|
|
1706
|
-
running,
|
|
1707
|
-
timeoutMs,
|
|
1708
|
-
maxOutputBytes
|
|
1709
|
-
};
|
|
1710
|
-
}
|
|
1711
|
-
var BoundedOutput = class {
|
|
1712
|
-
#chunks = [];
|
|
1713
|
-
#kept = 0;
|
|
1714
|
-
#dropped = 0;
|
|
1715
|
-
#budget;
|
|
1716
|
-
constructor(budget) {
|
|
1717
|
-
this.#budget = budget;
|
|
1718
|
-
}
|
|
1719
|
-
push(chunk) {
|
|
1720
|
-
const take = Math.min(chunk.length, this.#budget.remaining);
|
|
1721
|
-
if (take > 0) {
|
|
1722
|
-
this.#chunks.push(take === chunk.length ? chunk : chunk.subarray(0, take));
|
|
1723
|
-
this.#kept += take;
|
|
1724
|
-
this.#budget.remaining -= take;
|
|
1725
|
-
}
|
|
1726
|
-
this.#dropped += chunk.length - take;
|
|
1727
|
-
}
|
|
1728
|
-
text() {
|
|
1729
|
-
const head = Buffer.concat(this.#chunks, this.#kept).toString("utf8");
|
|
1730
|
-
return this.#dropped > 0 ? `${head}\n[output truncated: ${this.#dropped} more bytes dropped]` : head;
|
|
1731
|
-
}
|
|
1732
|
-
};
|
|
1733
|
-
function spawnShell(input) {
|
|
1734
|
-
const startedAt = Date.now();
|
|
1735
|
-
const budget = { remaining: input.maxOutputBytes };
|
|
1736
|
-
const stdout = new BoundedOutput(budget);
|
|
1737
|
-
const stderr = new BoundedOutput(budget);
|
|
1738
|
-
let settle;
|
|
1739
|
-
const result = new Promise((resolve) => {
|
|
1740
|
-
settle = resolve;
|
|
1741
|
-
});
|
|
1742
|
-
let done = false;
|
|
1743
|
-
const finish = (exitCode, note) => {
|
|
1744
|
-
if (done) return;
|
|
1745
|
-
done = true;
|
|
1746
|
-
clearTimeout(timer);
|
|
1747
|
-
const captured = stderr.text();
|
|
1748
|
-
const stderrText = note ? captured ? `${captured}\n[${note}]` : `[${note}]` : captured;
|
|
1749
|
-
settle?.({
|
|
1750
|
-
command: input.command,
|
|
1751
|
-
stdout: stdout.text(),
|
|
1752
|
-
stderr: stderrText,
|
|
1753
|
-
exitCode,
|
|
1754
|
-
durationMs: Date.now() - startedAt
|
|
1755
|
-
});
|
|
1756
|
-
};
|
|
1757
|
-
const child = spawn("/bin/sh", ["-c", input.command], {
|
|
1758
|
-
cwd: input.cwd,
|
|
1759
|
-
env: {
|
|
1760
|
-
...input.env,
|
|
1761
|
-
PWD: input.cwd
|
|
1762
|
-
},
|
|
1763
|
-
stdio: [
|
|
1764
|
-
"ignore",
|
|
1765
|
-
"pipe",
|
|
1766
|
-
"pipe"
|
|
1767
|
-
],
|
|
1768
|
-
detached: true
|
|
1769
|
-
});
|
|
1770
|
-
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1771
|
-
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1772
|
-
const killGroup = () => {
|
|
1773
|
-
if (child.pid === void 0) return;
|
|
1774
|
-
try {
|
|
1775
|
-
process.kill(-child.pid, "SIGKILL");
|
|
1776
|
-
} catch {
|
|
1777
|
-
try {
|
|
1778
|
-
child.kill("SIGKILL");
|
|
1779
|
-
} catch {}
|
|
1780
|
-
}
|
|
1781
|
-
child.stdout.destroy();
|
|
1782
|
-
child.stderr.destroy();
|
|
1783
|
-
};
|
|
1784
|
-
const kill = (reason, exitCode = KILLED_EXIT_CODE) => {
|
|
1785
|
-
if (done) return;
|
|
1786
|
-
killGroup();
|
|
1787
|
-
finish(exitCode, `killed: ${reason}`);
|
|
1788
|
-
};
|
|
1789
|
-
const timer = setTimeout(() => kill(`timed out after ${Math.round(input.timeoutMs / 1e3)}s`, TIMEOUT_EXIT_CODE), input.timeoutMs);
|
|
1790
|
-
child.on("error", (error) => finish(SPAWN_FAILURE_EXIT_CODE, error.message));
|
|
1791
|
-
child.on("close", (code, signal) => {
|
|
1792
|
-
const signalCode = signal ? constants$1.signals[signal] ?? 0 : 0;
|
|
1793
|
-
finish(code ?? (signalCode ? 128 + signalCode : 1), signal ? `killed by ${signal}` : void 0);
|
|
1794
|
-
});
|
|
1795
|
-
return {
|
|
1796
|
-
result,
|
|
1797
|
-
kill: (reason) => kill(reason)
|
|
1798
|
-
};
|
|
1799
|
-
}
|
|
1800
|
-
//#endregion
|
|
1801
|
-
//#region src/routes/ws.ts
|
|
2812
|
+
const SHELL_DETACHED_BACKPRESSURE = "backpressure";
|
|
1802
2813
|
function attachClient(ctx, ws, runner, req, access) {
|
|
1803
2814
|
const { bridge, parking } = ctx;
|
|
1804
2815
|
const url = new URL(req.url ?? "/", "http://internal");
|
|
@@ -1808,12 +2819,18 @@ function attachClient(ctx, ws, runner, req, access) {
|
|
|
1808
2819
|
const send = (frame) => {
|
|
1809
2820
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
1810
2821
|
};
|
|
2822
|
+
const client = {
|
|
2823
|
+
send,
|
|
2824
|
+
open: () => ws.readyState === ws.OPEN,
|
|
2825
|
+
buffered: () => ws.bufferedAmount,
|
|
2826
|
+
shells: /* @__PURE__ */ new Map()
|
|
2827
|
+
};
|
|
1811
2828
|
send({
|
|
1812
2829
|
type: "attached",
|
|
1813
2830
|
protocolVersion: PROTOCOL_VERSION,
|
|
1814
2831
|
session: ctx.projects.withProject(runner.info()),
|
|
1815
2832
|
replayingFrom: afterSeq,
|
|
1816
|
-
...shellPermitted(ctx.
|
|
2833
|
+
...shellPermitted(ctx.shells, runner, access.operator) ? { shell: true } : {},
|
|
1817
2834
|
...ctx.pricingOverrides ? { pricingOverrides: ctx.pricingOverrides } : {}
|
|
1818
2835
|
});
|
|
1819
2836
|
const unsubscribe = runner.subscribe((event) => send({
|
|
@@ -1837,7 +2854,7 @@ function attachClient(ctx, ws, runner, req, access) {
|
|
|
1837
2854
|
});
|
|
1838
2855
|
return;
|
|
1839
2856
|
}
|
|
1840
|
-
handleCommand(ctx, frame, runner, access).catch((error) => {
|
|
2857
|
+
handleCommand(ctx, frame, runner, access, client).catch((error) => {
|
|
1841
2858
|
send({
|
|
1842
2859
|
type: "protocol_error",
|
|
1843
2860
|
message: error instanceof Error ? error.message : "command failed"
|
|
@@ -1845,12 +2862,13 @@ function attachClient(ctx, ws, runner, req, access) {
|
|
|
1845
2862
|
});
|
|
1846
2863
|
});
|
|
1847
2864
|
ws.on("close", () => {
|
|
2865
|
+
detachShells(client);
|
|
1848
2866
|
unsubscribe();
|
|
1849
2867
|
detachBridge();
|
|
1850
2868
|
parking.onDetach(runner.id);
|
|
1851
2869
|
});
|
|
1852
2870
|
}
|
|
1853
|
-
async function handleCommand(ctx, frame, runner, access) {
|
|
2871
|
+
async function handleCommand(ctx, frame, runner, access, client) {
|
|
1854
2872
|
const { attachmentStore, bridge } = ctx;
|
|
1855
2873
|
switch (frame.type) {
|
|
1856
2874
|
case "user_message": {
|
|
@@ -1904,21 +2922,107 @@ async function handleCommand(ctx, frame, runner, access) {
|
|
|
1904
2922
|
});
|
|
1905
2923
|
return;
|
|
1906
2924
|
case "shell_command": {
|
|
1907
|
-
|
|
2925
|
+
const shells = permittedShells(ctx, runner, access);
|
|
1908
2926
|
if (typeof frame.command !== "string" || frame.command.includes("\0")) throw new Error("shell command must be a string");
|
|
1909
2927
|
if (frame.command.length > SHELL_COMMAND_MAX) throw new Error(`shell command exceeds ${SHELL_COMMAND_MAX} characters`);
|
|
1910
2928
|
if (frame.command.trim() === "") throw new Error("shell command is empty");
|
|
1911
2929
|
if (!runner.queueLocalCommand) throw new Error(`the ${runner.info().engine ?? "claude"} engine cannot take shell output`);
|
|
1912
|
-
const
|
|
1913
|
-
|
|
2930
|
+
const { shell, source } = await shells.spawn({
|
|
2931
|
+
runner,
|
|
2932
|
+
command: frame.command,
|
|
2933
|
+
owner: "user"
|
|
2934
|
+
});
|
|
2935
|
+
try {
|
|
2936
|
+
runner.queueLocalCommand(source);
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
shells.kill(runner.id, shell.id);
|
|
2939
|
+
throw error;
|
|
2940
|
+
}
|
|
1914
2941
|
return;
|
|
1915
2942
|
}
|
|
2943
|
+
case "shell_attach":
|
|
2944
|
+
await attachShell(permittedShells(ctx, runner, access), client, runner.id, frame.shellId, {
|
|
2945
|
+
cols: frame.cols,
|
|
2946
|
+
rows: frame.rows
|
|
2947
|
+
});
|
|
2948
|
+
return;
|
|
2949
|
+
case "shell_input":
|
|
2950
|
+
permittedShells(ctx, runner, access).write(runner.id, frame.shellId, frame.data);
|
|
2951
|
+
return;
|
|
2952
|
+
case "shell_resize":
|
|
2953
|
+
permittedShells(ctx, runner, access).resize(runner.id, frame.shellId, {
|
|
2954
|
+
cols: frame.cols,
|
|
2955
|
+
rows: frame.rows
|
|
2956
|
+
});
|
|
2957
|
+
return;
|
|
2958
|
+
case "shell_detach":
|
|
2959
|
+
detachShell(client, frame.shellId);
|
|
2960
|
+
return;
|
|
1916
2961
|
case "close":
|
|
1917
2962
|
runner.close("client");
|
|
1918
2963
|
return;
|
|
1919
2964
|
default: throw new Error(`unknown command: ${frame.type}`);
|
|
1920
2965
|
}
|
|
1921
2966
|
}
|
|
2967
|
+
function permittedShells(ctx, runner, access) {
|
|
2968
|
+
if (ctx.shells === null || !shellPermitted(ctx.shells, runner, access.operator)) throw new Error(SHELL_REFUSAL);
|
|
2969
|
+
return ctx.shells;
|
|
2970
|
+
}
|
|
2971
|
+
async function attachShell(shells, client, sessionId, shellId, size) {
|
|
2972
|
+
shells.resize(sessionId, shellId, size);
|
|
2973
|
+
let detach;
|
|
2974
|
+
const drop = (reason) => {
|
|
2975
|
+
if (detach === void 0 || client.shells.get(shellId) !== detach) return;
|
|
2976
|
+
client.shells.delete(shellId);
|
|
2977
|
+
detach();
|
|
2978
|
+
client.send({
|
|
2979
|
+
type: "shell_detached",
|
|
2980
|
+
shellId,
|
|
2981
|
+
reason
|
|
2982
|
+
});
|
|
2983
|
+
};
|
|
2984
|
+
const sink = {
|
|
2985
|
+
write: (data) => {
|
|
2986
|
+
if (client.buffered() > 4194304) {
|
|
2987
|
+
drop(SHELL_DETACHED_BACKPRESSURE);
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
client.send({
|
|
2991
|
+
type: "shell_output",
|
|
2992
|
+
shellId,
|
|
2993
|
+
data
|
|
2994
|
+
});
|
|
2995
|
+
},
|
|
2996
|
+
end: drop
|
|
2997
|
+
};
|
|
2998
|
+
const attached = await shells.attach(sessionId, shellId, sink);
|
|
2999
|
+
if (!client.open()) {
|
|
3000
|
+
attached.detach();
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
detachShell(client, shellId);
|
|
3004
|
+
if (attached.shell.status === "running") {
|
|
3005
|
+
detach = attached.detach;
|
|
3006
|
+
client.shells.set(shellId, detach);
|
|
3007
|
+
}
|
|
3008
|
+
const { shell, scrollback } = attached;
|
|
3009
|
+
client.send({
|
|
3010
|
+
type: "shell_attached",
|
|
3011
|
+
shellId,
|
|
3012
|
+
shell,
|
|
3013
|
+
cols: shell.cols,
|
|
3014
|
+
rows: shell.rows,
|
|
3015
|
+
scrollback
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
function detachShell(client, shellId) {
|
|
3019
|
+
client.shells.get(shellId)?.();
|
|
3020
|
+
client.shells.delete(shellId);
|
|
3021
|
+
}
|
|
3022
|
+
function detachShells(client) {
|
|
3023
|
+
for (const detach of client.shells.values()) detach();
|
|
3024
|
+
client.shells.clear();
|
|
3025
|
+
}
|
|
1922
3026
|
//#endregion
|
|
1923
3027
|
//#region src/services/attachments.ts
|
|
1924
3028
|
const DEFAULT_MAX_FILE_BYTES = 10485760;
|
|
@@ -2742,7 +3846,6 @@ var ProfileService = class {
|
|
|
2742
3846
|
if (!hasEngineRunnerFactory) return `profile '${p.name}' uses engine 'provider' but no \`createEngineRunner\` was provided to build one`;
|
|
2743
3847
|
} else if (p.engine === "codex") {
|
|
2744
3848
|
if (p.codexHome && !existsSync(p.codexHome)) return `profile '${p.name}' codexHome does not exist: ${p.codexHome}`;
|
|
2745
|
-
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`;
|
|
2746
3849
|
} else if (!p.configDir || !existsSync(p.configDir)) return `profile '${p.name}' configDir does not exist: ${p.configDir}`;
|
|
2747
3850
|
if (disableBypassPermissions && p.defaults?.permissionMode === "bypassPermissions") return `profile '${p.name}' defaults to bypassPermissions but disableBypassPermissions is set`;
|
|
2748
3851
|
const fallbackMode = p.defaults?.permissionMode;
|
|
@@ -3396,13 +4499,16 @@ function createSessionFactory(deps) {
|
|
|
3396
4499
|
}
|
|
3397
4500
|
};
|
|
3398
4501
|
const buildRunner = async (built, restore, id) => {
|
|
3399
|
-
const
|
|
3400
|
-
...built,
|
|
3401
|
-
peers: deps.peers
|
|
3402
|
-
} : built;
|
|
3403
|
-
const name = config.profile;
|
|
4502
|
+
const name = built.profile;
|
|
3404
4503
|
const profile = name !== void 0 ? profiles.get(name) : void 0;
|
|
3405
4504
|
if (name !== void 0 && !profile) throw new Error(`unknown profile: ${name}`);
|
|
4505
|
+
const capabilities = profile?.capabilities ?? adapterFor(profile?.engine).capabilities;
|
|
4506
|
+
const config = {
|
|
4507
|
+
...built,
|
|
4508
|
+
...deps.peers ? { peers: deps.peers } : {},
|
|
4509
|
+
...deps.shells && capabilities.hostCwd === true ? { shells: deps.shells } : {}
|
|
4510
|
+
};
|
|
4511
|
+
if (config.instructions !== void 0 && capabilities.systemInstructions === false) throw new Error(`the ${engineOf(profile)} engine cannot deliver system instructions`);
|
|
3406
4512
|
const runner = profile && isProviderProfile(profile) ? await deps.createEngineRunner({
|
|
3407
4513
|
config,
|
|
3408
4514
|
profile,
|
|
@@ -3496,7 +4602,7 @@ function createSessionFactory(deps) {
|
|
|
3496
4602
|
const SOCKET_CLOSE_GRACE_MS = 250;
|
|
3497
4603
|
const CLOSE_DEADLINE_MS = 1e3;
|
|
3498
4604
|
const USAGE_STALE_MS = 3e5;
|
|
3499
|
-
function surveyDrain(registry) {
|
|
4605
|
+
function surveyDrain(registry, shells) {
|
|
3500
4606
|
const working = [];
|
|
3501
4607
|
const awaitingHuman = [];
|
|
3502
4608
|
for (const info of registry.list()) {
|
|
@@ -3507,7 +4613,12 @@ function surveyDrain(registry) {
|
|
|
3507
4613
|
return {
|
|
3508
4614
|
working,
|
|
3509
4615
|
awaitingHuman,
|
|
3510
|
-
timedOut: false
|
|
4616
|
+
timedOut: false,
|
|
4617
|
+
shells: (shells?.running() ?? []).map(({ sessionId, id, label }) => ({
|
|
4618
|
+
sessionId,
|
|
4619
|
+
id,
|
|
4620
|
+
label
|
|
4621
|
+
}))
|
|
3511
4622
|
};
|
|
3512
4623
|
}
|
|
3513
4624
|
function sameDrain(a, b) {
|
|
@@ -3567,13 +4678,24 @@ function createWorkerServer(options = {}) {
|
|
|
3567
4678
|
if (invalid) throw new Error(`createWorkerServer: ${invalid}`);
|
|
3568
4679
|
}
|
|
3569
4680
|
const refs = {};
|
|
3570
|
-
const
|
|
4681
|
+
const generation = randomUUID();
|
|
4682
|
+
const shells = options.shell?.enabled === true ? createShellRegistry({
|
|
4683
|
+
generation,
|
|
4684
|
+
artifactDir: options.shell.artifactDir ?? null,
|
|
4685
|
+
timeoutMs: options.shell.timeoutMs,
|
|
4686
|
+
artifactMaxBytes: options.shell.artifactMaxBytes,
|
|
4687
|
+
artifactTtlMs: options.shell.artifactTtlMs,
|
|
4688
|
+
maxRunningPerSession: options.shell.maxRunningPerSession,
|
|
4689
|
+
onError: (error, context) => console.warn(`[workerdeck] shell ${context.op} error (${context.shellId ?? context.sessionId ?? "-"}): ${String(error)}`)
|
|
4690
|
+
}) : null;
|
|
4691
|
+
const projects = new ProjectInfoService({ decorate: (info) => shells ? shells.decorate(info) : info });
|
|
3571
4692
|
const peers = options.peers?.enabled === false ? void 0 : createPeerService({
|
|
3572
4693
|
refs,
|
|
3573
4694
|
projects,
|
|
3574
4695
|
options: options.peers
|
|
3575
4696
|
});
|
|
3576
4697
|
if (peers) installPeerDirectory(peers);
|
|
4698
|
+
installShellDirectory(shells ? createShellDirectory(shells) : void 0);
|
|
3577
4699
|
const factory = createSessionFactory({
|
|
3578
4700
|
adapterFor,
|
|
3579
4701
|
profiles,
|
|
@@ -3584,6 +4706,7 @@ function createWorkerServer(options = {}) {
|
|
|
3584
4706
|
approvalTimeoutMs: options.approvalTimeoutMs,
|
|
3585
4707
|
requireApiKey: options.requireApiKey,
|
|
3586
4708
|
peers: peers ? peerDirectoryHandle() : void 0,
|
|
4709
|
+
shells: shells ? shellDirectoryHandle() : void 0,
|
|
3587
4710
|
refs
|
|
3588
4711
|
});
|
|
3589
4712
|
const availability = new AvailabilityTracker({
|
|
@@ -3597,14 +4720,13 @@ function createWorkerServer(options = {}) {
|
|
|
3597
4720
|
decorateInfo: (info) => projects.withProject(info)
|
|
3598
4721
|
});
|
|
3599
4722
|
const producedFiles = new ProducedFileStore();
|
|
3600
|
-
const shell = options.shell?.enabled === true ? createShellService(options.shell) : null;
|
|
3601
4723
|
const registry = new SessionRegistry({ onRegister: (runner) => {
|
|
3602
4724
|
const detachers = [
|
|
3603
4725
|
notifier.watch(runner),
|
|
3604
4726
|
producedFiles.watch(runner),
|
|
3605
4727
|
profileUsage.watch(runner),
|
|
3606
4728
|
spendLedger.watch(runner),
|
|
3607
|
-
|
|
4729
|
+
shells?.watch(runner),
|
|
3608
4730
|
peers?.watch(runner)
|
|
3609
4731
|
];
|
|
3610
4732
|
const profile = runner.info().profile;
|
|
@@ -3715,7 +4837,8 @@ function createWorkerServer(options = {}) {
|
|
|
3715
4837
|
hostFilesWritable: options.hostFiles?.write === true,
|
|
3716
4838
|
maxHostFileBytes: options.hostFiles?.maxFileBytes ?? 1048576,
|
|
3717
4839
|
maxHostDirEntries: options.hostFiles?.maxEntries ?? 5e3,
|
|
3718
|
-
|
|
4840
|
+
shells,
|
|
4841
|
+
generation,
|
|
3719
4842
|
pricingOverrides: Object.keys(pricing.overrides).length > 0 ? pricing.overrides : void 0
|
|
3720
4843
|
};
|
|
3721
4844
|
const handleRequest = async (req, res) => {
|
|
@@ -3784,6 +4907,18 @@ function createWorkerServer(options = {}) {
|
|
|
3784
4907
|
await handleSdkSessions(ctx, req, res, authCtx);
|
|
3785
4908
|
return;
|
|
3786
4909
|
}
|
|
4910
|
+
if (pathname === basePath + "/meta") {
|
|
4911
|
+
const authCtx = await auth.authenticate(req);
|
|
4912
|
+
if (!authCtx.ok) {
|
|
4913
|
+
json(res, 401, { error: "unauthorized" });
|
|
4914
|
+
return;
|
|
4915
|
+
}
|
|
4916
|
+
json(res, 200, {
|
|
4917
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
4918
|
+
...auth.isOperator(authCtx) ? { machineId: machineId() } : {}
|
|
4919
|
+
});
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
3787
4922
|
if (pathname.startsWith(basePath + "/fs/")) {
|
|
3788
4923
|
const authCtx = await auth.authenticate(req);
|
|
3789
4924
|
if (!authCtx.ok) {
|
|
@@ -3884,10 +5019,12 @@ function createWorkerServer(options = {}) {
|
|
|
3884
5019
|
queue,
|
|
3885
5020
|
bridge,
|
|
3886
5021
|
parking,
|
|
5022
|
+
shells,
|
|
3887
5023
|
listen: async (port, host) => {
|
|
3888
5024
|
await profiles.refreshStored();
|
|
3889
5025
|
await profiles.seedStore();
|
|
3890
5026
|
await parking.hydrate();
|
|
5027
|
+
await shells?.hydrate();
|
|
3891
5028
|
return new Promise((resolve, reject) => {
|
|
3892
5029
|
server.once("error", reject);
|
|
3893
5030
|
server.listen(port, host, () => {
|
|
@@ -3919,16 +5056,16 @@ function createWorkerServer(options = {}) {
|
|
|
3919
5056
|
const { timeoutMs = 3e4, pollMs = 250, onProgress } = drainOptions;
|
|
3920
5057
|
draining = true;
|
|
3921
5058
|
const deadline = Date.now() + timeoutMs;
|
|
3922
|
-
let report = surveyDrain(registry);
|
|
5059
|
+
let report = surveyDrain(registry, shells);
|
|
3923
5060
|
onProgress?.(report);
|
|
3924
5061
|
while (report.working.length > 0 && Date.now() < deadline) {
|
|
3925
5062
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
3926
|
-
const next = surveyDrain(registry);
|
|
5063
|
+
const next = surveyDrain(registry, shells);
|
|
3927
5064
|
if (!sameDrain(next, report)) onProgress?.(next);
|
|
3928
5065
|
report = next;
|
|
3929
5066
|
}
|
|
3930
5067
|
report = {
|
|
3931
|
-
...surveyDrain(registry),
|
|
5068
|
+
...surveyDrain(registry, shells),
|
|
3932
5069
|
timedOut: false
|
|
3933
5070
|
};
|
|
3934
5071
|
report.timedOut = report.working.length > 0;
|
|
@@ -3938,23 +5075,25 @@ function createWorkerServer(options = {}) {
|
|
|
3938
5075
|
close: () => {
|
|
3939
5076
|
closing ??= new Promise((resolve) => {
|
|
3940
5077
|
queue?.close();
|
|
5078
|
+
shells?.killAll("server_stopped");
|
|
5079
|
+
const flushed = shells?.flush().catch(() => {}) ?? Promise.resolve();
|
|
3941
5080
|
parking.close();
|
|
3942
5081
|
registry.closeAll();
|
|
3943
|
-
shell?.killAll();
|
|
3944
5082
|
for (const ws of wss.clients) ws.close(1001, "server shutting down");
|
|
3945
5083
|
queueSockets.clear();
|
|
3946
5084
|
const force = setTimeout(() => {
|
|
3947
5085
|
for (const ws of wss.clients) ws.terminate();
|
|
3948
5086
|
}, SOCKET_CLOSE_GRACE_MS);
|
|
3949
5087
|
force.unref();
|
|
3950
|
-
const
|
|
5088
|
+
const finish = () => void flushed.then(() => resolve());
|
|
5089
|
+
const deadline = setTimeout(finish, CLOSE_DEADLINE_MS);
|
|
3951
5090
|
deadline.unref();
|
|
3952
5091
|
const drained = Promise.all([new Promise((done) => wss.close(() => done())), new Promise((done) => server.close(() => done()))]);
|
|
3953
5092
|
server.closeAllConnections();
|
|
3954
5093
|
drained.then(() => {
|
|
3955
5094
|
clearTimeout(force);
|
|
3956
5095
|
clearTimeout(deadline);
|
|
3957
|
-
|
|
5096
|
+
finish();
|
|
3958
5097
|
});
|
|
3959
5098
|
});
|
|
3960
5099
|
return closing;
|
|
@@ -4052,6 +5191,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
|
|
|
4052
5191
|
};
|
|
4053
5192
|
}
|
|
4054
5193
|
//#endregion
|
|
4055
|
-
export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, ProfileUsageTracker, SessionNotifier, SessionParkManager, SessionRegistry, SpendLedger, createFileProfileStore, createFileSessionStore, createFileSpendStore, createMemoryProfileStore, createProviderRunner, createWorkerServer, dayKey, isDormant, reloadPlan, sandboxedProviderProfile, toDurableRecord };
|
|
5194
|
+
export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, ProfileUsageTracker, SHELL_REFUSAL, SessionNotifier, SessionParkManager, SessionRegistry, SpendLedger, createFileProfileStore, createFileSessionStore, createFileSpendStore, createMemoryProfileStore, createProviderRunner, createShellRegistry, createWorkerServer, dayKey, isDormant, reloadPlan, sandboxedProviderProfile, shellPermitted, toDurableRecord };
|
|
4056
5195
|
|
|
4057
5196
|
//# sourceMappingURL=index.mjs.map
|