@sandbank.dev/core 0.1.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.
@@ -0,0 +1,101 @@
1
+ import type { SandboxProvider, Sandbox, CreateConfig } from './types.js';
2
+ export type MessagePriority = 'normal' | 'steer';
3
+ export interface SessionMessage {
4
+ from: string;
5
+ to: string | null;
6
+ type: string;
7
+ payload: unknown;
8
+ priority: MessagePriority;
9
+ timestamp: string;
10
+ }
11
+ export interface ContextStore {
12
+ get<T = unknown>(key: string): Promise<T | undefined>;
13
+ set<T = unknown>(key: string, value: T): Promise<void>;
14
+ delete(key: string): Promise<void>;
15
+ keys(): Promise<string[]>;
16
+ watch(key: string, fn: (value: unknown) => void): () => void;
17
+ watchAll(fn: (key: string, value: unknown) => void): () => void;
18
+ }
19
+ export type CompletionStatus = 'success' | 'failure' | 'cancelled';
20
+ export interface SandboxCompletion {
21
+ sandboxName: string;
22
+ status: CompletionStatus;
23
+ summary: string;
24
+ timestamp: string;
25
+ }
26
+ export interface Session {
27
+ /** Session 唯一标识 */
28
+ readonly id: string;
29
+ /** 注册并创建沙箱 */
30
+ spawn(name: string, config: CreateConfig): Promise<Sandbox>;
31
+ /** 获取已注册的沙箱 */
32
+ getSandbox(name: string): Sandbox | undefined;
33
+ /** 列出已注册的沙箱名 */
34
+ listSandboxes(): string[];
35
+ /** 发送消息给指定沙箱 */
36
+ send(to: string, type: string, payload?: unknown, options?: SendOptions): void;
37
+ /** 广播消息给所有沙箱 */
38
+ broadcast(type: string, payload?: unknown, options?: SendOptions): void;
39
+ /** 共享上下文存储 */
40
+ readonly context: ContextStore;
41
+ /** 监听收到的消息 */
42
+ onMessage(fn: (msg: SessionMessage) => void): () => void;
43
+ /** 监听沙箱状态变化 */
44
+ onSandboxState(fn: (info: {
45
+ name: string;
46
+ status: CompletionStatus;
47
+ summary: string;
48
+ }) => void): () => void;
49
+ /** 等待指定沙箱完成 */
50
+ waitFor(name: string, timeoutMs?: number): Promise<SandboxCompletion>;
51
+ /** 等待所有沙箱完成 */
52
+ waitForAll(timeoutMs?: number): Promise<SandboxCompletion[]>;
53
+ /** 关闭 session(销毁所有沙箱、关闭 relay) */
54
+ close(): Promise<void>;
55
+ }
56
+ export interface SendOptions {
57
+ priority?: MessagePriority;
58
+ }
59
+ export type RelayConfig = {
60
+ type: 'memory';
61
+ } | {
62
+ type: 'hosted';
63
+ url: string;
64
+ token?: string;
65
+ };
66
+ export interface CreateSessionConfig {
67
+ provider: SandboxProvider;
68
+ relay?: RelayConfig;
69
+ timeoutMinutes?: number;
70
+ maxSandboxes?: number;
71
+ onError?: (error: Error) => void;
72
+ }
73
+ export interface JsonRpcRequest {
74
+ jsonrpc: '2.0';
75
+ id: number | string;
76
+ method: string;
77
+ params?: Record<string, unknown>;
78
+ }
79
+ export interface JsonRpcResponse {
80
+ jsonrpc: '2.0';
81
+ id: number | string | null;
82
+ result?: unknown;
83
+ error?: JsonRpcError;
84
+ }
85
+ export interface JsonRpcNotification {
86
+ jsonrpc: '2.0';
87
+ method: string;
88
+ params?: Record<string, unknown>;
89
+ }
90
+ export interface JsonRpcError {
91
+ code: number;
92
+ message: string;
93
+ data?: unknown;
94
+ }
95
+ export interface Transport {
96
+ send(data: string): void;
97
+ onMessage(fn: (data: string) => void): void;
98
+ close(): void;
99
+ readonly readyState: 'connecting' | 'open' | 'closed';
100
+ }
101
+ //# sourceMappingURL=session-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-types.d.ts","sourceRoot":"","sources":["../src/session-types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { Session, CreateSessionConfig } from './session-types.js';
2
+ export declare function createSession(config: CreateSessionConfig): Promise<Session>;
3
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EACP,mBAAmB,EASpB,MAAM,oBAAoB,CAAA;AAI3B,wBAAsB,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,OAAO,CAAC,CA6VjF"}
@@ -0,0 +1,320 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export async function createSession(config) {
3
+ let rpcId = 1;
4
+ function createRpcRequest(method, params) {
5
+ return { jsonrpc: '2.0', id: rpcId++, method, ...(params ? { params } : {}) };
6
+ }
7
+ const sessionId = `session-${randomUUID()}`;
8
+ const relayConfig = config.relay ?? { type: 'memory' };
9
+ const onError = config.onError ?? (() => { });
10
+ // 启动 relay(memory 模式:动态 import @sandbank.dev/relay)
11
+ let relayUrl;
12
+ let wsUrl;
13
+ let closeRelay;
14
+ if (relayConfig.type === 'memory') {
15
+ // Dynamic import: @sandbank.dev/relay is an optional peer dep (no static dependency)
16
+ const specifier = '@sandbank.dev/relay';
17
+ const relayMod = await import(specifier);
18
+ const relay = await relayMod.startRelay({ port: 0 });
19
+ relayUrl = relay.url;
20
+ wsUrl = relay.wsUrl;
21
+ closeRelay = () => relay.close();
22
+ }
23
+ else {
24
+ relayUrl = relayConfig.url;
25
+ wsUrl = relayConfig.url.replace(/^https?/, (p) => p === 'https' ? 'wss' : 'ws');
26
+ closeRelay = async () => { };
27
+ }
28
+ // 编排者 WebSocket 连接
29
+ const ws = new WebSocket(wsUrl);
30
+ await new Promise((resolve, reject) => {
31
+ ws.addEventListener('open', () => resolve());
32
+ ws.addEventListener('error', () => reject(new Error('Failed to connect to relay')));
33
+ });
34
+ const pending = new Map();
35
+ const messageListeners = [];
36
+ const stateListeners = [];
37
+ const completions = new Map();
38
+ const completionWaiters = new Map();
39
+ // 路由 WebSocket 消息
40
+ ws.addEventListener('message', (evt) => {
41
+ const data = typeof evt.data === 'string' ? evt.data : String(evt.data);
42
+ try {
43
+ const msg = JSON.parse(data);
44
+ // RPC 响应
45
+ if ('id' in msg && msg.id != null) {
46
+ const entry = pending.get(msg.id);
47
+ if (entry) {
48
+ pending.delete(msg.id);
49
+ if (msg.error) {
50
+ entry.reject(new Error(`RPC error: ${msg.error.message}`));
51
+ }
52
+ else {
53
+ entry.resolve(msg.result);
54
+ }
55
+ }
56
+ return;
57
+ }
58
+ // 通知
59
+ if ('method' in msg) {
60
+ if (msg.method === 'message' && msg.params) {
61
+ const sessionMsg = msg.params;
62
+ for (const fn of messageListeners)
63
+ fn(sessionMsg);
64
+ }
65
+ else if (msg.method === 'sandbox.state' && msg.params) {
66
+ const { name, status, summary } = msg.params;
67
+ const completion = {
68
+ sandboxName: name,
69
+ status,
70
+ summary,
71
+ timestamp: new Date().toISOString(),
72
+ };
73
+ completions.set(name, completion);
74
+ for (const fn of stateListeners)
75
+ fn({ name, status, summary });
76
+ // 唤醒 waitFor
77
+ const waiters = completionWaiters.get(name);
78
+ if (waiters) {
79
+ for (const w of waiters) {
80
+ if (w.timer)
81
+ clearTimeout(w.timer);
82
+ w.resolve(completion);
83
+ }
84
+ completionWaiters.delete(name);
85
+ }
86
+ }
87
+ else if (msg.method === 'context.changed') {
88
+ // context 变更 → 通知 context watchers
89
+ const { key, value } = msg.params;
90
+ for (const fn of contextKeyWatchers.get(key) ?? [])
91
+ fn(value);
92
+ for (const fn of contextAllWatchers)
93
+ fn(key, value);
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // ignore parse errors
99
+ }
100
+ });
101
+ // Handle unexpected disconnects: reject all pending calls and waiters
102
+ ws.addEventListener('close', () => {
103
+ const err = new Error('Relay connection closed');
104
+ for (const [, entry] of pending)
105
+ entry.reject(err);
106
+ pending.clear();
107
+ for (const [, waiters] of completionWaiters) {
108
+ for (const w of waiters) {
109
+ if (w.timer)
110
+ clearTimeout(w.timer);
111
+ w.reject(err);
112
+ }
113
+ }
114
+ completionWaiters.clear();
115
+ });
116
+ const RPC_TIMEOUT_MS = 30_000;
117
+ async function rpcCall(method, params) {
118
+ const req = createRpcRequest(method, params);
119
+ return new Promise((resolve, reject) => {
120
+ const timer = setTimeout(() => {
121
+ pending.delete(req.id);
122
+ reject(new Error(`RPC timeout: ${method} (${RPC_TIMEOUT_MS}ms)`));
123
+ }, RPC_TIMEOUT_MS);
124
+ pending.set(req.id, {
125
+ resolve: (result) => { clearTimeout(timer); resolve(result); },
126
+ reject: (err) => { clearTimeout(timer); reject(err); },
127
+ });
128
+ try {
129
+ ws.send(JSON.stringify(req));
130
+ }
131
+ catch (sendErr) {
132
+ clearTimeout(timer);
133
+ pending.delete(req.id);
134
+ reject(sendErr instanceof Error ? sendErr : new Error(String(sendErr)));
135
+ }
136
+ });
137
+ }
138
+ // 生成 session token
139
+ const token = (relayConfig.type === 'hosted' && relayConfig.token)
140
+ ? relayConfig.token
141
+ : crypto.randomUUID();
142
+ // 认证
143
+ const authResult = await rpcCall('session.auth', {
144
+ sessionId,
145
+ token,
146
+ role: 'orchestrator',
147
+ });
148
+ const sessionToken = authResult.token ?? token;
149
+ const sandboxes = new Map();
150
+ const contextKeyWatchers = new Map();
151
+ const contextAllWatchers = new Set();
152
+ // Context 代理
153
+ const context = {
154
+ async get(key) {
155
+ const result = await rpcCall('context.get', { key });
156
+ return result.value ?? undefined;
157
+ },
158
+ async set(key, value) {
159
+ await rpcCall('context.set', { key, value });
160
+ },
161
+ async delete(key) {
162
+ await rpcCall('context.delete', { key });
163
+ },
164
+ async keys() {
165
+ const result = await rpcCall('context.keys');
166
+ return result.keys;
167
+ },
168
+ watch(key, fn) {
169
+ if (!contextKeyWatchers.has(key))
170
+ contextKeyWatchers.set(key, new Set());
171
+ contextKeyWatchers.get(key).add(fn);
172
+ return () => { contextKeyWatchers.get(key)?.delete(fn); };
173
+ },
174
+ watchAll(fn) {
175
+ contextAllWatchers.add(fn);
176
+ return () => { contextAllWatchers.delete(fn); };
177
+ },
178
+ };
179
+ const session = {
180
+ id: sessionId,
181
+ async spawn(name, sandboxConfig) {
182
+ if (sandboxes.has(name)) {
183
+ throw new Error(`Sandbox name already in use: "${name}"`);
184
+ }
185
+ const maxSandboxes = config.maxSandboxes ?? 10;
186
+ if (sandboxes.size >= maxSandboxes) {
187
+ throw new Error(`Max sandboxes (${maxSandboxes}) reached`);
188
+ }
189
+ // Reserve the name to prevent concurrent spawn with same name or exceeding max
190
+ sandboxes.set(name, null);
191
+ try {
192
+ // 注入 relay 环境变量
193
+ const env = {
194
+ ...sandboxConfig.env,
195
+ SANDBANK_RELAY_URL: relayUrl,
196
+ SANDBANK_WS_URL: wsUrl,
197
+ SANDBANK_SESSION_ID: sessionId,
198
+ SANDBANK_SANDBOX_NAME: name,
199
+ SANDBANK_AUTH_TOKEN: sessionToken,
200
+ };
201
+ const sandbox = await config.provider.create({ ...sandboxConfig, env });
202
+ // 在 relay 注册沙箱
203
+ try {
204
+ await rpcCall('session.register', { name, sandboxId: sandbox.id });
205
+ }
206
+ catch (err) {
207
+ await config.provider.destroy(sandbox.id).catch(() => { });
208
+ throw err;
209
+ }
210
+ sandboxes.set(name, sandbox);
211
+ return sandbox;
212
+ }
213
+ catch (err) {
214
+ sandboxes.delete(name);
215
+ throw err;
216
+ }
217
+ },
218
+ getSandbox(name) {
219
+ const sb = sandboxes.get(name);
220
+ return sb ?? undefined; // filter out null placeholders from in-flight spawns
221
+ },
222
+ listSandboxes() {
223
+ return [...sandboxes.entries()]
224
+ .filter(([, sb]) => sb != null)
225
+ .map(([name]) => name);
226
+ },
227
+ // Fire-and-forget: errors are passed to the onError callback (default: silent).
228
+ // Use onError in CreateSessionConfig to handle delivery failures.
229
+ send(to, type, payload, options) {
230
+ rpcCall('message.send', {
231
+ to,
232
+ type,
233
+ payload: payload ?? null,
234
+ priority: options?.priority ?? 'normal',
235
+ }).catch(onError);
236
+ },
237
+ broadcast(type, payload, options) {
238
+ rpcCall('message.broadcast', {
239
+ type,
240
+ payload: payload ?? null,
241
+ priority: options?.priority ?? 'normal',
242
+ }).catch(onError);
243
+ },
244
+ context,
245
+ onMessage(fn) {
246
+ messageListeners.push(fn);
247
+ return () => {
248
+ const idx = messageListeners.indexOf(fn);
249
+ if (idx >= 0)
250
+ messageListeners.splice(idx, 1);
251
+ };
252
+ },
253
+ onSandboxState(fn) {
254
+ stateListeners.push(fn);
255
+ return () => {
256
+ const idx = stateListeners.indexOf(fn);
257
+ if (idx >= 0)
258
+ stateListeners.splice(idx, 1);
259
+ };
260
+ },
261
+ waitFor(name, timeoutMs) {
262
+ // 已经完成
263
+ const existing = completions.get(name);
264
+ if (existing)
265
+ return Promise.resolve(existing);
266
+ return new Promise((resolve, reject) => {
267
+ const waiter = { resolve, reject };
268
+ if (timeoutMs) {
269
+ waiter.timer = setTimeout(() => {
270
+ const waiters = completionWaiters.get(name);
271
+ if (waiters) {
272
+ const idx = waiters.indexOf(waiter);
273
+ if (idx >= 0)
274
+ waiters.splice(idx, 1);
275
+ }
276
+ reject(new Error(`Timeout waiting for sandbox "${name}" (${timeoutMs}ms)`));
277
+ }, timeoutMs);
278
+ }
279
+ if (!completionWaiters.has(name))
280
+ completionWaiters.set(name, []);
281
+ completionWaiters.get(name).push(waiter);
282
+ });
283
+ },
284
+ async waitForAll(timeoutMs) {
285
+ // Note: uses a snapshot of sandbox names at call time.
286
+ // Sandboxes spawned after this call will not be waited for.
287
+ const names = [...sandboxes.keys()];
288
+ const promises = names.map((name) => session.waitFor(name, timeoutMs));
289
+ return Promise.all(promises);
290
+ },
291
+ async close() {
292
+ // 先清理 pending,避免 unhandled rejection
293
+ for (const [, entry] of pending) {
294
+ entry.reject(new Error('Session closed'));
295
+ }
296
+ pending.clear();
297
+ // 清理 waitFor:reject 所有挂起的 waiter
298
+ for (const [, waiters] of completionWaiters) {
299
+ for (const w of waiters) {
300
+ if (w.timer)
301
+ clearTimeout(w.timer);
302
+ w.reject(new Error('Session closed'));
303
+ }
304
+ }
305
+ completionWaiters.clear();
306
+ // 并行销毁所有沙箱(在断开 WebSocket 前,确保 relay 仍可达)
307
+ // Filter out null placeholders from in-progress spawns
308
+ await Promise.allSettled([...sandboxes.values()].filter(Boolean).map(sandbox => config.provider.destroy(sandbox.id)));
309
+ sandboxes.clear();
310
+ // 断开 WebSocket 并等待关闭完成
311
+ await new Promise((resolve) => {
312
+ ws.addEventListener('close', () => resolve(), { once: true });
313
+ ws.close();
314
+ });
315
+ // 关闭 relay
316
+ await closeRelay();
317
+ },
318
+ };
319
+ return session;
320
+ }
@@ -0,0 +1,3 @@
1
+ import type { Sandbox, SkillDefinition } from './types.js';
2
+ export declare function injectSkills(sandbox: Sandbox, skills: SkillDefinition[], skillDir?: string): Promise<void>;
3
+ //# sourceMappingURL=skill-inject.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill-inject.d.ts","sourceRoot":"","sources":["../src/skill-inject.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAc1D,wBAAsB,YAAY,CAChC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,eAAe,EAAE,EACzB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -0,0 +1,23 @@
1
+ const DEFAULT_SKILL_DIR = '/root/.claude/skills';
2
+ function shellEscape(s) {
3
+ return "'" + s.replace(/'/g, "'\\''") + "'";
4
+ }
5
+ function validateSkillName(name) {
6
+ if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
7
+ throw new Error(`Invalid skill name: "${name}" — must not contain path separators or be empty`);
8
+ }
9
+ }
10
+ export async function injectSkills(sandbox, skills, skillDir) {
11
+ if (skills.length === 0)
12
+ return;
13
+ const dir = skillDir ?? DEFAULT_SKILL_DIR;
14
+ for (const skill of skills) {
15
+ validateSkillName(skill.name);
16
+ }
17
+ // Ensure target directory exists
18
+ await sandbox.exec(`mkdir -p ${shellEscape(dir)}`);
19
+ for (const skill of skills) {
20
+ const path = `${dir}/${skill.name}.md`;
21
+ await sandbox.writeFile(path, skill.content);
22
+ }
23
+ }
@@ -0,0 +1,8 @@
1
+ import type { TerminalInfo, TerminalSession } from './types.js';
2
+ /**
3
+ * 连接 ttyd WebSocket 端点,返回 TerminalSession。
4
+ *
5
+ * 使用全局 WebSocket(Node.js 22+ 和浏览器均原生支持)。
6
+ */
7
+ export declare function connectTerminal(info: TerminalInfo): TerminalSession;
8
+ //# sourceMappingURL=terminal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal.d.ts","sourceRoot":"","sources":["../src/terminal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAc,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAqB3E;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,YAAY,GAAG,eAAe,CA4EnE"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ttyd binary protocol constants.
3
+ *
4
+ * Client → Server:
5
+ * 0x00 + UTF-8 text = user input
6
+ * 0x01 + JSON string = resize {"columns":N,"rows":N}
7
+ *
8
+ * Server → Client:
9
+ * 0x00 + UTF-8 text = terminal output
10
+ * 0x01 = auth required (ignored)
11
+ * 0x02 + text = window title (ignored)
12
+ */
13
+ const MSG_INPUT = 0;
14
+ const MSG_RESIZE = 1;
15
+ const MSG_OUTPUT = 0;
16
+ const decoder = new TextDecoder();
17
+ const encoder = new TextEncoder();
18
+ /**
19
+ * 连接 ttyd WebSocket 端点,返回 TerminalSession。
20
+ *
21
+ * 使用全局 WebSocket(Node.js 22+ 和浏览器均原生支持)。
22
+ */
23
+ export function connectTerminal(info) {
24
+ const listeners = new Set();
25
+ let state = 'connecting';
26
+ let resolveReady;
27
+ let rejectReady;
28
+ const ready = new Promise((resolve, reject) => {
29
+ resolveReady = resolve;
30
+ rejectReady = reject;
31
+ });
32
+ const ws = new WebSocket(info.url);
33
+ ws.binaryType = 'arraybuffer';
34
+ ws.addEventListener('open', () => {
35
+ state = 'open';
36
+ resolveReady();
37
+ });
38
+ ws.addEventListener('message', (event) => {
39
+ const buf = new Uint8Array(event.data);
40
+ if (buf.length === 0)
41
+ return;
42
+ const type = buf[0];
43
+ if (type === MSG_OUTPUT) {
44
+ const text = decoder.decode(buf.subarray(1));
45
+ for (const fn of listeners)
46
+ fn(text);
47
+ }
48
+ // type 1 (auth) and type 2 (title) are ignored
49
+ });
50
+ ws.addEventListener('close', () => {
51
+ state = 'closed';
52
+ });
53
+ ws.addEventListener('error', (event) => {
54
+ if (state === 'connecting') {
55
+ rejectReady(new Error(`WebSocket connection failed: ${info.url}`));
56
+ }
57
+ state = 'closed';
58
+ });
59
+ return {
60
+ write(data) {
61
+ if (state !== 'open')
62
+ return;
63
+ const payload = encoder.encode(data);
64
+ const frame = new Uint8Array(1 + payload.length);
65
+ frame[0] = MSG_INPUT;
66
+ frame.set(payload, 1);
67
+ ws.send(frame);
68
+ },
69
+ onData(cb) {
70
+ listeners.add(cb);
71
+ return { dispose: () => { listeners.delete(cb); } };
72
+ },
73
+ resize(cols, rows) {
74
+ if (state !== 'open')
75
+ return;
76
+ const json = JSON.stringify({ columns: cols, rows });
77
+ const payload = encoder.encode(json);
78
+ const frame = new Uint8Array(1 + payload.length);
79
+ frame[0] = MSG_RESIZE;
80
+ frame.set(payload, 1);
81
+ ws.send(frame);
82
+ },
83
+ close() {
84
+ if (state === 'closed')
85
+ return;
86
+ state = 'closed';
87
+ ws.close();
88
+ },
89
+ get state() { return state; },
90
+ get ready() { return ready; },
91
+ };
92
+ }