@cjhyy/code-shell-server 0.9.21 → 0.9.23

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.
Files changed (104) hide show
  1. package/README.md +45 -1
  2. package/dist/bin/code-shell-settings-recovery.d.ts +2 -0
  3. package/dist/bin/code-shell-settings-recovery.js +4 -0
  4. package/dist/desktop-web/http-api.js +14 -0
  5. package/dist/environment-identity.d.ts +4 -0
  6. package/dist/environment-identity.js +67 -0
  7. package/dist/hub/auth-store.d.ts +2 -0
  8. package/dist/hub/auth-store.js +7 -0
  9. package/dist/index.auth.d.ts +3 -0
  10. package/dist/index.auth.js +2 -0
  11. package/dist/index.links.d.ts +1 -0
  12. package/dist/index.links.js +1 -0
  13. package/dist/index.panels.d.ts +7 -0
  14. package/dist/index.panels.js +7 -0
  15. package/dist/index.remote-relay.d.ts +5 -0
  16. package/dist/index.remote-relay.js +6 -0
  17. package/dist/index.serve.d.ts +2 -0
  18. package/dist/index.serve.js +2 -0
  19. package/dist/links/http.d.ts +3 -0
  20. package/dist/links/http.js +11 -2
  21. package/dist/links/remote-configuration.d.ts +4 -0
  22. package/dist/links/remote-configuration.js +32 -0
  23. package/dist/links/service.d.ts +6 -1
  24. package/dist/links/service.js +241 -19
  25. package/dist/mobile-remote/mobile-upload-service.d.ts +4 -0
  26. package/dist/mobile-remote/mobile-upload-service.js +69 -11
  27. package/dist/mobile-remote/remote-host-manager.d.ts +10 -5
  28. package/dist/mobile-remote/remote-host-manager.js +53 -5
  29. package/dist/panels/agent-task-service.js +9 -2
  30. package/dist/panels/automations.d.ts +49 -0
  31. package/dist/panels/automations.js +134 -0
  32. package/dist/panels/bridge-contract.d.ts +5 -3
  33. package/dist/panels/bridge-contract.js +11 -1
  34. package/dist/panels/directory-bookmarks.d.ts +9 -1
  35. package/dist/panels/directory-bookmarks.js +64 -5
  36. package/dist/panels/execution-gate.d.ts +26 -0
  37. package/dist/panels/execution-gate.js +70 -0
  38. package/dist/panels/http.d.ts +26 -2
  39. package/dist/panels/http.js +7 -1
  40. package/dist/panels/hub-automations.d.ts +31 -0
  41. package/dist/panels/hub-automations.js +352 -0
  42. package/dist/panels/management-http.d.ts +20 -2
  43. package/dist/panels/management-http.js +15 -2
  44. package/dist/panels/management.d.ts +26 -4
  45. package/dist/panels/management.js +382 -39
  46. package/dist/panels/package-mutation.d.ts +8 -0
  47. package/dist/panels/package-mutation.js +23 -0
  48. package/dist/panels/process-service.d.ts +3 -0
  49. package/dist/panels/process-service.js +77 -4
  50. package/dist/panels/resources/http.d.ts +11 -0
  51. package/dist/panels/resources/http.js +74 -0
  52. package/dist/panels/resources/references.d.ts +5 -0
  53. package/dist/panels/resources/references.js +26 -0
  54. package/dist/panels/runtime-services.js +16 -3
  55. package/dist/panels/runtime.d.ts +16 -4
  56. package/dist/panels/runtime.js +522 -64
  57. package/dist/panels/shared-tool-jobs.d.ts +40 -0
  58. package/dist/panels/shared-tool-jobs.js +37 -0
  59. package/dist/panels/storage-store.d.ts +21 -0
  60. package/dist/panels/storage-store.js +43 -0
  61. package/dist/panels/task-cookie-host.d.ts +26 -0
  62. package/dist/panels/task-cookie-host.js +145 -0
  63. package/dist/panels/task-cookies.d.ts +51 -0
  64. package/dist/panels/task-cookies.js +183 -0
  65. package/dist/panels/tool-executor.d.ts +21 -5
  66. package/dist/panels/tool-executor.js +171 -30
  67. package/dist/panels/tool-jobs-storage.d.ts +2 -0
  68. package/dist/panels/tool-jobs-storage.js +34 -0
  69. package/dist/panels/tool-jobs.d.ts +49 -3
  70. package/dist/panels/tool-jobs.js +270 -15
  71. package/dist/panels/types.d.ts +43 -0
  72. package/dist/project-runtime/backup.d.ts +26 -0
  73. package/dist/project-runtime/backup.js +500 -0
  74. package/dist/project-runtime/control-server.d.ts +4 -0
  75. package/dist/project-runtime/control-server.js +19 -1
  76. package/dist/project-runtime/docker-provider.d.ts +4 -0
  77. package/dist/project-runtime/docker-provider.js +16 -1
  78. package/dist/project-runtime/managed-entry.js +4 -0
  79. package/dist/project-runtime/registry.d.ts +2 -0
  80. package/dist/project-runtime/registry.js +15 -0
  81. package/dist/project-runtime/seccomp.d.ts +8 -0
  82. package/dist/project-runtime/seccomp.js +72 -0
  83. package/dist/project-runtime/types.d.ts +2 -0
  84. package/dist/remote-relay/byte-stream.d.ts +9 -0
  85. package/dist/remote-relay/byte-stream.js +71 -0
  86. package/dist/remote-relay/connector.d.ts +27 -0
  87. package/dist/remote-relay/connector.js +197 -0
  88. package/dist/remote-relay/local-stream.d.ts +10 -0
  89. package/dist/remote-relay/local-stream.js +120 -0
  90. package/dist/remote-relay/protocol.d.ts +39 -0
  91. package/dist/remote-relay/protocol.js +64 -0
  92. package/dist/serve/automation-worker.d.ts +16 -0
  93. package/dist/serve/automation-worker.js +126 -0
  94. package/dist/serve/cli.d.ts +3 -0
  95. package/dist/serve/cli.js +17 -0
  96. package/dist/serve/headless-server.d.ts +3 -0
  97. package/dist/serve/headless-server.js +140 -10
  98. package/dist/serve/settings-recovery-cli.d.ts +29 -0
  99. package/dist/serve/settings-recovery-cli.js +128 -0
  100. package/dist/session-http-responses.d.ts +7 -0
  101. package/dist/session-http-responses.js +40 -0
  102. package/dist/worker-bridge-core.d.ts +9 -1
  103. package/dist/worker-bridge-core.js +45 -0
  104. package/package.json +15 -6
@@ -0,0 +1,8 @@
1
+ export interface ProjectSeccompProfile {
2
+ readonly json: string;
3
+ readonly sha256: string;
4
+ }
5
+ /** Administrator configuration only. Snapshot bytes once; never read project files or HTTP input. */
6
+ export declare function readProjectSeccompProfile(path: string): ProjectSeccompProfile;
7
+ /** Docker CLI reads its own private copy; editing the source cannot race container creation. */
8
+ export declare function materializeProjectSeccompProfile(dataDir: string, profile: ProjectSeccompProfile): Promise<string>;
@@ -0,0 +1,72 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, rename, rm } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ /** Administrator configuration only. Snapshot bytes once; never read project files or HTTP input. */
6
+ export function readProjectSeccompProfile(path) {
7
+ let fd;
8
+ try {
9
+ if (!path || /[\0\r\n]/.test(path))
10
+ throw new Error();
11
+ if (!lstatSync(resolve(path)).isFile())
12
+ throw new Error();
13
+ fd = openSync(resolve(path), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
14
+ const stat = fstatSync(fd);
15
+ const limit = 1024 * 1024;
16
+ if (!stat.isFile() || stat.size < 2 || stat.size > limit)
17
+ throw new Error();
18
+ const buffer = Buffer.alloc(limit + 1);
19
+ let bytes = 0;
20
+ while (bytes < buffer.length) {
21
+ const count = readSync(fd, buffer, bytes, buffer.length - bytes, null);
22
+ if (!count)
23
+ break;
24
+ bytes += count;
25
+ }
26
+ if (bytes > limit)
27
+ throw new Error();
28
+ const value = JSON.parse(buffer.subarray(0, bytes).toString("utf8"));
29
+ // Docker validates individual rules. Reject disabling the default filter here.
30
+ if (!value ||
31
+ Array.isArray(value) ||
32
+ value.defaultAction !== "SCMP_ACT_ERRNO" ||
33
+ !Array.isArray(value.syscalls))
34
+ throw new Error();
35
+ const json = JSON.stringify(value);
36
+ return Object.freeze({ json, sha256: createHash("sha256").update(json).digest("hex") });
37
+ }
38
+ catch {
39
+ // A mistaken path may name a private configuration file: never echo its contents.
40
+ throw new Error("Project seccomp profile must be a regular JSON file (max 1 MiB) with a deny-by-default policy.");
41
+ }
42
+ finally {
43
+ if (fd !== undefined)
44
+ closeSync(fd);
45
+ }
46
+ }
47
+ /** Docker CLI reads its own private copy; editing the source cannot race container creation. */
48
+ export async function materializeProjectSeccompProfile(dataDir, profile) {
49
+ const directory = join(dataDir, "project-runtime-security");
50
+ await mkdir(directory, { recursive: true, mode: 0o700 });
51
+ const info = await lstat(directory);
52
+ if (!info.isDirectory() || info.isSymbolicLink())
53
+ throw new Error("Unsafe project runtime security directory.");
54
+ await chmod(directory, 0o700);
55
+ const destination = join(directory, `${profile.sha256}.json`);
56
+ const temporary = join(directory, `${randomUUID()}.tmp`);
57
+ try {
58
+ const handle = await open(temporary, "wx", 0o600);
59
+ try {
60
+ await handle.writeFile(profile.json, "utf8");
61
+ await handle.chmod(0o400);
62
+ }
63
+ finally {
64
+ await handle.close();
65
+ }
66
+ await rename(temporary, destination);
67
+ return destination;
68
+ }
69
+ finally {
70
+ await rm(temporary, { force: true });
71
+ }
72
+ }
@@ -1,3 +1,4 @@
1
+ import type { RemoteLinkConfiguration } from "@cjhyy/code-shell-core";
1
2
  /** Stored only by the trusted control plane; credentials must never reach browser DTOs. */
2
3
  export interface ProjectRuntimeRecord {
3
4
  id: string;
@@ -40,4 +41,5 @@ export interface ManagedProjectSecret {
40
41
  password: string;
41
42
  publicOrigin: string;
42
43
  publicPathPrefix: string;
44
+ remoteLink?: RemoteLinkConfiguration;
43
45
  }
@@ -0,0 +1,9 @@
1
+ import { Duplex } from "node:stream";
2
+ import { type WebSocket } from "ws";
3
+ /**
4
+ * One raw TCP connection over one authenticated, OPEN data WebSocket. Callers
5
+ * must create ws with maxPayload=RELAY_DATA_MAX_BYTES and compression disabled.
6
+ * Native ws/Node streams supply backpressure; there is no multiplex/credit queue.
7
+ * EOF closes the entire connection (HTTP framing does not use stream EOF).
8
+ */
9
+ export declare function createRelayByteStream(ws: WebSocket): Duplex;
@@ -0,0 +1,71 @@
1
+ import { Duplex } from "node:stream";
2
+ import { createWebSocketStream } from "ws";
3
+ import { RELAY_DATA_CHUNK_BYTES, RELAY_DATA_MAX_BYTES } from "./protocol.js";
4
+ /**
5
+ * One raw TCP connection over one authenticated, OPEN data WebSocket. Callers
6
+ * must create ws with maxPayload=RELAY_DATA_MAX_BYTES and compression disabled.
7
+ * Native ws/Node streams supply backpressure; there is no multiplex/credit queue.
8
+ * EOF closes the entire connection (HTTP framing does not use stream EOF).
9
+ */
10
+ export function createRelayByteStream(ws) {
11
+ if (ws.readyState !== ws.OPEN)
12
+ throw new Error("Relay data WebSocket is not open");
13
+ // Register before createWebSocketStream so invalid frames never reach consumers.
14
+ ws.on("message", (data, binary) => {
15
+ const size = Array.isArray(data)
16
+ ? data.reduce((total, item) => total + item.length, 0)
17
+ : data.byteLength;
18
+ if (!binary || size > RELAY_DATA_MAX_BYTES) {
19
+ stream.destroy(new Error("Invalid relay data frame"));
20
+ }
21
+ });
22
+ const wire = createWebSocketStream(ws, {
23
+ highWaterMark: RELAY_DATA_MAX_BYTES,
24
+ // The outer Duplex owns EOF: inner auto-finalization can race unread
25
+ // outer buffers or wait forever for finish after an abrupt WS close.
26
+ allowHalfOpen: true,
27
+ });
28
+ const stream = new Duplex({
29
+ highWaterMark: RELAY_DATA_MAX_BYTES,
30
+ allowHalfOpen: false,
31
+ read() {
32
+ wire.resume();
33
+ },
34
+ write(chunk, _encoding, callback) {
35
+ let offset = 0;
36
+ const next = (error) => {
37
+ if (error)
38
+ return callback(error);
39
+ if (offset === chunk.length)
40
+ return callback();
41
+ const end = Math.min(offset + RELAY_DATA_CHUNK_BYTES, chunk.length);
42
+ const part = chunk.subarray(offset, end);
43
+ offset = end;
44
+ wire.write(part, next);
45
+ };
46
+ next();
47
+ },
48
+ final(callback) {
49
+ if (ws.readyState === ws.CLOSED)
50
+ callback();
51
+ else
52
+ wire.end(callback);
53
+ },
54
+ destroy(error, callback) {
55
+ wire.destroy();
56
+ callback(error);
57
+ },
58
+ });
59
+ wire.on("data", (chunk) => {
60
+ if (!stream.destroyed && !stream.push(chunk))
61
+ wire.pause();
62
+ });
63
+ wire.on("end", () => stream.push(null));
64
+ wire.on("error", (error) => stream.destroy(error));
65
+ wire.on("close", () => {
66
+ // A normal EOF may still be buffered by a slow outer reader. Preserve it.
67
+ if (!wire.readableEnded)
68
+ stream.destroy();
69
+ });
70
+ return stream;
71
+ }
@@ -0,0 +1,27 @@
1
+ import type { ConnectionOptions } from "node:tls";
2
+ export type DeviceRelayState = "connecting" | "ready" | "disconnected" | "unauthorized" | "closed";
3
+ /** Issued by RemoteHostManager.relayTarget(), bound to one running relay Host. */
4
+ export interface RelayLocalTarget {
5
+ readonly port: number;
6
+ readonly signal: AbortSignal;
7
+ setPublicBaseUrl(origin: string): void;
8
+ }
9
+ export interface DeviceRelayConnectorOptions {
10
+ /** Directory HTTPS origin, never a relay-selected destination. */
11
+ relayOrigin: string;
12
+ hostId: string;
13
+ publicOrigin: string;
14
+ credential: string;
15
+ /** Target is always 127.0.0.1; stopping its Host revokes this capability. */
16
+ localHost: RelayLocalTarget;
17
+ /** Additional trusted CA for private installations; TLS verification stays enabled. */
18
+ ca?: ConnectionOptions["ca"];
19
+ onState?: (state: DeviceRelayState) => void;
20
+ }
21
+ export interface DeviceRelayConnector {
22
+ /** Idempotent while running. Callers must await close before restarting. */
23
+ start(): void;
24
+ /** Fences callbacks immediately, then waits for control/TCP/data connections to close. */
25
+ close(): Promise<void>;
26
+ }
27
+ export declare function createDeviceRelayConnector(options: DeviceRelayConnectorOptions): DeviceRelayConnector;
@@ -0,0 +1,197 @@
1
+ import { WebSocket } from "ws";
2
+ import { openRelayStream } from "./local-stream.js";
3
+ import { isRelayHostId, isRelayOrigin, isRelayToken, parseRelayControlMessage, RELAY_CONNECT_PATH, RELAY_CONTROL_MAX_BYTES, RELAY_MAX_STREAMS, RELAY_PING_INTERVAL_MS, RELAY_PONG_TIMEOUT_MS, RELAY_SETUP_TIMEOUT_MS, } from "./protocol.js";
4
+ export function createDeviceRelayConnector(options) {
5
+ if (!isRelayOrigin(options.relayOrigin) ||
6
+ !isRelayOrigin(options.publicOrigin) ||
7
+ !isRelayHostId(options.hostId) ||
8
+ !isRelayToken(options.credential) ||
9
+ !options.localHost ||
10
+ !Number.isInteger(options.localHost.port) ||
11
+ options.localHost.port < 1 ||
12
+ options.localHost.port > 65535) {
13
+ throw new Error("Invalid device relay configuration");
14
+ }
15
+ // Do not let a caller's later object mutation change authenticated endpoints.
16
+ const config = {
17
+ ...options,
18
+ localHost: {
19
+ port: options.localHost.port,
20
+ signal: options.localHost.signal,
21
+ setPublicBaseUrl: options.localHost.setPublicBaseUrl.bind(options.localHost),
22
+ },
23
+ };
24
+ let generation = 0;
25
+ let running = false;
26
+ let closing;
27
+ let control;
28
+ let reconnect;
29
+ let failures = 0;
30
+ const pending = new Set();
31
+ const streams = new Set();
32
+ const state = (value) => {
33
+ try {
34
+ config.onState?.(value);
35
+ }
36
+ catch {
37
+ /* Observers cannot retain live transports. */
38
+ }
39
+ };
40
+ const track = (done) => {
41
+ pending.add(done);
42
+ void done.finally(() => pending.delete(done));
43
+ };
44
+ const connect = (attempt) => {
45
+ if (!running || generation !== attempt || config.localHost.signal.aborted)
46
+ return;
47
+ state("connecting");
48
+ if (!running || generation !== attempt || config.localHost.signal.aborted)
49
+ return;
50
+ const url = new URL(RELAY_CONNECT_PATH, config.relayOrigin);
51
+ url.protocol = "wss:";
52
+ const ws = new WebSocket(url, {
53
+ headers: { Authorization: `Bearer ${config.credential}` },
54
+ ca: config.ca,
55
+ followRedirects: false,
56
+ perMessageDeflate: false,
57
+ maxPayload: RELAY_CONTROL_MAX_BYTES,
58
+ handshakeTimeout: RELAY_SETUP_TIMEOUT_MS,
59
+ });
60
+ control = ws;
61
+ const current = () => running && generation === attempt && control === ws && !config.localHost.signal.aborted;
62
+ let leaseId;
63
+ let lastPong = Date.now();
64
+ const owned = new Map();
65
+ const deadline = setTimeout(() => ws.terminate(), RELAY_SETUP_TIMEOUT_MS);
66
+ const heartbeat = setInterval(() => {
67
+ if (!current() || Date.now() - lastPong >= RELAY_PONG_TIMEOUT_MS)
68
+ return ws.terminate();
69
+ if (ws.readyState === ws.OPEN)
70
+ ws.ping();
71
+ }, RELAY_PING_INTERVAL_MS);
72
+ track(new Promise((resolve) => ws.once("close", () => {
73
+ clearTimeout(deadline);
74
+ clearInterval(heartbeat);
75
+ for (const stream of owned.values())
76
+ stream.close();
77
+ if (current()) {
78
+ control = undefined;
79
+ state("disconnected");
80
+ if (!running || generation !== attempt || control || config.localHost.signal.aborted)
81
+ return resolve();
82
+ const delay = Math.min(30_000, 500 * 2 ** Math.min(failures++, 6));
83
+ reconnect = setTimeout(() => connect(attempt), delay * (0.75 + Math.random() * 0.5));
84
+ }
85
+ resolve();
86
+ })));
87
+ ws.on("error", () => {
88
+ /* close owns cleanup/retry; never log credentials or request data. */
89
+ });
90
+ ws.on("unexpected-response", (_request, response) => {
91
+ if (current() && (response.statusCode === 401 || response.statusCode === 403)) {
92
+ // A verified TLS endpoint rejected this credential. Retrying cannot
93
+ // restore authority; network failures and 5xx still use normal retry.
94
+ running = false;
95
+ state("unauthorized");
96
+ }
97
+ response.destroy();
98
+ ws.terminate();
99
+ });
100
+ ws.on("pong", () => {
101
+ if (current())
102
+ lastPong = Date.now();
103
+ });
104
+ ws.on("message", (data, binary) => {
105
+ if (!current())
106
+ return ws.terminate();
107
+ let message;
108
+ try {
109
+ if (binary || Buffer.byteLength(data.toString()) > RELAY_CONTROL_MAX_BYTES)
110
+ throw new Error("Invalid control frame");
111
+ message = parseRelayControlMessage(JSON.parse(data.toString()));
112
+ }
113
+ catch {
114
+ ws.terminate();
115
+ return;
116
+ }
117
+ if (!message)
118
+ return ws.terminate();
119
+ if (message.type === "welcome" && !leaseId) {
120
+ if (message.hostId !== config.hostId || message.publicOrigin !== config.publicOrigin)
121
+ return ws.terminate();
122
+ try {
123
+ config.localHost.setPublicBaseUrl(message.publicOrigin);
124
+ }
125
+ catch {
126
+ ws.terminate();
127
+ return;
128
+ }
129
+ if (!current())
130
+ return ws.terminate();
131
+ leaseId = message.leaseId;
132
+ clearTimeout(deadline);
133
+ ws.send(JSON.stringify({ type: "ready", v: 1, leaseId }));
134
+ failures = 0;
135
+ state("ready");
136
+ return;
137
+ }
138
+ if (message.type !== "open" ||
139
+ !leaseId ||
140
+ message.leaseId !== leaseId ||
141
+ owned.has(message.streamId))
142
+ return ws.terminate();
143
+ const failed = () => {
144
+ if (current() && ws.readyState === ws.OPEN)
145
+ ws.send(JSON.stringify({ type: "failed", v: 1, leaseId, streamId: message.streamId }));
146
+ };
147
+ if (streams.size >= RELAY_MAX_STREAMS)
148
+ return failed();
149
+ const stream = openRelayStream(config, message, failed);
150
+ owned.set(message.streamId, stream);
151
+ streams.add(stream);
152
+ const done = stream.done.then(() => {
153
+ owned.delete(message.streamId);
154
+ streams.delete(stream);
155
+ });
156
+ track(done);
157
+ });
158
+ };
159
+ const connector = {
160
+ start() {
161
+ config.localHost.signal.throwIfAborted();
162
+ if (running)
163
+ return;
164
+ if (closing)
165
+ throw new Error("Device relay connector is closing");
166
+ config.localHost.signal.addEventListener("abort", revoke, { once: true });
167
+ running = true;
168
+ failures = 0;
169
+ connect(++generation);
170
+ },
171
+ close() {
172
+ if (closing)
173
+ return closing;
174
+ running = false;
175
+ config.localHost.signal.removeEventListener("abort", revoke);
176
+ generation++;
177
+ clearTimeout(reconnect);
178
+ reconnect = undefined;
179
+ control?.terminate();
180
+ control = undefined;
181
+ for (const stream of streams)
182
+ stream.close();
183
+ closing = Promise.all([...pending])
184
+ .then(() => {
185
+ state("closed");
186
+ })
187
+ .finally(() => {
188
+ closing = undefined;
189
+ });
190
+ return closing;
191
+ },
192
+ };
193
+ function revoke() {
194
+ void connector.close();
195
+ }
196
+ return connector;
197
+ }
@@ -0,0 +1,10 @@
1
+ import type { DeviceRelayConnectorOptions } from "./connector.js";
2
+ import { type RelayControlMessage } from "./protocol.js";
3
+ export interface RelayLocalStream {
4
+ close(): void;
5
+ done: Promise<void>;
6
+ }
7
+ /** One fixed loopback connection. Never accept a target, URL, or port from control messages. */
8
+ export declare function openRelayStream(config: DeviceRelayConnectorOptions, message: Extract<RelayControlMessage, {
9
+ type: "open";
10
+ }>, failed: () => void): RelayLocalStream;
@@ -0,0 +1,120 @@
1
+ import { connect } from "node:net";
2
+ import { WebSocket } from "ws";
3
+ import { createRelayByteStream } from "./byte-stream.js";
4
+ import { RELAY_DATA_MAX_BYTES, RELAY_SETUP_TIMEOUT_MS, RELAY_STREAMS_PATH, } from "./protocol.js";
5
+ const ERROR_DRAIN_TIMEOUT_MS = 2000;
6
+ /** One fixed loopback connection. Never accept a target, URL, or port from control messages. */
7
+ export function openRelayStream(config, message, failed) {
8
+ const socket = connect({ host: "127.0.0.1", port: config.localHost.port });
9
+ let ws;
10
+ let bytes;
11
+ let disposed = false;
12
+ let attached = false;
13
+ let tcpClosed = false;
14
+ let wsClosed = true;
15
+ let drainingResponse = false;
16
+ let errorDrainTimer;
17
+ let resolveDone;
18
+ const done = new Promise((resolve) => {
19
+ resolveDone = resolve;
20
+ });
21
+ const settle = () => {
22
+ if (tcpClosed && wsClosed)
23
+ resolveDone();
24
+ };
25
+ const close = () => {
26
+ if (disposed)
27
+ return;
28
+ disposed = true;
29
+ clearTimeout(timer);
30
+ clearTimeout(errorDrainTimer);
31
+ socket.destroy();
32
+ bytes?.destroy();
33
+ ws?.terminate();
34
+ };
35
+ const fail = () => {
36
+ if (!disposed && !attached)
37
+ failed();
38
+ close();
39
+ };
40
+ const drainResponse = () => {
41
+ if (disposed || drainingResponse || !bytes)
42
+ return;
43
+ drainingResponse = true;
44
+ socket.unpipe(bytes);
45
+ bytes.unpipe(socket);
46
+ // An early HTTP rejection can arrive before the request finishes writing.
47
+ // Preserve already-read response bytes instead of turning them into EPIPE
48
+ // at the peer. HTTP framing still rejects genuinely truncated responses.
49
+ // The dead TCP target receives no further body; discard inbound frames only
50
+ // during this bounded WSS close handshake so they cannot stall its close.
51
+ errorDrainTimer = setTimeout(close, ERROR_DRAIN_TIMEOUT_MS);
52
+ errorDrainTimer.unref?.();
53
+ bytes.resume();
54
+ bytes.end();
55
+ };
56
+ const timer = setTimeout(fail, RELAY_SETUP_TIMEOUT_MS);
57
+ socket.on("error", (error) => {
58
+ if (attached && socket.bytesRead > 0 && ["EPIPE", "ECONNRESET"].includes(error.code ?? ""))
59
+ drainResponse();
60
+ else
61
+ fail();
62
+ });
63
+ socket.once("close", () => {
64
+ tcpClosed = true;
65
+ // A clean local EOF must flush queued response bytes before closing WSS.
66
+ if (!attached)
67
+ fail();
68
+ else if (!socket.readableEnded || !socket.writableFinished) {
69
+ if (socket.bytesRead > 0)
70
+ drainResponse();
71
+ else
72
+ fail();
73
+ }
74
+ settle();
75
+ });
76
+ socket.once("connect", () => {
77
+ if (disposed)
78
+ return;
79
+ const url = new URL(RELAY_STREAMS_PATH + message.streamId, config.relayOrigin);
80
+ url.protocol = "wss:";
81
+ wsClosed = false;
82
+ ws = new WebSocket(url, {
83
+ headers: {
84
+ Authorization: `Bearer ${config.credential}`,
85
+ "X-CodeShell-Relay-Lease": message.leaseId,
86
+ "X-CodeShell-Relay-Ticket": message.ticket,
87
+ },
88
+ ca: config.ca,
89
+ followRedirects: false,
90
+ perMessageDeflate: false,
91
+ maxPayload: RELAY_DATA_MAX_BYTES,
92
+ handshakeTimeout: RELAY_SETUP_TIMEOUT_MS,
93
+ });
94
+ ws.on("error", fail);
95
+ ws.once("close", () => {
96
+ wsClosed = true;
97
+ clearTimeout(errorDrainTimer);
98
+ // The stream drains received bytes before ending the local TCP socket.
99
+ if (!attached)
100
+ fail();
101
+ settle();
102
+ });
103
+ ws.once("open", () => {
104
+ if (disposed)
105
+ return ws.terminate();
106
+ clearTimeout(timer);
107
+ attached = true;
108
+ bytes = createRelayByteStream(ws);
109
+ bytes.on("error", close);
110
+ bytes.on("close", () => {
111
+ if (bytes.readableEnded && bytes.writableFinished)
112
+ socket.end();
113
+ else
114
+ close();
115
+ });
116
+ socket.pipe(bytes).pipe(socket);
117
+ });
118
+ });
119
+ return { close, done };
120
+ }
@@ -0,0 +1,39 @@
1
+ /** Device relay v1. The relay terminates TLS and is a trusted transport. */
2
+ export declare const RELAY_PROTOCOL_VERSION = 1;
3
+ export declare const RELAY_CONTROL_MAX_BYTES: number;
4
+ export declare const RELAY_DATA_MAX_BYTES: number;
5
+ export declare const RELAY_DATA_CHUNK_BYTES: number;
6
+ export declare const RELAY_MAX_STREAMS = 32;
7
+ export declare const RELAY_CONNECT_PATH = "/api/v1/remote-hosts/connect";
8
+ export declare const RELAY_STREAMS_PATH = "/api/v1/remote-hosts/streams/";
9
+ export declare const RELAY_SETUP_TIMEOUT_MS = 10000;
10
+ export declare const RELAY_PING_INTERVAL_MS = 20000;
11
+ export declare const RELAY_PONG_TIMEOUT_MS = 60000;
12
+ export type RelayControlMessage = {
13
+ type: "welcome";
14
+ v: 1;
15
+ hostId: string;
16
+ publicOrigin: string;
17
+ leaseId: string;
18
+ } | {
19
+ type: "ready";
20
+ v: 1;
21
+ leaseId: string;
22
+ } | {
23
+ type: "open";
24
+ v: 1;
25
+ leaseId: string;
26
+ streamId: string;
27
+ ticket: string;
28
+ } | {
29
+ type: "failed";
30
+ v: 1;
31
+ leaseId: string;
32
+ streamId: string;
33
+ };
34
+ export declare function isRelayToken(value: unknown): value is string;
35
+ export declare function isRelayHostId(value: unknown): value is string;
36
+ /** Canonical HTTPS origin only: no credentials, path, fragment or query. */
37
+ export declare function isRelayOrigin(value: unknown): value is string;
38
+ /** Validate an already JSON-decoded message; wire size/direction are checked by its receiver. */
39
+ export declare function parseRelayControlMessage(value: unknown): RelayControlMessage | undefined;
@@ -0,0 +1,64 @@
1
+ /** Device relay v1. The relay terminates TLS and is a trusted transport. */
2
+ export const RELAY_PROTOCOL_VERSION = 1;
3
+ export const RELAY_CONTROL_MAX_BYTES = 16 * 1024;
4
+ export const RELAY_DATA_MAX_BYTES = 64 * 1024;
5
+ export const RELAY_DATA_CHUNK_BYTES = 32 * 1024;
6
+ export const RELAY_MAX_STREAMS = 32;
7
+ export const RELAY_CONNECT_PATH = "/api/v1/remote-hosts/connect";
8
+ export const RELAY_STREAMS_PATH = "/api/v1/remote-hosts/streams/";
9
+ export const RELAY_SETUP_TIMEOUT_MS = 10_000;
10
+ export const RELAY_PING_INTERVAL_MS = 20_000;
11
+ export const RELAY_PONG_TIMEOUT_MS = 60_000;
12
+ export function isRelayToken(value) {
13
+ return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value);
14
+ }
15
+ export function isRelayHostId(value) {
16
+ return typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
17
+ }
18
+ /** Canonical HTTPS origin only: no credentials, path, fragment or query. */
19
+ export function isRelayOrigin(value) {
20
+ if (typeof value !== "string")
21
+ return false;
22
+ try {
23
+ const url = new URL(value);
24
+ return url.protocol === "https:" && url.origin === value && !url.username && !url.password;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Validate an already JSON-decoded message; wire size/direction are checked by its receiver. */
31
+ export function parseRelayControlMessage(value) {
32
+ if (!value || typeof value !== "object" || Array.isArray(value))
33
+ return;
34
+ const message = value;
35
+ if (message.v !== RELAY_PROTOCOL_VERSION || !isRelayToken(message.leaseId))
36
+ return;
37
+ switch (message.type) {
38
+ case "welcome":
39
+ if (isRelayHostId(message.hostId) && isRelayOrigin(message.publicOrigin))
40
+ return {
41
+ type: "welcome",
42
+ v: 1,
43
+ leaseId: message.leaseId,
44
+ hostId: message.hostId,
45
+ publicOrigin: message.publicOrigin,
46
+ };
47
+ return;
48
+ case "ready":
49
+ return { type: "ready", v: 1, leaseId: message.leaseId };
50
+ case "open":
51
+ if (isRelayToken(message.streamId) && isRelayToken(message.ticket))
52
+ return {
53
+ type: "open",
54
+ v: 1,
55
+ leaseId: message.leaseId,
56
+ streamId: message.streamId,
57
+ ticket: message.ticket,
58
+ };
59
+ return;
60
+ case "failed":
61
+ if (isRelayToken(message.streamId))
62
+ return { type: "failed", v: 1, leaseId: message.leaseId, streamId: message.streamId };
63
+ }
64
+ }
@@ -0,0 +1,16 @@
1
+ import type { CronJob, CronRunRequest } from "@cjhyy/code-shell-core/internal";
2
+ import { WorkerBridgeCore } from "../worker-bridge-core.js";
3
+ export interface HubAutomationWorkerOptions {
4
+ bridge: WorkerBridgeCore;
5
+ cwd: string;
6
+ /** Must synchronously exclude other runs/configuration changes until release. */
7
+ reserve(job: CronJob, requestId: string): () => void;
8
+ model(): string | undefined;
9
+ admissionTimeoutMs?: number;
10
+ }
11
+ /** Borrows the project's live Worker so persisted Session state has one writer. */
12
+ export declare function createHubAutomationWorker(options: HubAutomationWorkerOptions): {
13
+ execute: (request: CronRunRequest) => Promise<void>;
14
+ handleApproval: (params: Record<string, any>) => boolean;
15
+ ownsSession: (sessionId: string) => boolean;
16
+ };