@capsulos/sdk 0.0.0-stage → 0.1.1

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/dist/pty.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { type Connection, type PtyOptions } from './types.js';
2
+ export declare class PtySession {
3
+ private readonly ws;
4
+ private readonly dataListeners;
5
+ private readonly exitPromise;
6
+ private resolveExit;
7
+ private rejectExit;
8
+ private settled;
9
+ private constructor();
10
+ /** Open a terminal. Resolves once the socket is up; the process is already running. */
11
+ static open(conn: Connection, opts?: PtyOptions): Promise<PtySession>;
12
+ /** Send keystrokes (or any bytes) to the terminal. */
13
+ write(data: string | Uint8Array): void;
14
+ resize(cols: number, rows: number): void;
15
+ /** Subscribe to terminal output; returns an unsubscribe function. */
16
+ onData(listener: (chunk: Uint8Array) => void): () => void;
17
+ /** Resolves with the exit code when the process ends; rejects if the connection drops first. */
18
+ wait(): Promise<number>;
19
+ /** Hang up. The daemon kills the process when the socket goes away. */
20
+ close(): void;
21
+ private settle;
22
+ private fail;
23
+ }
package/dist/pty.js ADDED
@@ -0,0 +1,122 @@
1
+ // An interactive terminal inside the capsule, over a WebSocket to capsulod's
2
+ // /pty: binary frames are terminal bytes in both directions, text frames are
3
+ // JSON control messages (resize from us, exit from the daemon).
4
+ import { CapsuleError } from './types.js';
5
+ /** Explain a failed upgrade: unreachable daemon, or one too old to have /pty. */
6
+ async function diagnose(conn, headers) {
7
+ try {
8
+ const res = await fetch(`${conn.baseUrl}/pty`, { headers, signal: AbortSignal.timeout(5_000) });
9
+ if (res.status === 404) {
10
+ return `this capsule's capsulod has no /pty endpoint: rebuild the template with the current daemon (pnpm docker:base, or capsulos template build) and create a new capsule`;
11
+ }
12
+ return `${conn.baseUrl}/pty answered HTTP ${res.status} instead of upgrading to a WebSocket`;
13
+ }
14
+ catch (err) {
15
+ return `cannot reach ${conn.baseUrl} (${err instanceof Error ? err.message : String(err)})`;
16
+ }
17
+ }
18
+ export class PtySession {
19
+ ws;
20
+ dataListeners = new Set();
21
+ exitPromise;
22
+ resolveExit;
23
+ rejectExit;
24
+ settled = false;
25
+ constructor(ws) {
26
+ this.ws = ws;
27
+ this.exitPromise = new Promise((resolve, reject) => {
28
+ this.resolveExit = resolve;
29
+ this.rejectExit = reject;
30
+ });
31
+ // A dropped connection rejects wait(); callers that never call it must
32
+ // not take the process down with an unhandled rejection.
33
+ this.exitPromise.catch(() => undefined);
34
+ ws.binaryType = 'arraybuffer';
35
+ ws.addEventListener('message', (ev) => {
36
+ if (typeof ev.data === 'string') {
37
+ const msg = JSON.parse(ev.data);
38
+ if (msg.type === 'exit')
39
+ this.settle(msg.exitCode);
40
+ return;
41
+ }
42
+ const chunk = new Uint8Array(ev.data);
43
+ for (const l of this.dataListeners)
44
+ l(chunk);
45
+ });
46
+ ws.addEventListener('close', (ev) => {
47
+ if (!this.settled)
48
+ this.fail(new CapsuleError(`pty closed without exit (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`));
49
+ });
50
+ ws.addEventListener('error', () => {
51
+ if (!this.settled)
52
+ this.fail(new CapsuleError('pty connection error'));
53
+ });
54
+ }
55
+ /** Open a terminal. Resolves once the socket is up; the process is already running. */
56
+ static open(conn, opts = {}) {
57
+ return new Promise((resolve, reject) => {
58
+ void (async () => {
59
+ const q = new URLSearchParams();
60
+ if (opts.cmd)
61
+ q.set('cmd', opts.cmd);
62
+ for (const a of opts.args ?? [])
63
+ q.append('arg', a);
64
+ if (opts.cwd)
65
+ q.set('cwd', opts.cwd);
66
+ for (const [k, v] of Object.entries(opts.env ?? {}))
67
+ q.append('env', `${k}=${v}`);
68
+ q.set('cols', String(opts.cols ?? 80));
69
+ q.set('rows', String(opts.rows ?? 24));
70
+ const url = `${conn.baseUrl.replace(/^http/, 'ws')}/pty?${q}`;
71
+ const headers = await conn.headers();
72
+ const WS = globalThis.WebSocket;
73
+ const ws = new WS(url, Object.keys(headers).length ? { headers } : undefined);
74
+ const timer = setTimeout(() => {
75
+ ws.close();
76
+ reject(new CapsuleError('pty connect timeout'));
77
+ }, opts.connectTimeoutMs ?? 30_000);
78
+ const session = new PtySession(ws);
79
+ if (opts.onData)
80
+ session.onData(opts.onData);
81
+ ws.addEventListener('open', () => {
82
+ clearTimeout(timer);
83
+ resolve(session);
84
+ });
85
+ ws.addEventListener('error', () => {
86
+ clearTimeout(timer);
87
+ // Node's WebSocket error carries no status; ask the daemon directly.
88
+ void diagnose(conn, headers).then((why) => reject(new CapsuleError(`pty connection failed: ${why}`)));
89
+ });
90
+ })().catch(reject);
91
+ });
92
+ }
93
+ /** Send keystrokes (or any bytes) to the terminal. */
94
+ write(data) {
95
+ this.ws.send(typeof data === 'string' ? new TextEncoder().encode(data) : data);
96
+ }
97
+ resize(cols, rows) {
98
+ this.ws.send(JSON.stringify({ type: 'resize', cols, rows }));
99
+ }
100
+ /** Subscribe to terminal output; returns an unsubscribe function. */
101
+ onData(listener) {
102
+ this.dataListeners.add(listener);
103
+ return () => this.dataListeners.delete(listener);
104
+ }
105
+ /** Resolves with the exit code when the process ends; rejects if the connection drops first. */
106
+ wait() {
107
+ return this.exitPromise;
108
+ }
109
+ /** Hang up. The daemon kills the process when the socket goes away. */
110
+ close() {
111
+ this.ws.close(1000, 'client closed');
112
+ }
113
+ settle(code) {
114
+ this.settled = true;
115
+ this.resolveExit(code);
116
+ }
117
+ fail(err) {
118
+ this.settled = true;
119
+ this.rejectExit(err);
120
+ }
121
+ }
122
+ //# sourceMappingURL=pty.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pty.js","sourceRoot":"","sources":["../src/pty.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,168 @@
1
+ /** Where a capsule lives. Implemented by @capsulos/provider-* packages. */
2
+ export interface Provider {
3
+ readonly name: string;
4
+ /** Create a capsule and wait until it runs (see CreateOptions for the wait). */
5
+ create(spec: CapsuleSpec, opts?: CreateOptions): Promise<CapsuleHandle>;
6
+ get(id: string): Promise<CapsuleInfo | null>;
7
+ /** Capsules this provider knows about, optionally only those of one template. */
8
+ list(template?: string): Promise<CapsuleInfo[]>;
9
+ terminate(id: string): Promise<void>;
10
+ /** How to reach the daemon of an existing capsule (endpoint + auth headers). */
11
+ connect(id: string): Promise<Connection>;
12
+ }
13
+ export interface CreateOptions {
14
+ /**
15
+ * How long to wait for the capsule to run before giving up (provider
16
+ * default: 120 s). On timeout the provider terminates what it started, so
17
+ * nothing is left running without an owner, and throws a CapsuleError
18
+ * whose `capsuleId` names it.
19
+ */
20
+ timeoutMs?: number;
21
+ /** Abort the wait (same clean-up as a timeout). */
22
+ signal?: AbortSignal;
23
+ }
24
+ export interface HeadersOptions {
25
+ /** Discard any cached credential and mint a new one (after a 401/403). */
26
+ fresh?: boolean;
27
+ }
28
+ export interface Connection {
29
+ /** Base URL of capsulod, e.g. https://abc.microvm.lambda-url.us-east-1.on.aws */
30
+ baseUrl: string;
31
+ /** Headers every request must carry (e.g. X-aws-proxy-auth). Cached by the provider; `fresh` re-mints. */
32
+ headers(opts?: HeadersOptions): Promise<Record<string, string>>;
33
+ }
34
+ export type CapsuleState = 'pending' | 'running' | 'suspended' | 'terminated' | 'unknown';
35
+ export interface CapsuleInfo {
36
+ id: string;
37
+ provider: string;
38
+ state: CapsuleState;
39
+ template: string | null;
40
+ createdAt: Date | null;
41
+ /** When the provider terminated it (provider clock); null while alive or unknown. */
42
+ terminatedAt?: Date | null;
43
+ /** The provider's reason for the current state (e.g. why it died), when it gives one. */
44
+ stateReason?: string | null;
45
+ /** Where capsulod answers (the Connection's baseUrl), when the provider knows it without another call. */
46
+ endpoint?: string | null;
47
+ /** Version of the template the capsule runs (a MicrovmImage version on Lambda). */
48
+ templateVersion?: string | null;
49
+ /** The hard wall-clock limit it was created with. */
50
+ maxDurationSeconds?: number | null;
51
+ }
52
+ export interface CapsuleHandle extends CapsuleInfo {
53
+ connection: Connection;
54
+ }
55
+ /** A mount declared at creation. `posix` drivers are mounted in the kernel and seen by processes. */
56
+ export type MountSpec = {
57
+ driver: 'local';
58
+ } | {
59
+ driver: 'tmpfs';
60
+ sizeMb?: number;
61
+ } | {
62
+ driver: 'overlay';
63
+ lower: string;
64
+ } | {
65
+ driver: 's3';
66
+ bucket: string;
67
+ prefix?: string;
68
+ readOnly?: boolean;
69
+ region?: string;
70
+ };
71
+ export interface MountResult {
72
+ path: string;
73
+ driver: string;
74
+ ok: boolean;
75
+ error?: string;
76
+ }
77
+ export interface Health {
78
+ ok: boolean;
79
+ version: string;
80
+ uptimeS: number;
81
+ /** True once the mount table was applied (by the boot hook or /configure). */
82
+ configured: boolean;
83
+ mounts?: MountResult[];
84
+ /** The template's long-running process (CAPSULOD_START), when the template declares one. */
85
+ service?: ServiceStatus;
86
+ }
87
+ export interface ServiceStatus {
88
+ cmd: string;
89
+ pid: number;
90
+ running: boolean;
91
+ restarts: number;
92
+ lastExitCode?: number;
93
+ startedAt?: string;
94
+ }
95
+ export interface IdlePolicy {
96
+ /** Suspend after this long without traffic (seconds). */
97
+ suspendAfterSeconds: number;
98
+ /** Destroy after this long suspended (seconds). */
99
+ destroyAfterSeconds: number;
100
+ }
101
+ export interface CapsuleSpec {
102
+ /** Template (image) name as registered with the provider. */
103
+ template: string;
104
+ /** Hard wall-clock limit for the capsule, in seconds. */
105
+ maxDurationSeconds?: number;
106
+ idle?: IdlePolicy;
107
+ mounts?: Record<string, MountSpec>;
108
+ /** Opaque per-capsule configuration delivered to the daemon at boot (never secrets). */
109
+ metadata?: Record<string, string>;
110
+ /** Idempotency key: creating twice with the same key returns the same capsule. */
111
+ clientToken?: string;
112
+ }
113
+ export interface ExecOptions {
114
+ args?: string[];
115
+ cwd?: string;
116
+ env?: Record<string, string>;
117
+ stdin?: string;
118
+ timeoutMs?: number;
119
+ onStdout?: (chunk: string) => void;
120
+ onStderr?: (chunk: string) => void;
121
+ }
122
+ export interface ExecResult {
123
+ exitCode: number;
124
+ stdout: string;
125
+ stderr: string;
126
+ timeout: boolean;
127
+ error?: string;
128
+ durationMs: number;
129
+ }
130
+ export type ExecEvent = {
131
+ type: 'stdout';
132
+ data: string;
133
+ } | {
134
+ type: 'stderr';
135
+ data: string;
136
+ } | {
137
+ type: 'exit';
138
+ exitCode: number;
139
+ timeout?: boolean;
140
+ error?: string;
141
+ };
142
+ export interface PtyOptions {
143
+ /** Program to run; defaults to bash when the template has it, else sh. */
144
+ cmd?: string;
145
+ args?: string[];
146
+ cwd?: string;
147
+ env?: Record<string, string>;
148
+ cols?: number;
149
+ rows?: number;
150
+ onData?: (chunk: Uint8Array) => void;
151
+ connectTimeoutMs?: number;
152
+ }
153
+ export interface FileEntry {
154
+ name: string;
155
+ path: string;
156
+ isDir: boolean;
157
+ size: number;
158
+ mode: string;
159
+ modTime: string;
160
+ }
161
+ export declare class CapsuleError extends Error {
162
+ readonly status?: number | undefined;
163
+ /** The capsule the error is about, when there is one (e.g. the one a create() timeout terminated). */
164
+ readonly capsuleId?: string;
165
+ constructor(message: string, status?: number | undefined, opts?: {
166
+ capsuleId?: string;
167
+ });
168
+ }
package/dist/types.js ADDED
@@ -0,0 +1,17 @@
1
+ // The three axes of capsulos (docs/ARCHITECTURE.md):
2
+ // Provider — where a capsule runs (lambda, docker, later firecracker)
3
+ // Primitives — what a capsule exposes (process, fs, pty, ports)
4
+ // Drivers — how a primitive is implemented (fs: local, tmpfs, overlay, s3)
5
+ export class CapsuleError extends Error {
6
+ status;
7
+ /** The capsule the error is about, when there is one (e.g. the one a create() timeout terminated). */
8
+ capsuleId;
9
+ constructor(message, status, opts = {}) {
10
+ super(message);
11
+ this.status = status;
12
+ this.name = 'CapsuleError';
13
+ if (opts.capsuleId)
14
+ this.capsuleId = opts.capsuleId;
15
+ }
16
+ }
17
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,qDAAqD;AACrD,0EAA0E;AAC1E,kEAAkE;AAClE,gFAAgF;AAwKhF,MAAM,OAAO,YAAa,SAAQ,KAAK;IAM1B;IALX,sGAAsG;IAC7F,SAAS,CAAU;IAE5B,YACE,OAAe,EACN,MAAe,EACxB,OAA+B,EAAE;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,WAAM,GAAN,MAAM,CAAS;QAIxB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,IAAI,CAAC,SAAS;YAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;IACtD,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@capsulos/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Throw a capsule, get a computer. Sandboxes for AI agents on your own AWS account (Lambda MicroVMs) or on Docker.",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "sandbox",
8
+ "ai-agents",
9
+ "aws",
10
+ "lambda",
11
+ "microvm",
12
+ "firecracker",
13
+ "e2b",
14
+ "daytona"
15
+ ],
16
+ "homepage": "https://github.com/tornoai/capsulos#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/tornoai/capsulos.git",
20
+ "directory": "packages/sdk"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/tornoai/capsulos/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "sideEffects": false,
38
+ "files": [
39
+ "dist",
40
+ "src"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.20.1",
47
+ "typescript": "^5.9.0",
48
+ "vitest": "^4.1.10"
49
+ },
50
+ "scripts": {
51
+ "typecheck": "tsc --noEmit",
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "test": "vitest run --passWithNoTests"
54
+ }
6
55
  }
package/src/capsule.ts ADDED
@@ -0,0 +1,139 @@
1
+ // The public face of a capsule. `Capsule.create()` asks the provider for a
2
+ // machine and waits until capsulod answers; everything after that goes
3
+ // straight to the daemon.
4
+ import { DaemonClient, type ClientOptions } from './client.js';
5
+ import { Ports } from './ports.js';
6
+ import { PtySession } from './pty.js';
7
+ import {
8
+ CapsuleError,
9
+ type CapsuleSpec,
10
+ type ExecOptions,
11
+ type ExecResult,
12
+ type FileEntry,
13
+ type Health,
14
+ type Provider,
15
+ type PtyOptions,
16
+ } from './types.js';
17
+
18
+ export interface CapsuleOptions extends ClientOptions {
19
+ /** How long to wait for the daemon to answer /health after creation. */
20
+ readyTimeoutMs?: number;
21
+ /** How long the provider may take to run the capsule (default 120 s); on timeout it is terminated. */
22
+ createTimeoutMs?: number;
23
+ /** Abort creation (the provider terminates what it started) or the wait for the daemon. */
24
+ signal?: AbortSignal;
25
+ }
26
+
27
+ export class Capsule {
28
+ readonly commands: Commands;
29
+ readonly files: Files;
30
+ readonly pty: Pty;
31
+ /** Servers listening inside the capsule, reachable through the daemon (preview URLs). */
32
+ readonly ports: Ports;
33
+
34
+ private constructor(
35
+ readonly id: string,
36
+ private readonly provider: Provider,
37
+ private readonly client: DaemonClient,
38
+ ) {
39
+ this.commands = new Commands(client);
40
+ this.files = new Files(client);
41
+ this.pty = new Pty(client);
42
+ this.ports = new Ports(client.connection);
43
+ }
44
+
45
+ /** Throw a capsule: create it with the provider and wait until it is ready. */
46
+ static async create(provider: Provider, spec: CapsuleSpec, opts: CapsuleOptions = {}): Promise<Capsule> {
47
+ const handle = await provider.create(spec, { timeoutMs: opts.createTimeoutMs, signal: opts.signal });
48
+ const client = new DaemonClient(handle.connection, opts);
49
+ const health = await waitReady(client, opts.readyTimeoutMs ?? 120_000, opts.signal);
50
+ // Providers deliver the mount table through their boot hook. When that did
51
+ // not happen (no hook, or the hook came without its payload), hand it over
52
+ // directly. Either way, a mount that failed is an error here, not a
53
+ // surprise later when a write lands in a plain directory.
54
+ let results = health.mounts ?? [];
55
+ const wanted = Object.keys(spec.mounts ?? {});
56
+ const applied = new Set(results.map((r) => r.path));
57
+ const hookMissed = !health.configured || wanted.some((p) => !applied.has(p));
58
+ if (hookMissed && (spec.mounts || spec.metadata)) {
59
+ results = await client.configure({ mounts: spec.mounts, metadata: spec.metadata });
60
+ }
61
+ const failed = results.filter((r) => !r.ok);
62
+ if (failed.length) {
63
+ throw new CapsuleError(`mounts failed: ${failed.map((r) => `${r.path} (${r.driver}): ${r.error}`).join('; ')}`);
64
+ }
65
+ return new Capsule(handle.id, provider, client);
66
+ }
67
+
68
+ /** Reconnect to a capsule that already exists (it may be suspended: the first request resumes it). */
69
+ static async connect(provider: Provider, id: string, opts: CapsuleOptions = {}): Promise<Capsule> {
70
+ const conn = await provider.connect(id);
71
+ return new Capsule(id, provider, new DaemonClient(conn, opts));
72
+ }
73
+
74
+ health() {
75
+ return this.client.health();
76
+ }
77
+
78
+ /** Put the capsule back in your pocket. */
79
+ async kill(): Promise<void> {
80
+ await this.provider.terminate(this.id);
81
+ }
82
+ }
83
+
84
+ export class Commands {
85
+ constructor(private readonly client: DaemonClient) {}
86
+ /** Run a command (through `sh -c` unless `args` is given). Pass onStdout/onStderr for live output. */
87
+ run(cmd: string, opts?: ExecOptions): Promise<ExecResult> {
88
+ return this.client.exec(cmd, opts);
89
+ }
90
+ }
91
+
92
+ export class Pty {
93
+ constructor(private readonly client: DaemonClient) {}
94
+ /** Open an interactive terminal (a shell by default). */
95
+ open(opts?: PtyOptions): Promise<PtySession> {
96
+ return PtySession.open(this.client.connection, opts);
97
+ }
98
+ }
99
+
100
+ export class Files {
101
+ constructor(private readonly client: DaemonClient) {}
102
+ read(path: string): Promise<string> {
103
+ return this.client.readText(path);
104
+ }
105
+ readBytes(path: string): Promise<Uint8Array> {
106
+ return this.client.readFile(path);
107
+ }
108
+ write(path: string, data: string | Uint8Array, opts?: { mode?: string }): Promise<void> {
109
+ return this.client.writeFile(path, data, opts);
110
+ }
111
+ list(path: string): Promise<FileEntry[]> {
112
+ return this.client.list(path);
113
+ }
114
+ stat(path: string): Promise<FileEntry> {
115
+ return this.client.stat(path);
116
+ }
117
+ mkdir(path: string): Promise<void> {
118
+ return this.client.mkdir(path);
119
+ }
120
+ remove(path: string): Promise<void> {
121
+ return this.client.remove(path);
122
+ }
123
+ }
124
+
125
+ async function waitReady(client: DaemonClient, timeoutMs: number, signal?: AbortSignal): Promise<Health> {
126
+ const t0 = Date.now();
127
+ let lastErr: unknown;
128
+ while (Date.now() - t0 < timeoutMs) {
129
+ if (signal?.aborted) throw new CapsuleError('wait for the capsule daemon aborted');
130
+ try {
131
+ const h = await client.health();
132
+ if (h.ok) return h;
133
+ } catch (err) {
134
+ lastErr = err;
135
+ }
136
+ await new Promise((r) => setTimeout(r, 400));
137
+ }
138
+ throw new CapsuleError(`capsule daemon did not become ready in ${timeoutMs}ms (${String(lastErr)})`);
139
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DaemonClient, parseSse } from './client.js';
3
+ import type { Connection } from './types.js';
4
+
5
+ const conn: Connection = { baseUrl: 'http://capsule', headers: async () => ({ 'X-Test': '1' }) };
6
+
7
+ function sseResponse(frames: string[]): Response {
8
+ return new Response(frames.join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
9
+ }
10
+
11
+ describe('parseSse', () => {
12
+ it('yields one event per frame, across chunk boundaries', async () => {
13
+ const res = sseResponse([
14
+ 'event: stdout\ndata: {"type":"stdout","data":"he"}\n\n',
15
+ 'event: stdout\ndata: {"type":"stdout","data":"llo"}\n\n',
16
+ 'event: exit\ndata: {"type":"exit","exitCode":0}\n\n',
17
+ ]);
18
+ const events = [];
19
+ for await (const ev of parseSse(res)) events.push(ev);
20
+ expect(events).toEqual([
21
+ { type: 'stdout', data: 'he' },
22
+ { type: 'stdout', data: 'llo' },
23
+ { type: 'exit', exitCode: 0 },
24
+ ]);
25
+ });
26
+ });
27
+
28
+ describe('DaemonClient.exec', () => {
29
+ it('streams when callbacks are given and sends auth headers', async () => {
30
+ let seen: Request | undefined;
31
+ const fetchImpl: typeof fetch = async (input, init) => {
32
+ seen = new Request(input, init);
33
+ return sseResponse(['data: {"type":"stdout","data":"x"}\n\n', 'data: {"type":"exit","exitCode":2}\n\n']);
34
+ };
35
+ const client = new DaemonClient(conn, { fetch: fetchImpl });
36
+ const chunks: string[] = [];
37
+ const res = await client.exec('echo x', { onStdout: (c) => chunks.push(c) });
38
+ expect(chunks).toEqual(['x']);
39
+ expect(res.exitCode).toBe(2);
40
+ expect(seen?.headers.get('X-Test')).toBe('1');
41
+ expect(seen?.headers.get('Accept')).toBe('text/event-stream');
42
+ });
43
+
44
+ it('turns non-2xx into CapsuleError with the daemon message', async () => {
45
+ const fetchImpl: typeof fetch = async () => new Response('{"error":"nope"}', { status: 404 });
46
+ const client = new DaemonClient(conn, { fetch: fetchImpl });
47
+ await expect(client.readText('/x')).rejects.toThrow(/HTTP 404: nope/);
48
+ });
49
+ });
50
+
51
+ describe('DaemonClient credentials', () => {
52
+ it('retries once with fresh headers when the endpoint rejects the token', async () => {
53
+ const minted: boolean[] = [];
54
+ const conn: Connection = {
55
+ baseUrl: 'http://capsule',
56
+ headers: async (opts) => {
57
+ minted.push(Boolean(opts?.fresh));
58
+ return { 'X-Token': opts?.fresh ? 'new' : 'stale' };
59
+ },
60
+ };
61
+ const client = new DaemonClient(conn, {
62
+ fetch: async (_url, init) => {
63
+ const token = new Headers(init?.headers).get('X-Token');
64
+ return token === 'new'
65
+ ? new Response(JSON.stringify({ ok: true, version: 't', uptimeS: 1, configured: true }), { status: 200 })
66
+ : new Response(JSON.stringify({ error: 'forbidden' }), { status: 403 });
67
+ },
68
+ });
69
+ const health = await client.health();
70
+ expect(health.ok).toBe(true);
71
+ expect(minted).toEqual([false, true]);
72
+ });
73
+
74
+ it('gives up after one fresh attempt', async () => {
75
+ const conn: Connection = { baseUrl: 'http://capsule', headers: async () => ({}) };
76
+ const client = new DaemonClient(conn, { fetch: async () => new Response('{"error":"no"}', { status: 403 }) });
77
+ await expect(client.health()).rejects.toThrow(/HTTP 403/);
78
+ });
79
+ });