@volter/teams 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.
- package/README.md +21 -0
- package/admission.mjs +62 -0
- package/browser-channel.mjs +157 -0
- package/channel.mjs +319 -0
- package/client.mjs +119 -0
- package/home.mjs +220 -0
- package/identity.mjs +85 -0
- package/index.mjs +8 -0
- package/machine-core.mjs +397 -0
- package/package.json +39 -0
- package/products.mjs +157 -0
- package/server/auth.mjs +13 -0
- package/server/core.mjs +64 -0
- package/server/events.mjs +3 -0
- package/server/http.mjs +211 -0
- package/server/identity.mjs +15 -0
- package/server/machine-link.mjs +228 -0
- package/server/migrations/001.mjs +298 -0
- package/server/migrations/002.mjs +13 -0
- package/server/migrations/003.mjs +11 -0
- package/server/migrations/004.mjs +35 -0
- package/server/migrations/index.mjs +6 -0
- package/server/request-json.mjs +74 -0
- package/server/wire.mjs +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# `@volter/teams`
|
|
2
|
+
|
|
3
|
+
Volter Teams, the neutral team plane (company RFC 0008): what any product needs to be
|
|
4
|
+
reached by a team, with no harness in it.
|
|
5
|
+
|
|
6
|
+
- **The machine daemon's core** (`machine-core.mjs`): this OS user's machine identity and
|
|
7
|
+
hash-chained account log, the owner-only local door, one outbound link to a Teams
|
|
8
|
+
server with every stream admitted from the machine's own synced grants, and product
|
|
9
|
+
doors. A product builds on `MachineCore` and registers its own doors by stream name;
|
|
10
|
+
the harness does (`@volter-ai-dev/supercode-teams`, `machine.mjs`).
|
|
11
|
+
- **Products behind the daemon** (`products.mjs`): a product serving its own kind of
|
|
12
|
+
target writes a receipt; the daemon admits the caller, both sides prove the receipt's
|
|
13
|
+
token without sending it, and messages are relayed.
|
|
14
|
+
- **Admission** (`admission.mjs`): grants of capabilities on targets, `<kind>:*`
|
|
15
|
+
wildcards, `*` for operate and view only.
|
|
16
|
+
- **The Teams server's neutral routes** (`server/`): identity and sign-in, teams,
|
|
17
|
+
members, groups, credentials, invitations, machines, enrollment and machine grants,
|
|
18
|
+
the machine link, and the migration chain. The harness's routes attach through
|
|
19
|
+
`api.harnessRoutes`.
|
|
20
|
+
|
|
21
|
+
This package is private: nothing is published while nothing is open-sourced.
|
package/admission.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// What a caller may do on this machine. `admitted` is { class, grants } where
|
|
2
|
+
// class is 'operator' (the OS user through the local door, or a holder of an
|
|
3
|
+
// `operate` machine grant through the server) or 'participant', and grants are
|
|
4
|
+
// { id, caps, targets } rows from the caller's machine grants (design §11).
|
|
5
|
+
//
|
|
6
|
+
// Targets: `<kind>:<name>` for the kinds a product serves on this machine
|
|
7
|
+
// (the harness: `pane:<contextKey>`, `runtime:<id>`, `root:<path>`, `port:<pane>/<n>`),
|
|
8
|
+
// `<kind>:*` for every target of one kind, or `*` (operate and view only).
|
|
9
|
+
// `root:<path>` covers the path and everything under it.
|
|
10
|
+
|
|
11
|
+
export function targetsCover(patterns, target) {
|
|
12
|
+
for (const pattern of patterns) {
|
|
13
|
+
if (pattern === '*') return true;
|
|
14
|
+
if (pattern === target) return true;
|
|
15
|
+
if (pattern.endsWith(':*') && pattern.length > 2 && target.startsWith(pattern.slice(0, -1))) return true;
|
|
16
|
+
if (pattern.startsWith('root:') && target.startsWith('root:')) {
|
|
17
|
+
const base = pattern.slice(5).replace(/\/+$/, '');
|
|
18
|
+
const path = target.slice(5);
|
|
19
|
+
if (path === base || path.startsWith(base + '/')) return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Does the admitted caller hold `cap` on `target`? Operators hold everything. */
|
|
26
|
+
export function holds(admitted, cap, target) {
|
|
27
|
+
if (!admitted) return false;
|
|
28
|
+
if (admitted.class === 'operator') return true;
|
|
29
|
+
return admitted.grants.some((g) => g.caps.includes(cap) && targetsCover(g.targets, target));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Does the caller hold `observe` on any pane (a named pane, `pane:*`, or the whole machine)? */
|
|
33
|
+
export function observesPanes(admitted) {
|
|
34
|
+
if (!admitted) return false;
|
|
35
|
+
if (admitted.class === 'operator') return true;
|
|
36
|
+
return admitted.grants.some((g) => g.caps.includes('observe') && g.targets.some((t) => t === '*' || t.startsWith('pane:')));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The OS user on its own machine, through the owner-only local door. */
|
|
40
|
+
export const LOCAL_OPERATOR = Object.freeze({ class: 'operator', grants: Object.freeze([]) });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The admission a server-forwarded stream carries, re-derived from this
|
|
44
|
+
* machine's own copy of its grants: the claim only names grant ids and the
|
|
45
|
+
* principal; the capabilities come from the synced rows. Expired, unknown or
|
|
46
|
+
* other-subject grants admit nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function admitFromGrants(claim, { custodianId, grants, now = Date.now() }) {
|
|
49
|
+
if (!claim || typeof claim.principal !== 'string') return null;
|
|
50
|
+
if (claim.custodian === true) return claim.principal === custodianId ? LOCAL_OPERATOR : null;
|
|
51
|
+
const held = [];
|
|
52
|
+
for (const id of Array.isArray(claim.grants) ? claim.grants : []) {
|
|
53
|
+
const grant = grants.get(id);
|
|
54
|
+
if (!grant || grant.expires_at <= now) continue;
|
|
55
|
+
const subjects = new Set([claim.principal, ...(Array.isArray(claim.groups) ? claim.groups : [])]);
|
|
56
|
+
if (!subjects.has(grant.subject_id)) continue;
|
|
57
|
+
held.push({ id: grant.id, caps: grant.caps, targets: grant.targets });
|
|
58
|
+
}
|
|
59
|
+
if (!held.length) return null;
|
|
60
|
+
if (held.some((grant) => grant.caps.includes('operate'))) return { class: 'operator', grants: held };
|
|
61
|
+
return { class: 'participant', grants: held };
|
|
62
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The machine channel from a browser: the framing and streams of channel.mjs
|
|
2
|
+
// (a hello each way, then u32-length JSON envelopes { ch, t, ... }) over a
|
|
3
|
+
// browser WebSocket, with no Node built-ins. A browser cannot set headers on a
|
|
4
|
+
// WebSocket, so the caller's bearer rides as a `bearer.<token>` subprotocol
|
|
5
|
+
// beside the door's own; the server never echoes it back.
|
|
6
|
+
const PROTOCOL = 'supercode-machine/1';
|
|
7
|
+
const NODE_PROTOCOL = 2;
|
|
8
|
+
const CHANNEL_PROTOCOL = 'supercode-machine-channel.v1';
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
class Emitter {
|
|
13
|
+
#listeners = new Map();
|
|
14
|
+
on(event, listener) { (this.#listeners.get(event) ?? this.#listeners.set(event, new Set()).get(event)).add(listener); return this; }
|
|
15
|
+
once(event, listener) { const wrapped = (...args) => { this.off(event, wrapped); listener(...args); }; return this.on(event, wrapped); }
|
|
16
|
+
off(event, listener) { this.#listeners.get(event)?.delete(listener); return this; }
|
|
17
|
+
listenerCount(event) { return this.#listeners.get(event)?.size ?? 0; }
|
|
18
|
+
emit(event, ...args) { for (const listener of [...(this.#listeners.get(event) ?? [])]) listener(...args); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function frame(value) {
|
|
22
|
+
const body = encoder.encode(JSON.stringify(value));
|
|
23
|
+
const out = new Uint8Array(4 + body.length);
|
|
24
|
+
new DataView(out.buffer).setUint32(0, body.length);
|
|
25
|
+
out.set(body, 4);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class BrowserStream extends Emitter {
|
|
30
|
+
constructor(channel, id, name) { super(); this.channel = channel; this.id = id; this.name = name; this.closed = false; }
|
|
31
|
+
write(data) { if (this.closed) throw new Error(`stream ${this.name}#${this.id} is closed`); this.channel.send({ ch: this.id, t: 'data', d: data }); }
|
|
32
|
+
close(reason) { if (this.closed) return; this.closed = true; this.channel.send({ ch: this.id, t: 'close', reason: reason ?? null }); this.channel.streams.delete(this.id); this.emit('close', reason ?? null); }
|
|
33
|
+
// A peer that answers at once speaks before `open()`'s awaiter attaches: hold 'data' and
|
|
34
|
+
// 'close' until a listener for either is attached, then release them in order (channel.mjs).
|
|
35
|
+
on(event, listener) {
|
|
36
|
+
super.on(event, listener);
|
|
37
|
+
if (event === 'data') this.dataListening = true;
|
|
38
|
+
if (this.held && (event === 'data' || event === 'close')) {
|
|
39
|
+
const held = this.held; this.held = null;
|
|
40
|
+
queueMicrotask(() => { for (const [e, v] of held) this.emit(e, v); });
|
|
41
|
+
}
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
deliver(event, value) {
|
|
45
|
+
// As channel.mjs: a close someone listens for goes out after whatever is held; otherwise
|
|
46
|
+
// events wait until a 'data' or 'close' listener is attached.
|
|
47
|
+
if (event === 'close' && this.listenerCount('close') > 0) {
|
|
48
|
+
const held = this.held ?? []; this.held = null;
|
|
49
|
+
for (const [e, v] of held) this.emit(e, v);
|
|
50
|
+
this.emit('close', value);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!this.held && this.dataListening) { this.emit(event, value); return; }
|
|
54
|
+
(this.held ??= []).push([event, value]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class BrowserChannel extends Emitter {
|
|
59
|
+
constructor(socket) {
|
|
60
|
+
super();
|
|
61
|
+
this.socket = socket;
|
|
62
|
+
this.streams = new Map();
|
|
63
|
+
this.next = 1;
|
|
64
|
+
this.closed = false;
|
|
65
|
+
this.buffer = new Uint8Array(0);
|
|
66
|
+
this.hello = null;
|
|
67
|
+
socket.binaryType = 'arraybuffer';
|
|
68
|
+
socket.addEventListener('message', (event) => this.#receive(new Uint8Array(event.data)));
|
|
69
|
+
socket.addEventListener('close', (event) => {
|
|
70
|
+
this.closed = true;
|
|
71
|
+
// The server names why it closed a channel (a revoked grant, the machine gone); pass it on.
|
|
72
|
+
const reason = event?.reason || 'connection closed';
|
|
73
|
+
for (const stream of this.streams.values()) { stream.closed = true; stream.deliver('close', reason); }
|
|
74
|
+
this.streams.clear();
|
|
75
|
+
this.emit('close');
|
|
76
|
+
});
|
|
77
|
+
this.ping = setInterval(() => { if (!this.closed && this.hello) this.send({ ch: 0, t: 'ping' }); }, 30_000);
|
|
78
|
+
this.once('close', () => clearInterval(this.ping));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
send(envelope) { if (!this.closed && this.socket.readyState === 1) this.socket.send(frame(envelope)); }
|
|
82
|
+
|
|
83
|
+
#receive(bytes) {
|
|
84
|
+
const joined = new Uint8Array(this.buffer.length + bytes.length);
|
|
85
|
+
joined.set(this.buffer); joined.set(bytes, this.buffer.length);
|
|
86
|
+
this.buffer = joined;
|
|
87
|
+
while (this.buffer.length >= 4) {
|
|
88
|
+
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset).getUint32(0);
|
|
89
|
+
if (this.buffer.length < 4 + length) return;
|
|
90
|
+
const value = JSON.parse(decoder.decode(this.buffer.subarray(4, 4 + length)));
|
|
91
|
+
this.buffer = this.buffer.slice(4 + length);
|
|
92
|
+
if (!this.hello) { this.hello = value; this.emit('hello', value); continue; }
|
|
93
|
+
this.#envelope(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#envelope(envelope) {
|
|
98
|
+
const stream = this.streams.get(envelope.ch);
|
|
99
|
+
if (envelope.t === 'opened') stream?.emit('opened');
|
|
100
|
+
else if (envelope.t === 'data') stream?.deliver('data', envelope.d);
|
|
101
|
+
else if (envelope.t === 'close' && stream) { stream.closed = true; this.streams.delete(envelope.ch); stream.deliver('close', envelope.reason ?? null); }
|
|
102
|
+
else if (envelope.t === 'ping') this.send({ ch: 0, t: 'pong' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Open a named stream; resolves once the machine accepts it, rejects with its refusal. */
|
|
106
|
+
open(name, params = {}) {
|
|
107
|
+
const id = this.next; this.next += 2;
|
|
108
|
+
const stream = new BrowserStream(this, id, name);
|
|
109
|
+
this.streams.set(id, stream);
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
// The refusal listener leaves once the stream opens, so a close after that is the caller's.
|
|
112
|
+
const refused = (reason) => reject(new Error(`${name} refused: ${reason ?? 'closed'}`));
|
|
113
|
+
stream.on('close', refused);
|
|
114
|
+
stream.once('opened', () => { stream.off('close', refused); resolve(stream); });
|
|
115
|
+
this.send({ ch: id, t: 'open', name, params });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close() { this.socket.close(1000, 'closed'); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Machine `machineId` of team `teamId` on the Teams server at `origin`, reached with the
|
|
124
|
+
* caller's own `token`. `WebSocketImpl` defaults to the browser's WebSocket.
|
|
125
|
+
*/
|
|
126
|
+
export function openBrowserMachine({ origin, teamId, machineId, token }, { WebSocketImpl = globalThis.WebSocket } = {}) {
|
|
127
|
+
const url = `${String(origin).replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/machines/${encodeURIComponent(machineId)}/channel`;
|
|
128
|
+
const socket = new WebSocketImpl(url, [CHANNEL_PROTOCOL, `bearer.${token}`]);
|
|
129
|
+
const channel = new BrowserChannel(socket);
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
socket.addEventListener('open', () => socket.send(frame({ p: PROTOCOL, nv: NODE_PROTOCOL })), { once: true });
|
|
132
|
+
channel.once('hello', (hello) => (hello.error ? reject(new Error(hello.error)) : resolve(channel)));
|
|
133
|
+
socket.addEventListener('error', () => reject(new Error(`could not reach machine ${machineId} through ${origin}`)), { once: true });
|
|
134
|
+
socket.addEventListener('close', () => reject(new Error('the server closed the machine channel')), { once: true });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A pane's terminal stream: 'data' (text), 'hello', 'notice', 'close'; write(text), resize(cols, rows), close(). */
|
|
139
|
+
export async function attachBrowserPane(channel, { pane, mode = 'observe', cols = 120, rows = 40 }) {
|
|
140
|
+
const stream = await channel.open('terminal', { pane, mode, cols, rows });
|
|
141
|
+
const pane$ = new Emitter();
|
|
142
|
+
stream.on('data', (chunk) => {
|
|
143
|
+
const text = decoder.decode(Uint8Array.from(atob(chunk), (c) => c.charCodeAt(0)));
|
|
144
|
+
let message = null;
|
|
145
|
+
try { message = JSON.parse(text); } catch { /* raw bytes */ }
|
|
146
|
+
if (message && typeof message.data === 'string') pane$.emit('data', message.data);
|
|
147
|
+
else if (message?.type === 'hello') pane$.emit('hello', message);
|
|
148
|
+
else if (message?.type === 'error') pane$.emit('error-message', message.message);
|
|
149
|
+
else if (message?.type === 'notice') pane$.emit('notice', message.message);
|
|
150
|
+
else pane$.emit('data', text);
|
|
151
|
+
});
|
|
152
|
+
stream.on('close', (reason) => pane$.emit('close', reason));
|
|
153
|
+
pane$.write = (text) => stream.write(JSON.stringify({ input: text }));
|
|
154
|
+
pane$.resize = (c, r) => stream.write(JSON.stringify({ resize: { cols: c, rows: r } }));
|
|
155
|
+
pane$.close = () => stream.close();
|
|
156
|
+
return pane$;
|
|
157
|
+
}
|
package/channel.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// The machine channel: named streams multiplexed over one ordered byte stream.
|
|
2
|
+
//
|
|
3
|
+
// hello one plaintext frame each way: { p, nv } names the framing and the
|
|
4
|
+
// door vocabulary; a peer below the other's floor is refused with the
|
|
5
|
+
// floor named, so an old caller learns what to upgrade to.
|
|
6
|
+
// framing u32 length | JSON envelope { ch, t, ... }, t in open | opened | data | close | ping | pong.
|
|
7
|
+
// streams opened by name ("harness", "terminal", "node", "frontend", "port",
|
|
8
|
+
// "watch"); data is a string (a JSON line or base64 bytes); the
|
|
9
|
+
// acceptor decides per stream whether the caller may open it.
|
|
10
|
+
//
|
|
11
|
+
// It carries no identity of its own. The local door is an owner-only Unix
|
|
12
|
+
// socket, so the OS user is the caller; the remote door is the Teams server,
|
|
13
|
+
// which authenticates the caller and names its machine grant on each stream it
|
|
14
|
+
// forwards over the machine's link (TLS on the wire).
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
|
|
17
|
+
const PROTOCOL = 'supercode-machine/1';
|
|
18
|
+
/** The door vocabulary version. It grows; the framing under PROTOCOL never changes. */
|
|
19
|
+
export const NODE_PROTOCOL = 2;
|
|
20
|
+
const MAX_FRAME = 8 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Reads u32-length-prefixed frames from a socket. */
|
|
23
|
+
function frameReader(socket, onFrame, onError) {
|
|
24
|
+
let buffer = Buffer.alloc(0);
|
|
25
|
+
socket.on('data', (chunk) => {
|
|
26
|
+
buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk;
|
|
27
|
+
while (buffer.length >= 4) {
|
|
28
|
+
const length = buffer.readUInt32BE(0);
|
|
29
|
+
if (length > MAX_FRAME) {
|
|
30
|
+
onError(new Error(`frame of ${length} bytes exceeds the ${MAX_FRAME}-byte limit`));
|
|
31
|
+
socket.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (buffer.length < 4 + length) return;
|
|
35
|
+
const frame = buffer.subarray(4, 4 + length);
|
|
36
|
+
buffer = buffer.subarray(4 + length);
|
|
37
|
+
try {
|
|
38
|
+
onFrame(frame);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
onError(error);
|
|
41
|
+
socket.destroy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeFrame(socket, bytes) {
|
|
49
|
+
const header = Buffer.alloc(4);
|
|
50
|
+
header.writeUInt32BE(bytes.length, 0);
|
|
51
|
+
socket.write(Buffer.concat([header, bytes]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read exactly one plaintext hello frame. The socket is PAUSED before any
|
|
58
|
+
* remainder is unshifted, so bytes that arrived in the same read as this frame
|
|
59
|
+
* (the peer's next hello frame, or its first sealed frame) wait for the
|
|
60
|
+
* next reader instead of being emitted to nobody; every reader resumes it.
|
|
61
|
+
* Bounded: a peer that opens a socket and says nothing is refused after
|
|
62
|
+
* HANDSHAKE_TIMEOUT_MS, so it cannot hold a slot for free.
|
|
63
|
+
*/
|
|
64
|
+
function readOneFrame(socket, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let buffer = Buffer.alloc(0);
|
|
67
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error(`hello frame did not arrive within ${timeoutMs} ms`)); socket.destroy(); }, timeoutMs);
|
|
68
|
+
const onData = (chunk) => {
|
|
69
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
70
|
+
if (buffer.length < 4) return;
|
|
71
|
+
const length = buffer.readUInt32BE(0);
|
|
72
|
+
if (length > 64 * 1024) {
|
|
73
|
+
cleanup();
|
|
74
|
+
reject(new Error('hello frame too large'));
|
|
75
|
+
socket.destroy();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (buffer.length < 4 + length) return;
|
|
79
|
+
cleanup();
|
|
80
|
+
const frame = buffer.subarray(4, 4 + length);
|
|
81
|
+
const rest = buffer.subarray(4 + length);
|
|
82
|
+
socket.pause();
|
|
83
|
+
if (rest.length) socket.unshift(rest);
|
|
84
|
+
resolve(frame);
|
|
85
|
+
};
|
|
86
|
+
const onError = (error) => { cleanup(); reject(error); };
|
|
87
|
+
const onClose = () => { cleanup(); reject(new Error('socket closed during handshake')); };
|
|
88
|
+
const cleanup = () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
socket.off('data', onData);
|
|
91
|
+
socket.off('error', onError);
|
|
92
|
+
socket.off('close', onClose);
|
|
93
|
+
};
|
|
94
|
+
socket.on('data', onData);
|
|
95
|
+
socket.on('error', onError);
|
|
96
|
+
socket.on('close', onClose);
|
|
97
|
+
socket.resume();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Refuse a peer before any key is derived, in the clear, naming the reason
|
|
103
|
+
* (a floor, a rate limit, no grant). It is one plaintext hello frame the
|
|
104
|
+
* initiator reads in place of the reply, so a refusal is a sentence rather
|
|
105
|
+
* than a closed socket.
|
|
106
|
+
*/
|
|
107
|
+
export function refuseHandshake(socket, message) {
|
|
108
|
+
try { writeFrame(socket, Buffer.from(JSON.stringify({ p: PROTOCOL, nv: NODE_PROTOCOL, error: message }))); } catch { /* peer already gone */ }
|
|
109
|
+
socket.destroy();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A multiplexed stream over the channel. `write(data)` sends a string; 'data'
|
|
114
|
+
* events deliver strings; 'close' when the peer or we close it.
|
|
115
|
+
*/
|
|
116
|
+
export class ChannelStream extends EventEmitter {
|
|
117
|
+
constructor(channel, id, name, params) {
|
|
118
|
+
super();
|
|
119
|
+
this.channel = channel;
|
|
120
|
+
this.id = id;
|
|
121
|
+
this.name = name;
|
|
122
|
+
this.params = params;
|
|
123
|
+
this.closed = false;
|
|
124
|
+
}
|
|
125
|
+
write(data) {
|
|
126
|
+
if (this.closed) throw new Error(`stream ${this.name}#${this.id} is closed`);
|
|
127
|
+
this.channel._send({ ch: this.id, t: 'data', d: data });
|
|
128
|
+
}
|
|
129
|
+
close(reason) {
|
|
130
|
+
if (this.closed) return;
|
|
131
|
+
this.closed = true;
|
|
132
|
+
this.channel._send({ ch: this.id, t: 'close', reason: reason ?? null });
|
|
133
|
+
this.channel._streams.delete(this.id);
|
|
134
|
+
this.emit('close', reason ?? null);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Deliver a peer's 'data' or 'close'. A stream is handed to its opener through a promise,
|
|
138
|
+
* so a peer that answers at once (its data in the same read as 'opened') would otherwise
|
|
139
|
+
* speak before the opener has attached a listener. Until a 'data' or 'close' listener is
|
|
140
|
+
* attached, events are held, and released in order on the microtask after it is.
|
|
141
|
+
*/
|
|
142
|
+
_deliver(event, value) {
|
|
143
|
+
// A close someone listens for is never held behind data: what is held goes out first
|
|
144
|
+
// (to its listeners, or to none), then the close.
|
|
145
|
+
if (event === 'close' && this.listenerCount('close') > 0) {
|
|
146
|
+
const held = this._held ?? [];
|
|
147
|
+
this._held = null;
|
|
148
|
+
if (this._release) { this.off('newListener', this._release); this._release = null; }
|
|
149
|
+
for (const [e, v] of held) this.emit(e, v);
|
|
150
|
+
this.emit('close', value);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!this._held && this.listenerCount('data') > 0) {
|
|
154
|
+
this.emit(event, value);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!this._held) {
|
|
158
|
+
this._held = [];
|
|
159
|
+
const release = (name) => {
|
|
160
|
+
if (name !== 'data' && name !== 'close') return;
|
|
161
|
+
this.off('newListener', release);
|
|
162
|
+
this._release = null;
|
|
163
|
+
queueMicrotask(() => { const held = this._held ?? []; this._held = null; for (const [e, v] of held) this.emit(e, v); });
|
|
164
|
+
};
|
|
165
|
+
this._release = release;
|
|
166
|
+
this.on('newListener', release);
|
|
167
|
+
}
|
|
168
|
+
this._held.push([event, value]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class Channel extends EventEmitter {
|
|
173
|
+
/** @private use Channel.connect / Channel.accept */
|
|
174
|
+
constructor(socket, { peer, initiator }) {
|
|
175
|
+
super();
|
|
176
|
+
this.socket = socket;
|
|
177
|
+
this.peer = peer;
|
|
178
|
+
this._send = (envelope) => {
|
|
179
|
+
if (this.socket.destroyed) return;
|
|
180
|
+
writeFrame(socket, Buffer.from(JSON.stringify(envelope)));
|
|
181
|
+
};
|
|
182
|
+
this._streams = new Map();
|
|
183
|
+
this._nextStream = initiator ? 1 : 2;
|
|
184
|
+
this.closed = false;
|
|
185
|
+
frameReader(
|
|
186
|
+
socket,
|
|
187
|
+
(frame) => this._onEnvelope(JSON.parse(frame.toString('utf8'))),
|
|
188
|
+
(error) => this.emit('error', error),
|
|
189
|
+
);
|
|
190
|
+
// The hello left the socket paused with any early frames unshifted; our reader is on now.
|
|
191
|
+
socket.resume();
|
|
192
|
+
// A peer that ended its side has closed the channel: end ours now rather than
|
|
193
|
+
// hold a half-open socket until a transport timeout (5 s over a WebSocket).
|
|
194
|
+
socket.on('end', () => { if (!socket.destroyed) socket.destroy(); });
|
|
195
|
+
socket.on('close', () => {
|
|
196
|
+
this.closed = true;
|
|
197
|
+
for (const stream of this._streams.values()) {
|
|
198
|
+
stream.closed = true;
|
|
199
|
+
stream._deliver('close', 'connection closed');
|
|
200
|
+
}
|
|
201
|
+
this._streams.clear();
|
|
202
|
+
this.emit('close');
|
|
203
|
+
});
|
|
204
|
+
socket.on('error', (error) => this.emit('error', error));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_onEnvelope(envelope) {
|
|
208
|
+
if (envelope.t === 'open') {
|
|
209
|
+
const stream = new ChannelStream(this, envelope.ch, envelope.name, envelope.params ?? {});
|
|
210
|
+
this._streams.set(envelope.ch, stream);
|
|
211
|
+
let settled = false;
|
|
212
|
+
const accept = () => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
this._send({ ch: envelope.ch, t: 'opened' });
|
|
216
|
+
};
|
|
217
|
+
const refuse = (reason) => {
|
|
218
|
+
if (settled) return;
|
|
219
|
+
settled = true;
|
|
220
|
+
this._streams.delete(envelope.ch);
|
|
221
|
+
this._send({ ch: envelope.ch, t: 'close', reason: reason ?? 'refused' });
|
|
222
|
+
};
|
|
223
|
+
// An acceptor may decide asynchronously; only a channel with no acceptor at all refuses here.
|
|
224
|
+
if (this.listenerCount('stream') === 0) return refuse('no acceptor');
|
|
225
|
+
this.emit('stream', stream, accept, refuse);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const stream = this._streams.get(envelope.ch);
|
|
229
|
+
if (envelope.t === 'opened') {
|
|
230
|
+
stream?.emit('opened');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (envelope.t === 'data') {
|
|
234
|
+
stream?._deliver('data', envelope.d);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (envelope.t === 'close') {
|
|
238
|
+
if (!stream) return;
|
|
239
|
+
stream.closed = true;
|
|
240
|
+
this._streams.delete(envelope.ch);
|
|
241
|
+
stream._deliver('close', envelope.reason ?? null);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (envelope.t === 'ping') {
|
|
245
|
+
this._send({ ch: 0, t: 'pong' });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Send a ping every `intervalMs` while the channel lives. A relay in front of a WebSocket
|
|
251
|
+
* (Cloudflare's among them) closes a connection that carries nothing for long enough.
|
|
252
|
+
*/
|
|
253
|
+
keepAlive(intervalMs = 30_000) {
|
|
254
|
+
const timer = setInterval(() => { if (!this.closed) this._send({ ch: 0, t: 'ping' }); }, intervalMs);
|
|
255
|
+
timer.unref?.();
|
|
256
|
+
this.once('close', () => clearInterval(timer));
|
|
257
|
+
return this;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Open a named stream; resolves once the peer accepts it, rejects if refused. */
|
|
261
|
+
open(name, params = {}) {
|
|
262
|
+
// A closed connection refuses at once rather than waiting out the handshake bound.
|
|
263
|
+
if (this.closed) return Promise.reject(new Error(`${name} refused: connection closed`));
|
|
264
|
+
const id = this._nextStream;
|
|
265
|
+
this._nextStream += 2;
|
|
266
|
+
const stream = new ChannelStream(this, id, name, params);
|
|
267
|
+
this._streams.set(id, stream);
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
// Bounded: an acceptor that never answers is a refusal with a reason, not a hang.
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
stream.off('opened', onOpened); stream.off('close', onClosed);
|
|
272
|
+
this._streams.delete(id);
|
|
273
|
+
reject(new Error(`${name} did not open within ${HANDSHAKE_TIMEOUT_MS} ms`));
|
|
274
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
275
|
+
timer.unref?.();
|
|
276
|
+
const onOpened = () => { clearTimeout(timer); stream.off('close', onClosed); resolve(stream); };
|
|
277
|
+
const onClosed = (reason) => { clearTimeout(timer); stream.off('opened', onOpened); reject(new Error(`${name} refused: ${reason ?? 'closed'}`)); };
|
|
278
|
+
stream.once('opened', onOpened);
|
|
279
|
+
stream.once('close', onClosed);
|
|
280
|
+
this._send({ ch: id, t: 'open', name, params });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
close() {
|
|
285
|
+
this.socket.end();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The side that dialled: sends its hello, reads the answer. */
|
|
289
|
+
static async connect(socket, { floor = 1 } = {}) {
|
|
290
|
+
writeFrame(socket, Buffer.from(JSON.stringify({ p: PROTOCOL, nv: NODE_PROTOCOL })));
|
|
291
|
+
const reply = JSON.parse((await readOneFrame(socket)).toString('utf8'));
|
|
292
|
+
if (reply.error) { socket.destroy(); throw new Error(reply.error); }
|
|
293
|
+
if (reply.p !== PROTOCOL) { socket.destroy(); throw new Error(`peer speaks ${reply.p}, not ${PROTOCOL}`); }
|
|
294
|
+
const version = Number.isInteger(reply.nv) ? reply.nv : 0;
|
|
295
|
+
if (version < floor) {
|
|
296
|
+
socket.destroy();
|
|
297
|
+
throw new Error(`machine speaks node protocol ${version}; this caller's floor is ${floor}`);
|
|
298
|
+
}
|
|
299
|
+
return new Channel(socket, { peer: { nv: version }, initiator: true });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The side that listened: reads the caller's hello, answers or refuses with its floor. */
|
|
303
|
+
static async accept(socket, { floor = 1 } = {}) {
|
|
304
|
+
const hello = JSON.parse((await readOneFrame(socket)).toString('utf8'));
|
|
305
|
+
if (hello.p !== PROTOCOL) {
|
|
306
|
+
const message = `peer speaks ${hello.p}, not ${PROTOCOL}`;
|
|
307
|
+
refuseHandshake(socket, message);
|
|
308
|
+
throw new Error(message);
|
|
309
|
+
}
|
|
310
|
+
const version = Number.isInteger(hello.nv) ? hello.nv : 0;
|
|
311
|
+
if (version < floor) {
|
|
312
|
+
const message = `caller speaks node protocol ${version}; this machine's floor is ${floor}`;
|
|
313
|
+
refuseHandshake(socket, message);
|
|
314
|
+
throw new Error(message);
|
|
315
|
+
}
|
|
316
|
+
writeFrame(socket, Buffer.from(JSON.stringify({ p: PROTOCOL, nv: NODE_PROTOCOL })));
|
|
317
|
+
return new Channel(socket, { peer: { nv: version }, initiator: false });
|
|
318
|
+
}
|
|
319
|
+
}
|
package/client.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The caller's side of Volter Teams: open a machine's channel through its local door
|
|
2
|
+
// (the daemon's owner-only socket in its home) or its remote door (a Teams server, which
|
|
3
|
+
// forwards the channel to the enrolled machine's link after checking the caller's
|
|
4
|
+
// grants), and speak request/reply over a stream. The local door needs Node builtins
|
|
5
|
+
// only; `ws` loads when a remote door is first opened.
|
|
6
|
+
import { connect as netConnect } from 'node:net';
|
|
7
|
+
import { EventEmitter } from 'node:events';
|
|
8
|
+
import { Channel } from './channel.mjs';
|
|
9
|
+
|
|
10
|
+
/** The local door: the daemon serving `socketPath` (its home's `machine.sock`). */
|
|
11
|
+
export async function openLocalMachine(socketPath, { timeoutMs = 10_000, floor = 2 } = {}) {
|
|
12
|
+
const socket = await new Promise((resolve, reject) => {
|
|
13
|
+
const connection = netConnect(socketPath);
|
|
14
|
+
const timer = setTimeout(() => { connection.destroy(); reject(new Error(`connect to ${socketPath} timed out`)); }, timeoutMs);
|
|
15
|
+
connection.once('connect', () => { clearTimeout(timer); resolve(connection); });
|
|
16
|
+
connection.once('error', (error) => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
reject(error.code === 'ENOENT' || error.code === 'ECONNREFUSED'
|
|
19
|
+
? Object.assign(new Error(`no machine daemon is serving ${socketPath}; run \`supercode teams machine start\``), { code: 'machine_offline' })
|
|
20
|
+
: error);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
return Channel.connect(socket, { floor });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The remote door: machine `machineId` of team `teamId` on the Teams server at
|
|
28
|
+
* `origin`, authenticated with the caller's own credential. The server refuses
|
|
29
|
+
* a caller holding no grant on that machine exactly as it refuses one naming a
|
|
30
|
+
* machine that does not exist.
|
|
31
|
+
*/
|
|
32
|
+
export async function openRemoteMachine({ origin, teamId, machineId, token }, { timeoutMs = 15_000, floor = 2 } = {}) {
|
|
33
|
+
// Imported here so a caller of the local door alone needs nothing beyond Node builtins.
|
|
34
|
+
const { WebSocket, createWebSocketStream } = await import('ws');
|
|
35
|
+
const url = `${String(origin).replace(/^http/, 'ws')}/api/v1/teams/${encodeURIComponent(teamId)}/machines/${encodeURIComponent(machineId)}/channel`;
|
|
36
|
+
const socket = new WebSocket(url, 'supercode-machine-channel.v1', { headers: { authorization: `Bearer ${token}` }, maxPayload: 16 * 1024 * 1024 });
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
const timer = setTimeout(() => { socket.terminate(); reject(new Error(`connect to ${origin} timed out`)); }, timeoutMs);
|
|
39
|
+
socket.once('open', () => { clearTimeout(timer); resolve(); });
|
|
40
|
+
socket.once('unexpected-response', (_request, response) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
let body = '';
|
|
43
|
+
response.on('data', (chunk) => { body += chunk; });
|
|
44
|
+
response.on('end', () => {
|
|
45
|
+
let error = null;
|
|
46
|
+
try { error = JSON.parse(body).error; } catch { /* not JSON */ }
|
|
47
|
+
reject(Object.assign(new Error(error?.message ?? `server answered ${response.statusCode}`), { code: error?.code ?? 'unavailable', status: response.statusCode }));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
socket.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
51
|
+
});
|
|
52
|
+
return (await Channel.connect(createWebSocketStream(socket), { floor })).keepAlive();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Request/reply over a stream that carries JSON lines with `id`. */
|
|
56
|
+
export class StreamRpc {
|
|
57
|
+
constructor(stream) {
|
|
58
|
+
this.stream = stream;
|
|
59
|
+
this.next = 1;
|
|
60
|
+
this.pending = new Map();
|
|
61
|
+
this.buffer = '';
|
|
62
|
+
stream.on('data', (chunk) => {
|
|
63
|
+
this.buffer += chunk;
|
|
64
|
+
let index;
|
|
65
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
66
|
+
const line = this.buffer.slice(0, index);
|
|
67
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
68
|
+
if (!line) continue;
|
|
69
|
+
const message = JSON.parse(line);
|
|
70
|
+
const waiter = this.pending.get(message.id);
|
|
71
|
+
if (!waiter) continue;
|
|
72
|
+
this.pending.delete(message.id);
|
|
73
|
+
if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? String(message.error)), { code: message.error.code }));
|
|
74
|
+
else waiter.resolve(message.result);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
stream.on('close', (reason) => {
|
|
78
|
+
for (const waiter of this.pending.values()) waiter.reject(new Error(`stream closed: ${reason ?? 'peer'}`));
|
|
79
|
+
this.pending.clear();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One request. Bounded: a door that never answers becomes an error naming the
|
|
84
|
+
* method (default 60 s; the node's own per-door bound is 30 s), never a hang.
|
|
85
|
+
*/
|
|
86
|
+
call(method, params = {}, { timeoutMs = 60_000 } = {}) {
|
|
87
|
+
const id = this.next++;
|
|
88
|
+
this.stream.write(JSON.stringify({ id, method, params }) + '\n');
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
if (!this.pending.has(id)) return;
|
|
92
|
+
this.pending.delete(id);
|
|
93
|
+
reject(Object.assign(new Error(`${method} did not answer within ${timeoutMs} ms`), { code: 504 }));
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
timer.unref?.();
|
|
96
|
+
this.pending.set(id, {
|
|
97
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
98
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A harness's own execution server on the channel's machine, as the local address its client takes
|
|
106
|
+
* (execution-doors.mjs; Codex's `CODEX_EXEC_SERVER_URL`). `channel` is a machine channel, or a function
|
|
107
|
+
* that opens one for each connection. Each websocket connection opens one
|
|
108
|
+
* `exec-server` stream, whose job on the machine is that connection's server: a frame is one line of
|
|
109
|
+
* its JSON-RPC both ways, the stream carrying the bytes in base64 as `exec` does. Loopback only.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A request a browser made for a web page. Browsers send Sec-Fetch-Site and Sec-Fetch-Dest on
|
|
114
|
+
* every request to a loopback address, and Origin on cross-origin ones; Node's fetch sends only
|
|
115
|
+
* Sec-Fetch-Mode, so that one is no signal.
|
|
116
|
+
*/
|
|
117
|
+
export function fromWebPage(req) {
|
|
118
|
+
return req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined || req.headers['sec-fetch-dest'] !== undefined;
|
|
119
|
+
}
|