@workerdeck/server 0.9.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/build/index.d.mts +51 -1
- package/build/index.mjs +160 -3
- package/build/index.mjs.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -54,6 +54,7 @@ Routes (default `basePath: '/v1'`):
|
|
|
54
54
|
| --- | --- |
|
|
55
55
|
| `GET/POST /v1/sessions` | List sessions / create one (`CreateSessionRequest`, `cwd` required) |
|
|
56
56
|
| `GET/DELETE /v1/sessions/:id` | Session info / close and remove |
|
|
57
|
+
| `PATCH /v1/sessions/:id` | Rename (`{ title }`; `null` restores the derived title) |
|
|
57
58
|
| `WS /v1/sessions/:id/ws?afterSeq=n` | Attach: `attached` frame, replay past `n`, then live events |
|
|
58
59
|
| `POST /v1/sessions/:id/permissions/:requestId` | Resolve a pending approval over REST |
|
|
59
60
|
| `GET /v1/sdk-sessions?dir=…` | List the Agent SDK's on-disk sessions to offer resume |
|
package/build/index.d.mts
CHANGED
|
@@ -658,5 +658,55 @@ declare class AttachmentStore {
|
|
|
658
658
|
drop(sessionId: string): void;
|
|
659
659
|
}
|
|
660
660
|
//#endregion
|
|
661
|
-
|
|
661
|
+
//#region src/produced-files.d.ts
|
|
662
|
+
/** One host file an engine reported writing, as the store holds it. */
|
|
663
|
+
type ProducedFile = {
|
|
664
|
+
fileId: string; /** Absolute host path, exactly as the runner reported it. */
|
|
665
|
+
path: string;
|
|
666
|
+
mediaType?: string; /** Size when the runner announced it — advisory, and re-read at serve time. */
|
|
667
|
+
bytes?: number;
|
|
668
|
+
sessionId: string;
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
|
|
672
|
+
*
|
|
673
|
+
* **This is the whole access-control model, so it is worth being precise about
|
|
674
|
+
* what it is.** The store is an allowlist built from one source and one only:
|
|
675
|
+
* `file_produced` events, which a runner emits about a file its own engine just
|
|
676
|
+
* wrote. It is not a directory grant. Nothing else can add to it — not a
|
|
677
|
+
* request, not a config, and in particular not the agent, whose own path claims
|
|
678
|
+
* go through `/fs/*` and that route's root allowlist.
|
|
679
|
+
*
|
|
680
|
+
* That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
|
|
681
|
+
* "somewhere under a root the operator declared" is a guess about which paths
|
|
682
|
+
* are safe, while "the exact path this session's runner reported producing" is
|
|
683
|
+
* a fact about one file. A 2 MB generated PNG is the common case, and making
|
|
684
|
+
* the operator raise a byte cap to see their own picture was the bug this
|
|
685
|
+
* replaces.
|
|
686
|
+
*
|
|
687
|
+
* Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
|
|
688
|
+
* the session is removed. The bytes are never held here — only the path, so a
|
|
689
|
+
* gateway serving a long session accumulates a few hundred bytes per picture
|
|
690
|
+
* rather than the pictures.
|
|
691
|
+
*/
|
|
692
|
+
declare class ProducedFileStore {
|
|
693
|
+
#private;
|
|
694
|
+
/**
|
|
695
|
+
* Register a runner's produced files for its lifetime.
|
|
696
|
+
*
|
|
697
|
+
* Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
|
|
698
|
+
* and correct for the same reason: registration is idempotent (a `fileId` is
|
|
699
|
+
* derived from its path, so re-registering overwrites with itself), and a
|
|
700
|
+
* session rebuilt from a park must re-learn every file it produced before the
|
|
701
|
+
* park — otherwise a client's transcript keeps rendering image cards whose
|
|
702
|
+
* bytes have quietly become unreachable.
|
|
703
|
+
*/
|
|
704
|
+
watch(runner: Runner): void;
|
|
705
|
+
get(sessionId: string, fileId: string): ProducedFile | undefined;
|
|
706
|
+
/** Everything one session has produced, newest registration last. */
|
|
707
|
+
list(sessionId: string): ProducedFile[];
|
|
708
|
+
drop(sessionId: string): void;
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
export { AttachmentStore, type AttachmentStoreOptions, type Authenticator, BridgeHub, type BridgeHubOptions, type EngineRunnerContext, type FileSessionStoreOptions, MemorySessionStore, type ParkedSessionRecord, type ProducedFile, ProducedFileStore, type ProfileStore, type QueueServerOptions, type SdkSessionLister, type SessionNotificationOptions, SessionNotifier, SessionParkManager, type SessionParkOptions, SessionRegistry, type SessionRegistryOptions, type SessionStore, type WorkerServer, type WorkerServerOptions, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
662
712
|
//# sourceMappingURL=index.d.mts.map
|
package/build/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -266,7 +266,8 @@ const DEFAULT_IGNORED_DIRS = [
|
|
|
266
266
|
".pytest_cache",
|
|
267
267
|
".gradle",
|
|
268
268
|
"Pods",
|
|
269
|
-
"DerivedData"
|
|
269
|
+
"DerivedData",
|
|
270
|
+
".build"
|
|
270
271
|
];
|
|
271
272
|
/**
|
|
272
273
|
* Breadth-first so shallow files rank first before scoring even runs — for a bare
|
|
@@ -489,6 +490,67 @@ function safeName(name) {
|
|
|
489
490
|
return cleaned.length > 120 ? cleaned.slice(0, 120) : cleaned;
|
|
490
491
|
}
|
|
491
492
|
//#endregion
|
|
493
|
+
//#region src/produced-files.ts
|
|
494
|
+
/**
|
|
495
|
+
* The paths this gateway will serve from `GET /sessions/:id/produced/:fileId`.
|
|
496
|
+
*
|
|
497
|
+
* **This is the whole access-control model, so it is worth being precise about
|
|
498
|
+
* what it is.** The store is an allowlist built from one source and one only:
|
|
499
|
+
* `file_produced` events, which a runner emits about a file its own engine just
|
|
500
|
+
* wrote. It is not a directory grant. Nothing else can add to it — not a
|
|
501
|
+
* request, not a config, and in particular not the agent, whose own path claims
|
|
502
|
+
* go through `/fs/*` and that route's root allowlist.
|
|
503
|
+
*
|
|
504
|
+
* That is why the route needs neither `hostFiles.roots` nor `maxFileBytes`:
|
|
505
|
+
* "somewhere under a root the operator declared" is a guess about which paths
|
|
506
|
+
* are safe, while "the exact path this session's runner reported producing" is
|
|
507
|
+
* a fact about one file. A 2 MB generated PNG is the common case, and making
|
|
508
|
+
* the operator raise a byte cap to see their own picture was the bug this
|
|
509
|
+
* replaces.
|
|
510
|
+
*
|
|
511
|
+
* Lifetime is the session's, like `AttachmentStore`'s: in memory, dropped when
|
|
512
|
+
* the session is removed. The bytes are never held here — only the path, so a
|
|
513
|
+
* gateway serving a long session accumulates a few hundred bytes per picture
|
|
514
|
+
* rather than the pictures.
|
|
515
|
+
*/
|
|
516
|
+
var ProducedFileStore = class {
|
|
517
|
+
#bySession = /* @__PURE__ */ new Map();
|
|
518
|
+
/**
|
|
519
|
+
* Register a runner's produced files for its lifetime.
|
|
520
|
+
*
|
|
521
|
+
* Subscribes from seq 0, which is the opposite of what `SessionNotifier` wants
|
|
522
|
+
* and correct for the same reason: registration is idempotent (a `fileId` is
|
|
523
|
+
* derived from its path, so re-registering overwrites with itself), and a
|
|
524
|
+
* session rebuilt from a park must re-learn every file it produced before the
|
|
525
|
+
* park — otherwise a client's transcript keeps rendering image cards whose
|
|
526
|
+
* bytes have quietly become unreachable.
|
|
527
|
+
*/
|
|
528
|
+
watch(runner) {
|
|
529
|
+
runner.subscribe((event) => {
|
|
530
|
+
if (event.type !== "file_produced") return;
|
|
531
|
+
const held = this.#bySession.get(runner.id) ?? /* @__PURE__ */ new Map();
|
|
532
|
+
held.set(event.fileId, {
|
|
533
|
+
fileId: event.fileId,
|
|
534
|
+
path: event.path,
|
|
535
|
+
...event.mediaType ? { mediaType: event.mediaType } : {},
|
|
536
|
+
...event.bytes !== void 0 ? { bytes: event.bytes } : {},
|
|
537
|
+
sessionId: runner.id
|
|
538
|
+
});
|
|
539
|
+
this.#bySession.set(runner.id, held);
|
|
540
|
+
}, 0);
|
|
541
|
+
}
|
|
542
|
+
get(sessionId, fileId) {
|
|
543
|
+
return this.#bySession.get(sessionId)?.get(fileId);
|
|
544
|
+
}
|
|
545
|
+
/** Everything one session has produced, newest registration last. */
|
|
546
|
+
list(sessionId) {
|
|
547
|
+
return [...this.#bySession.get(sessionId)?.values() ?? []];
|
|
548
|
+
}
|
|
549
|
+
drop(sessionId) {
|
|
550
|
+
this.#bySession.delete(sessionId);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
//#endregion
|
|
492
554
|
//#region src/registry.ts
|
|
493
555
|
/** In-memory session table. Terminal sessions stay listed until removed or the process exits. */
|
|
494
556
|
var SessionRegistry = class {
|
|
@@ -1667,8 +1729,10 @@ function createWorkerServer(options = {}) {
|
|
|
1667
1729
|
* learned — and still absent on a cold server, the accepted regression.
|
|
1668
1730
|
*/
|
|
1669
1731
|
const profileDefaultModels = /* @__PURE__ */ new Map();
|
|
1732
|
+
const producedFiles = new ProducedFileStore();
|
|
1670
1733
|
const registry = new SessionRegistry({ onRegister: (runner) => {
|
|
1671
1734
|
notifier.watch(runner);
|
|
1735
|
+
producedFiles.watch(runner);
|
|
1672
1736
|
const profile = runner.info().profile;
|
|
1673
1737
|
if (!profile) return;
|
|
1674
1738
|
runner.subscribe((event) => {
|
|
@@ -1846,6 +1910,11 @@ function createWorkerServer(options = {}) {
|
|
|
1846
1910
|
attachments: true,
|
|
1847
1911
|
attachmentId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
1848
1912
|
};
|
|
1913
|
+
if (parts.length <= 3 && parts[1] === "produced") return {
|
|
1914
|
+
id: decodeURIComponent(parts[0]),
|
|
1915
|
+
produced: true,
|
|
1916
|
+
producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
1917
|
+
};
|
|
1849
1918
|
if (parts.length <= 3 && parts[1] === "mcp") return {
|
|
1850
1919
|
id: decodeURIComponent(parts[0]),
|
|
1851
1920
|
mcp: true,
|
|
@@ -1954,6 +2023,10 @@ function createWorkerServer(options = {}) {
|
|
|
1954
2023
|
json(res, 400, { error: "action must be 'reconnect', 'enable' or 'disable'" });
|
|
1955
2024
|
return;
|
|
1956
2025
|
}
|
|
2026
|
+
if (!(body.action === "reconnect" ? typeof runner.reconnectMcpServer === "function" : typeof runner.setMcpServerEnabled === "function")) {
|
|
2027
|
+
json(res, 501, { error: `this session's engine cannot ${body.action} an MCP server` });
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
1957
2030
|
try {
|
|
1958
2031
|
if (body.action === "reconnect") await runner.reconnectMcpServer?.(serverName);
|
|
1959
2032
|
else await runner.setMcpServerEnabled?.(serverName, body.action === "enable");
|
|
@@ -1967,6 +2040,68 @@ function createWorkerServer(options = {}) {
|
|
|
1967
2040
|
json(res, 405, { error: "method not allowed" });
|
|
1968
2041
|
};
|
|
1969
2042
|
/**
|
|
2043
|
+
* `{basePath}/sessions/:id/produced[/:fileId]` — files this session's ENGINE
|
|
2044
|
+
* wrote on the host (codex's generated images), listed and served.
|
|
2045
|
+
*
|
|
2046
|
+
* The one route here with no root allowlist and no byte cap, and the comment
|
|
2047
|
+
* on {@link ProducedFileStore} is the argument for why that is right rather
|
|
2048
|
+
* than lax: the allowlist is the exact set of paths this session's own runner
|
|
2049
|
+
* announced producing. It is emphatically NOT a hole in `/fs/*` — a path the
|
|
2050
|
+
* *agent* named is not a produced file and never enters this store.
|
|
2051
|
+
*
|
|
2052
|
+
* Everything else matches the attachment download: `nosniff` and an attachment
|
|
2053
|
+
* disposition, because these bytes are model-authored and must not render as a
|
|
2054
|
+
* document on the gateway's origin. (`<img src>` is unaffected — disposition
|
|
2055
|
+
* does not apply to subresources, which is the whole point.)
|
|
2056
|
+
*/
|
|
2057
|
+
const handleProducedFiles = async (req, res, sessionId, fileId) => {
|
|
2058
|
+
if (req.method !== "GET") {
|
|
2059
|
+
json(res, 405, { error: "method not allowed" });
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (fileId === void 0) {
|
|
2063
|
+
json(res, 200, { files: producedFiles.list(sessionId).map(({ fileId: id, path, mediaType, bytes }) => ({
|
|
2064
|
+
fileId: id,
|
|
2065
|
+
path,
|
|
2066
|
+
...mediaType ? { mediaType } : {},
|
|
2067
|
+
...bytes !== void 0 ? { bytes } : {}
|
|
2068
|
+
})) });
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
const found = producedFiles.get(sessionId, fileId);
|
|
2072
|
+
if (!found) {
|
|
2073
|
+
json(res, 404, { error: "no such produced file" });
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
let stat;
|
|
2077
|
+
try {
|
|
2078
|
+
stat = statSync(found.path);
|
|
2079
|
+
} catch {
|
|
2080
|
+
json(res, 404, { error: "produced file is no longer on disk" });
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
if (!stat.isFile()) {
|
|
2084
|
+
json(res, 404, { error: "produced file is not a regular file" });
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const filename = basename(found.path) || "file";
|
|
2088
|
+
res.writeHead(200, {
|
|
2089
|
+
"content-type": found.mediaType ?? contentTypeFor(filename),
|
|
2090
|
+
"content-length": stat.size,
|
|
2091
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
2092
|
+
"x-content-type-options": "nosniff"
|
|
2093
|
+
});
|
|
2094
|
+
await new Promise((done) => {
|
|
2095
|
+
const stream = createReadStream(found.path);
|
|
2096
|
+
stream.on("error", () => {
|
|
2097
|
+
res.destroy();
|
|
2098
|
+
done();
|
|
2099
|
+
});
|
|
2100
|
+
stream.on("close", () => done());
|
|
2101
|
+
stream.pipe(res);
|
|
2102
|
+
});
|
|
2103
|
+
};
|
|
2104
|
+
/**
|
|
1970
2105
|
* `{basePath}/fs/*` — the operator's real tree. Authorized by the auth key alone
|
|
1971
2106
|
* and deliberately outside the agent permission flow: the caller is the operator.
|
|
1972
2107
|
*
|
|
@@ -2631,6 +2766,10 @@ function createWorkerServer(options = {}) {
|
|
|
2631
2766
|
res.end(content);
|
|
2632
2767
|
return;
|
|
2633
2768
|
}
|
|
2769
|
+
if (route.produced) {
|
|
2770
|
+
await handleProducedFiles(req, res, route.id, route.producedFileId);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2634
2773
|
if (route.permissionId) {
|
|
2635
2774
|
if (req.method !== "POST") {
|
|
2636
2775
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -2656,11 +2795,29 @@ function createWorkerServer(options = {}) {
|
|
|
2656
2795
|
json(res, 200, { session: runner?.info() ?? parked.info });
|
|
2657
2796
|
return;
|
|
2658
2797
|
}
|
|
2798
|
+
if (req.method === "PATCH") {
|
|
2799
|
+
if (!runner) {
|
|
2800
|
+
json(res, 409, { error: "session is parked (wake it before renaming)" });
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
2804
|
+
if (body?.title !== void 0) {
|
|
2805
|
+
if (body.title !== null && typeof body.title !== "string") {
|
|
2806
|
+
json(res, 400, { error: "title must be a string or null" });
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
const title = typeof body.title === "string" ? body.title.trim() : "";
|
|
2810
|
+
runner.setTitle(title || void 0);
|
|
2811
|
+
}
|
|
2812
|
+
json(res, 200, { session: runner.info() });
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2659
2815
|
if (req.method === "DELETE") {
|
|
2660
2816
|
registry.remove(route.id);
|
|
2661
2817
|
bridge.remove(route.id);
|
|
2662
2818
|
await parking.discard(route.id);
|
|
2663
2819
|
attachmentStore.drop(route.id);
|
|
2820
|
+
producedFiles.drop(route.id);
|
|
2664
2821
|
json(res, 200, { session: runner?.info() ?? {
|
|
2665
2822
|
...parked.info,
|
|
2666
2823
|
status: "closed"
|
|
@@ -2893,6 +3050,6 @@ function createFileProfileStore(path = join(process.cwd(), ".workerdeck", "profi
|
|
|
2893
3050
|
};
|
|
2894
3051
|
}
|
|
2895
3052
|
//#endregion
|
|
2896
|
-
export { AttachmentStore, BridgeHub, MemorySessionStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
3053
|
+
export { AttachmentStore, BridgeHub, MemorySessionStore, ProducedFileStore, SessionNotifier, SessionParkManager, SessionRegistry, createFileProfileStore, createFileSessionStore, createMemoryProfileStore, createWorkerServer, toDurableRecord };
|
|
2897
3054
|
|
|
2898
3055
|
//# sourceMappingURL=index.mjs.map
|