@quo-systems/dock 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/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +27 -0
- package/beings/avatar.ts +84 -0
- package/beings/desk.ts +46 -0
- package/beings/index.ts +5 -0
- package/beings/quo-dock.md +698 -0
- package/beings/setup.ts +23 -0
- package/beings/side.ts +51 -0
- package/beings/user.ts +78 -0
- package/cli/client.ts +65 -0
- package/cli/daemon.ts +582 -0
- package/cli/quo.ts +126 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +82 -0
- package/dist/beings/desk.d.ts +42 -0
- package/dist/beings/desk.js +43 -0
- package/dist/beings/index.d.ts +3 -0
- package/dist/beings/index.js +5 -0
- package/dist/beings/setup.d.ts +2 -0
- package/dist/beings/setup.js +16 -0
- package/dist/beings/side.d.ts +25 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +108 -0
- package/dist/beings/user.js +74 -0
- package/dist/cli/client.d.ts +14 -0
- package/dist/cli/client.js +51 -0
- package/dist/cli/daemon.d.ts +57 -0
- package/dist/cli/daemon.js +581 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +121 -0
- package/dist/harbor/browser.d.ts +13 -0
- package/dist/harbor/browser.js +50 -0
- package/dist/harbor/disk.d.ts +19 -0
- package/dist/harbor/disk.js +126 -0
- package/dist/harbor/edge/edge.d.ts +26 -0
- package/dist/harbor/edge/edge.js +123 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +179 -0
- package/dist/harbor/edge/storage.d.ts +20 -0
- package/dist/harbor/edge/storage.js +81 -0
- package/dist/harbor/edge/worker.d.ts +14 -0
- package/dist/harbor/edge/worker.js +47 -0
- package/dist/harbor/files.d.ts +14 -0
- package/dist/harbor/files.js +83 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +85 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/html.d.ts +32 -0
- package/dist/human/html.js +136 -0
- package/dist/human/screen.d.ts +11 -0
- package/dist/human/screen.js +66 -0
- package/dist/human/tab.d.ts +7 -0
- package/dist/human/tab.js +68 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +96 -0
- package/dist/mcp/oauth.d.ts +98 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/pilot.d.ts +6 -0
- package/dist/mcp/pilot.js +68 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +94 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +63 -0
- package/dist/mcp/web/exchange.d.ts +23 -0
- package/dist/mcp/web/exchange.js +136 -0
- package/harbor/browser.ts +63 -0
- package/harbor/disk.ts +120 -0
- package/harbor/edge/edge.ts +147 -0
- package/harbor/edge/exercise.ts +187 -0
- package/harbor/edge/platform.d.ts +28 -0
- package/harbor/edge/storage.ts +82 -0
- package/harbor/edge/worker.ts +47 -0
- package/harbor/edge/wrangler.toml +21 -0
- package/harbor/files.ts +89 -0
- package/harbor/idb.ts +90 -0
- package/harbor/quo-harbor.md +403 -0
- package/human/dom.ts +21 -0
- package/human/html.ts +164 -0
- package/human/quo-human.md +86 -0
- package/human/screen.ts +76 -0
- package/human/tab.ts +84 -0
- package/mcp/agent.ts +94 -0
- package/mcp/http.ts +96 -0
- package/mcp/oauth.ts +260 -0
- package/mcp/pilot.ts +73 -0
- package/mcp/quo-mcp.md +262 -0
- package/mcp/runner.ts +131 -0
- package/mcp/server.ts +67 -0
- package/mcp/web/exchange.ts +146 -0
- package/package.json +89 -0
package/cli/daemon.ts
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The daemon: `quo serve`. One process holds the harbor and the ask pointer
|
|
3
|
+
// of every ward in it. Two local doors, never mixed, each a unix socket only
|
|
4
|
+
// the device's user can read:
|
|
5
|
+
//
|
|
6
|
+
// quo.sock the root. One line of JSON in, one line out.
|
|
7
|
+
// -> { id, ward?, method?, args?, via? }
|
|
8
|
+
// <- { id, result } | { id, error }
|
|
9
|
+
// A result of silence crosses as { silence: true }, because the
|
|
10
|
+
// wire has no symbol. With `via`, the line is an owner ask on
|
|
11
|
+
// another ward: `via` names a standing the user being holds
|
|
12
|
+
// at that ward, and the daemon asks on it in-process, as it
|
|
13
|
+
// drives an avatar for a side. The answer is what the far
|
|
14
|
+
// ward says to an owner at its door.
|
|
15
|
+
// side.sock local sides. The first line names the client identity, and
|
|
16
|
+
// every line after is MCP, JSON-RPC, one message per line. The
|
|
17
|
+
// daemon runs the model side in-process with the avatar; the
|
|
18
|
+
// process on the other end of the socket is a pipe to stdio.
|
|
19
|
+
// -> { as, ward? }
|
|
20
|
+
// <- { ok: as } | { error }
|
|
21
|
+
// With `run`, the side is the runner instead: a model at a
|
|
22
|
+
// completions endpoint, driven from here. Every line after the
|
|
23
|
+
// hello is one line from the human and starts a turn; every
|
|
24
|
+
// final text the model says comes back as one line, whoever
|
|
25
|
+
// started the turn. The bearer, if any, is read from the
|
|
26
|
+
// device by the client and crosses only this socket.
|
|
27
|
+
// -> { as, ward?, run: { url, model, key?, turns? } }
|
|
28
|
+
// <- { ok: as } | { error }, then one line per final text
|
|
29
|
+
//
|
|
30
|
+
// The proof for a local side is that it reached side.sock: the daemon mints
|
|
31
|
+
// a nonce for the connection and the desk's `local` verifier honours it
|
|
32
|
+
// once. A `local` proof arriving any other way names no nonce and is refused.
|
|
33
|
+
//
|
|
34
|
+
// Agents have no door: `<dir>/agents.json` names each agent's identity, its
|
|
35
|
+
// command and its folder, and the daemon admits her on the local proof and
|
|
36
|
+
// runs the agent side beside her avatar for as long as it serves. A push to
|
|
37
|
+
// her from the user being runs the command in the folder with the event.
|
|
38
|
+
//
|
|
39
|
+
// A third door, for occupants from elsewhere, is one HTTP listener on
|
|
40
|
+
// localhost that a reverse proxy fronts. Routes mount on it by path: `/mcp`
|
|
41
|
+
// for the model side, `/web` for the exchange pages, `/quo` for other
|
|
42
|
+
// harbors. It listens only when asked, and only on loopback: the proxy is
|
|
43
|
+
// what faces the world, and the daemon never does.
|
|
44
|
+
//
|
|
45
|
+
// `/quo` is the socket door, the quo. route, and the proxy maps the quo.
|
|
46
|
+
// hostname onto it, so a URL of that route is the base of every reach:
|
|
47
|
+
// POST `/quo/<pk>` carries one ask to a pk this harbor holds or holds a
|
|
48
|
+
// socket for, and an upgrade at `/quo` is a dialer's held socket,
|
|
49
|
+
// announcing its pks to be bound. With
|
|
50
|
+
// `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer: it
|
|
51
|
+
// holds one socket to each URL, announces its wards, and reconnects with
|
|
52
|
+
// backoff, so a Mac behind NAT is reached through the droplet it dialed.
|
|
53
|
+
import { createServer, type Server, type Socket } from 'node:net';
|
|
54
|
+
import { createServer as createHttp, type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http';
|
|
55
|
+
import { WebSocketServer } from 'ws';
|
|
56
|
+
import { build } from 'esbuild';
|
|
57
|
+
import { fileURLToPath } from 'node:url';
|
|
58
|
+
import { Socket as Held, dial, SUITE, type Line, type Dialer } from '@quo-systems/quo/harbor';
|
|
59
|
+
import { unlink, chmod, readFile, writeFile, rename } from 'node:fs/promises';
|
|
60
|
+
import { existsSync } from 'node:fs';
|
|
61
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
62
|
+
import { tmpdir } from 'node:os';
|
|
63
|
+
import { join, resolve } from 'node:path';
|
|
64
|
+
import { createInterface } from 'node:readline';
|
|
65
|
+
import { isSilence, isWord, wordOf, type JsonObject } from '@quo-systems/quo';
|
|
66
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
67
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
68
|
+
import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
|
|
69
|
+
import { Desk, type Avatar, type User, type Verifier } from '../beings/index.ts';
|
|
70
|
+
import { mcpSide } from '../mcp/server.ts';
|
|
71
|
+
import { runnerSide, type Model } from '../mcp/runner.ts';
|
|
72
|
+
import { agentSide, processRun, type Agent } from '../mcp/agent.ts';
|
|
73
|
+
import { OAuth, emptyStore, type Store } from '../mcp/oauth.ts';
|
|
74
|
+
import { Exchange } from '../mcp/web/exchange.ts';
|
|
75
|
+
import { McpHttp } from '../mcp/http.ts';
|
|
76
|
+
|
|
77
|
+
export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
|
|
78
|
+
export type Http = { port: number; host: string; mount(prefix: string, handler: Handler): void };
|
|
79
|
+
export type Quo = { sockets: Set<Held>; dialers: Dialer[] };
|
|
80
|
+
export type Serving = { harbor: DiskHarbor; sock: string; side: string; http: Http | null; oauth: OAuth | null; exchange: Exchange | null; mcp: McpHttp | null; agents: Map<string, Agent>; quo: Quo; close(): Promise<void> };
|
|
81
|
+
// The routes' public faces. From `<dir>/routes.json` on a device, or given.
|
|
82
|
+
// `quo` is what a tab dials; without it the tab dials the daemon itself.
|
|
83
|
+
export type Routes = { mcp: string; web: string; quo?: string };
|
|
84
|
+
// The agents this daemon runs, by client identity. From `<dir>/agents.json`
|
|
85
|
+
// on a device, or given.
|
|
86
|
+
export type Agents = Record<string, { command: string; args?: string[]; dir: string }>;
|
|
87
|
+
export type Options = { http?: { port: number; host?: string }; routes?: Routes; agents?: Agents; dial?: string[]; password?: () => string | undefined };
|
|
88
|
+
|
|
89
|
+
// The sockets live in the harbor's folder, where the folder's own permissions
|
|
90
|
+
// guard them. A unix socket path is short by law, about a hundred bytes on
|
|
91
|
+
// macOS, so a deep folder gets its sockets in the temp dir under a name only
|
|
92
|
+
// its path produces. Client and daemon compute the same answer.
|
|
93
|
+
const socketPath = (dir: string, name: string): string => {
|
|
94
|
+
const inside = join(resolve(dir), `${name}.sock`);
|
|
95
|
+
if (inside.length < 100) return inside;
|
|
96
|
+
return join(tmpdir(), `quo-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 16)}-${name}.sock`);
|
|
97
|
+
};
|
|
98
|
+
export const sockPath = (dir: string) => socketPath(dir, 'quo');
|
|
99
|
+
export const sidePath = (dir: string) => socketPath(dir, 'side');
|
|
100
|
+
|
|
101
|
+
// The nonces this daemon minted, honoured once each. Two proof kinds share
|
|
102
|
+
// them: `local`, for a side that reached side.sock, and `web`, for a human
|
|
103
|
+
// who passed the exchange pages. Both are the daemon vouching for someone it
|
|
104
|
+
// saw itself; a proof of either kind arriving any other way names no nonce.
|
|
105
|
+
const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean }>();
|
|
106
|
+
// A nonce is minted for one kind and honoured under that kind alone: a tab's
|
|
107
|
+
// nonce offered as a `local` proof names no nonce, so a proof of a kind still
|
|
108
|
+
// arrives only the one way that kind is made.
|
|
109
|
+
const honour =
|
|
110
|
+
(kind: string): Verifier =>
|
|
111
|
+
(proof) => {
|
|
112
|
+
const who = typeof proof.nonce === 'string' ? NONCES.get(proof.nonce) : undefined;
|
|
113
|
+
if (!who || who.kind !== kind) return null;
|
|
114
|
+
NONCES.delete(proof.nonce as string);
|
|
115
|
+
const { kind: _, ...rest } = who;
|
|
116
|
+
return rest;
|
|
117
|
+
};
|
|
118
|
+
Desk.verifiers.local = honour('local');
|
|
119
|
+
Desk.verifiers.web = honour('web');
|
|
120
|
+
Desk.verifiers.tab = honour('tab'); // a tab that gave the owner password at the web route
|
|
121
|
+
|
|
122
|
+
// What a request may carry, on the doors that read a body: enough for any ask
|
|
123
|
+
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
124
|
+
const BODY = 1 << 20;
|
|
125
|
+
async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined> {
|
|
126
|
+
const chunks: Buffer[] = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
for await (const c of req) {
|
|
129
|
+
size += c.length;
|
|
130
|
+
if (size > BODY) return undefined;
|
|
131
|
+
chunks.push(c);
|
|
132
|
+
}
|
|
133
|
+
return Buffer.concat(chunks);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Admit a client identity into a ward: find or boot her avatar, and enter
|
|
137
|
+
// her with a nonce the desk honours once. On a reconnect she already holds
|
|
138
|
+
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
139
|
+
// device may wake her others, is read the first time only; to change it,
|
|
140
|
+
// remove the occupant and allow again. The one path for every side.
|
|
141
|
+
export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
142
|
+
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
|
|
143
|
+
const key = `avatar:${identity}`;
|
|
144
|
+
let avatar = hosted.being(key) as Avatar | undefined;
|
|
145
|
+
if (!avatar) {
|
|
146
|
+
const out = (await hosted.ask('boot', { key, class: 'Avatar' })) as { error?: string };
|
|
147
|
+
if (out.error) return { error: out.error };
|
|
148
|
+
avatar = hosted.being(key) as Avatar;
|
|
149
|
+
}
|
|
150
|
+
const nonce = randomBytes(16).toString('hex');
|
|
151
|
+
NONCES.set(nonce, wake ? { kind, user: hosted.record.user, client: identity, wake: true } : { kind, user: hosted.record.user, client: identity });
|
|
152
|
+
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
153
|
+
NONCES.delete(nonce);
|
|
154
|
+
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
155
|
+
if ('error' in entered && !('asks' in entered)) return { error: entered.error };
|
|
156
|
+
return { avatar };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function listen(path: string, onConnection: (c: Socket) => void): Promise<Server> {
|
|
160
|
+
await unlink(path).catch(() => {}); // a socket left by a process that died holds nobody
|
|
161
|
+
const server = createServer(onConnection);
|
|
162
|
+
await new Promise<void>((ok, no) => {
|
|
163
|
+
server.once('error', no);
|
|
164
|
+
server.listen(path, () => ok());
|
|
165
|
+
});
|
|
166
|
+
await chmod(path, 0o600);
|
|
167
|
+
return server;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function serve(dir: string, options: Options = {}): Promise<Serving> {
|
|
171
|
+
const harbor = new DiskHarbor(dir);
|
|
172
|
+
await harbor.boot();
|
|
173
|
+
const sock = sockPath(harbor.dir);
|
|
174
|
+
const side = sidePath(harbor.dir);
|
|
175
|
+
const servers: (Server | HttpServer)[] = [];
|
|
176
|
+
let http: Http | null = null;
|
|
177
|
+
let oauth: OAuth | null = null;
|
|
178
|
+
let exchange: Exchange | null = null;
|
|
179
|
+
let mcp: McpHttp | null = null;
|
|
180
|
+
const agents = new Map<string, Agent>();
|
|
181
|
+
const quo: Quo = { sockets: new Set(), dialers: [] };
|
|
182
|
+
try {
|
|
183
|
+
servers.push(
|
|
184
|
+
await listen(sock, (c) => {
|
|
185
|
+
createInterface({ input: c }).on('line', (line) => {
|
|
186
|
+
void handle(harbor, line).then((reply) => c.write(reply + '\n'));
|
|
187
|
+
});
|
|
188
|
+
c.on('error', () => {});
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
servers.push(await listen(side, (c) => void sideConnection(harbor, c)));
|
|
192
|
+
if (options.http) {
|
|
193
|
+
const { server, ...door } = await listenHttp(harbor, options.http.host ?? '127.0.0.1', options.http.port, quo);
|
|
194
|
+
servers.push(server);
|
|
195
|
+
http = door;
|
|
196
|
+
mountQuo(harbor, http, server, quo);
|
|
197
|
+
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
198
|
+
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
199
|
+
const main = harbor.wards.get('main');
|
|
200
|
+
const here = `http://${http.host}:${http.port}`;
|
|
201
|
+
// the tab page: always, on the daemon's own door when no route names a public one
|
|
202
|
+
const tab = main ? tabPages(main, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password) : null;
|
|
203
|
+
if (routes) {
|
|
204
|
+
// the MCP endpoint: a bearer names an identity, the identity names her avatar, the side runs beside her
|
|
205
|
+
mcp = main ? new McpHttp((identity) => admit(main, identity, 'web'), () => main.save()) : null;
|
|
206
|
+
oauth = await mountOAuth(harbor.dir, http, routes, mcp);
|
|
207
|
+
if (mcp) mcp.gone = (identity) => oauth!.revoke(identity); // removal at the ward ends the grant at the route
|
|
208
|
+
if (main) {
|
|
209
|
+
exchange = new Exchange({
|
|
210
|
+
oauth,
|
|
211
|
+
password,
|
|
212
|
+
user: main.record.user,
|
|
213
|
+
admit: async (identity, wake) => {
|
|
214
|
+
const r = await admit(main, identity, 'web', wake);
|
|
215
|
+
return r.error ? { error: r.error } : {};
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const ex = exchange;
|
|
221
|
+
http.mount('/web', async (req, res, rest) => {
|
|
222
|
+
if (tab && (await tab(req, res, rest))) return;
|
|
223
|
+
if (ex && (await ex.handle(req, res, rest))) return;
|
|
224
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
225
|
+
res.end('no such page');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
for (const url of options.dial ?? (await readDial(harbor.dir))) quo.dialers.push(dial(harbor, url));
|
|
229
|
+
const main = harbor.wards.get('main');
|
|
230
|
+
if (main) {
|
|
231
|
+
for (const [identity, a] of Object.entries(options.agents ?? (await readAgents(harbor.dir)))) {
|
|
232
|
+
const admitted = await admit(main, identity, 'local');
|
|
233
|
+
if (!admitted.avatar) throw new Error(`agent ${identity}: ${admitted.error}`);
|
|
234
|
+
agents.set(identity, agentSide(admitted.avatar, processRun(a.command, a.args ?? [], a.dir), () => main.save()));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {
|
|
238
|
+
for (const s of servers) s.close();
|
|
239
|
+
for (const a of agents.values()) await a.close();
|
|
240
|
+
for (const d of quo.dialers) d.close();
|
|
241
|
+
await harbor.close(); // no daemon, no lease
|
|
242
|
+
throw e;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
harbor,
|
|
246
|
+
sock,
|
|
247
|
+
side,
|
|
248
|
+
http,
|
|
249
|
+
oauth,
|
|
250
|
+
exchange,
|
|
251
|
+
mcp,
|
|
252
|
+
agents,
|
|
253
|
+
quo,
|
|
254
|
+
close: async () => {
|
|
255
|
+
await mcp?.close();
|
|
256
|
+
for (const a of agents.values()) await a.close();
|
|
257
|
+
for (const d of quo.dialers) d.close();
|
|
258
|
+
for (const s of quo.sockets) s.close();
|
|
259
|
+
for (const s of servers) await new Promise<void>((ok) => s.close(() => ok()));
|
|
260
|
+
await unlink(sock).catch(() => {});
|
|
261
|
+
await unlink(side).catch(() => {});
|
|
262
|
+
await harbor.close();
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The HTTP door. Loopback only; the proxy faces the world. A route mounts
|
|
268
|
+
// under a prefix and gets the rest of the path. `/health` is the daemon's
|
|
269
|
+
// own: the wards it hosts, by name and pk, and nothing a stranger can use.
|
|
270
|
+
async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
|
|
271
|
+
const mounts = new Map<string, Handler>();
|
|
272
|
+
const server = createHttp((req, res) => {
|
|
273
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
274
|
+
if (url.pathname === '/health') {
|
|
275
|
+
// the wards it hosts, and the directory: pks and whether each is a
|
|
276
|
+
// socket held here, which is what an operator needs to see a dialer.
|
|
277
|
+
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
278
|
+
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
279
|
+
res.end(
|
|
280
|
+
JSON.stringify({
|
|
281
|
+
ok: true,
|
|
282
|
+
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
283
|
+
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
284
|
+
sockets: quo.sockets.size,
|
|
285
|
+
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const [prefix, handler] of mounts) {
|
|
291
|
+
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
292
|
+
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
293
|
+
if (!res.headersSent) res.writeHead(500);
|
|
294
|
+
res.end();
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
300
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
301
|
+
});
|
|
302
|
+
await new Promise<void>((ok, no) => {
|
|
303
|
+
server.once('error', no);
|
|
304
|
+
server.listen(port, host, () => ok());
|
|
305
|
+
});
|
|
306
|
+
const address = server.address();
|
|
307
|
+
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
308
|
+
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A side connection: hello, then MCP over the same lines, or, with `run`,
|
|
312
|
+
// the human's lines in and the model's final texts out.
|
|
313
|
+
async function sideConnection(harbor: DiskHarbor, c: Socket): Promise<void> {
|
|
314
|
+
c.on('error', () => {});
|
|
315
|
+
const lines = createInterface({ input: c });
|
|
316
|
+
const hello = await new Promise<string>((ok) => lines.once('line', ok));
|
|
317
|
+
let as: string, hosted, run: Model | undefined;
|
|
318
|
+
try {
|
|
319
|
+
const req = JSON.parse(hello) as { as?: string; ward?: string; run?: unknown };
|
|
320
|
+
hosted = harbor.wards.get(req.ward ?? 'main');
|
|
321
|
+
if (!hosted) throw new Error('no such ward');
|
|
322
|
+
if (typeof req.as !== 'string' || !/^[\w.-]+$/.test(req.as) || req.as === hosted.record.user || req.as === 'desk') throw new Error('as is a word, and not a being of the ward');
|
|
323
|
+
as = req.as;
|
|
324
|
+
if (req.run !== undefined) {
|
|
325
|
+
const r = req.run as Partial<Model>;
|
|
326
|
+
if (typeof r.url !== 'string' || typeof r.model !== 'string') throw new Error('run names a model: { url, model }');
|
|
327
|
+
run = { url: r.url, model: r.model, ...(typeof r.key === 'string' ? { key: r.key } : {}), ...(typeof r.turns === 'number' ? { turns: r.turns } : {}) };
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
c.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + '\n');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const admitted = await admit(hosted, as, 'local');
|
|
334
|
+
if (!admitted.avatar) return void c.end(JSON.stringify({ error: admitted.error }) + '\n');
|
|
335
|
+
const avatar = admitted.avatar;
|
|
336
|
+
c.write(JSON.stringify({ ok: as }) + '\n');
|
|
337
|
+
|
|
338
|
+
if (run) {
|
|
339
|
+
const runner = runnerSide(avatar, run, (text) => c.write(text + '\n'), () => hosted.save());
|
|
340
|
+
lines.on('line', (line) => {
|
|
341
|
+
runner.say(line).then(
|
|
342
|
+
(text) => {
|
|
343
|
+
if (text === null) c.write('\n'); // the ceiling: a turn with no final text is an empty line
|
|
344
|
+
},
|
|
345
|
+
(e: unknown) => c.write(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + '\n'),
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
c.once('close', () => void runner.close().catch(() => {}));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const transport: Transport = {
|
|
353
|
+
start: async () => {},
|
|
354
|
+
send: async (m: JSONRPCMessage) => {
|
|
355
|
+
c.write(JSON.stringify(m) + '\n');
|
|
356
|
+
},
|
|
357
|
+
close: async () => {
|
|
358
|
+
c.end();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
lines.on('line', (line) => {
|
|
362
|
+
try {
|
|
363
|
+
transport.onmessage?.(JSON.parse(line) as JSONRPCMessage);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
transport.onerror?.(e instanceof Error ? e : new Error(String(e)));
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
const serving = await mcpSide(avatar, transport, () => hosted.save());
|
|
369
|
+
c.once('close', () => {
|
|
370
|
+
transport.onclose?.();
|
|
371
|
+
void serving.close().catch(() => {});
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function handle(harbor: DiskHarbor, line: string): Promise<string> {
|
|
376
|
+
let id: unknown = null;
|
|
377
|
+
try {
|
|
378
|
+
const req = JSON.parse(line) as { id?: unknown; ward?: string; method?: string; args?: Record<string, unknown>; via?: unknown; reach?: { pk?: unknown; url?: unknown } };
|
|
379
|
+
id = req.id ?? null;
|
|
380
|
+
if (req.reach) {
|
|
381
|
+
// the directory is the harbor's, not a ward's: a hint from a link
|
|
382
|
+
if (typeof req.reach.pk !== 'string' || !/^[0-9a-f]{128}$/.test(req.reach.pk) || typeof req.reach.url !== 'string') return JSON.stringify({ id, error: 'a reach is a ward pk and a URL' });
|
|
383
|
+
await harbor.remember(req.reach.pk, req.reach.url);
|
|
384
|
+
return JSON.stringify({ id, result: { reach: req.reach.pk, url: req.reach.url } });
|
|
385
|
+
}
|
|
386
|
+
const hosted = harbor.wards.get(req.ward ?? 'main');
|
|
387
|
+
if (!hosted) return JSON.stringify({ id, error: 'no such ward' });
|
|
388
|
+
if (req.via !== undefined) {
|
|
389
|
+
// owning another ward is one of the human's standings, so her user
|
|
390
|
+
// being holds it, and the root asks on it. The keys rotate in the
|
|
391
|
+
// partition with every ask, and no door was passed, so the harbor
|
|
392
|
+
// saves here.
|
|
393
|
+
if (typeof req.via !== 'string') return JSON.stringify({ id, error: 'via is a standing of the user being' });
|
|
394
|
+
const user = hosted.being(hosted.record.user) as User | undefined;
|
|
395
|
+
const st = user?.standings[req.via];
|
|
396
|
+
if (!st) return JSON.stringify({ id, error: `no standing ${req.via} at the user being` });
|
|
397
|
+
try {
|
|
398
|
+
const out = await st.ask(req.method, (req.args ?? {}) as JsonObject);
|
|
399
|
+
return JSON.stringify({ id, result: isSilence(out) ? { silence: true } : isWord(out) ? { word: wordOf(out) } : out });
|
|
400
|
+
} finally {
|
|
401
|
+
await hosted.save();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const out = await hosted.ask(req.method, req.args ?? {});
|
|
405
|
+
return JSON.stringify({ id, result: isSilence(out) ? { silence: true } : out });
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return JSON.stringify({ id, error: e instanceof Error ? e.message : String(e) });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
|
|
412
|
+
async function readRoutes(dir: string): Promise<Routes | null> {
|
|
413
|
+
const p = join(dir, 'routes.json');
|
|
414
|
+
if (!existsSync(p)) return null;
|
|
415
|
+
const r = JSON.parse(await readFile(p, 'utf8')) as Partial<Routes>;
|
|
416
|
+
if (typeof r.mcp !== 'string' || typeof r.web !== 'string') return null;
|
|
417
|
+
const out: Routes = { mcp: r.mcp.replace(/\/$/, ''), web: r.web.replace(/\/$/, '') };
|
|
418
|
+
if (typeof r.quo === 'string') out.quo = r.quo.replace(/\/$/, '');
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// The tab page, `estate/human/tab.ts`, on the web route: the page, its
|
|
423
|
+
// bundle built once from the source, and the one call of the exchange a tab
|
|
424
|
+
// makes: the owner password for a nonce the desk honours under `tab`. The
|
|
425
|
+
// avatar that knocks with it lives in the tab, not here, so nothing is
|
|
426
|
+
// admitted on this side; the tab's harbor announces its ward and the user
|
|
427
|
+
// being knocks back down that socket.
|
|
428
|
+
function tabPages(main: Hosted, at: { quo: string; web: string }, password: () => string | undefined): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
|
|
429
|
+
// The tab's source sits beside this file: `.ts` in the tree, `.js` once
|
|
430
|
+
// emitted into the package's dist. The bundler takes whichever is there.
|
|
431
|
+
const tabEntry = () => {
|
|
432
|
+
const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
|
|
433
|
+
return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
|
|
434
|
+
};
|
|
435
|
+
let bundle: Promise<string> | undefined;
|
|
436
|
+
const built = () =>
|
|
437
|
+
(bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0]!.text));
|
|
438
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
439
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem;color:#222}input,button{font:inherit;padding:.4rem;margin:.2rem}pre{background:#f4f4f4;padding:.75rem;overflow:auto}</style>
|
|
440
|
+
</head><body><script type="module">import { start } from './tab.js'; start(${JSON.stringify(at)});</script></body></html>`;
|
|
441
|
+
return async (req, res, rest) => {
|
|
442
|
+
if (rest === '/tab' && req.method === 'GET') {
|
|
443
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
444
|
+
res.end(html);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (rest === '/tab.js' && req.method === 'GET') {
|
|
448
|
+
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
449
|
+
res.end(await built());
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (rest === '/tab/login' && req.method === 'POST') {
|
|
453
|
+
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
454
|
+
let body: { password?: unknown; identity?: unknown } = {};
|
|
455
|
+
try {
|
|
456
|
+
body = JSON.parse(raw?.toString() || '{}') as typeof body;
|
|
457
|
+
} catch {
|
|
458
|
+
/* nothing to read */
|
|
459
|
+
}
|
|
460
|
+
const json = (status: number, v: unknown) => {
|
|
461
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
462
|
+
res.end(JSON.stringify(v));
|
|
463
|
+
return true;
|
|
464
|
+
};
|
|
465
|
+
const want = password();
|
|
466
|
+
if (want === undefined) return json(503, { error: 'closed: no owner password is set' });
|
|
467
|
+
const got = typeof body.password === 'string' ? body.password : '';
|
|
468
|
+
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
469
|
+
await new Promise((ok) => setTimeout(ok, 300));
|
|
470
|
+
return json(401, { error: 'that is not the password' });
|
|
471
|
+
}
|
|
472
|
+
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
473
|
+
if (!/^[\w.-]{1,40}$/.test(identity) || identity === main.record.user || identity === 'desk') return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
474
|
+
const nonce = randomBytes(16).toString('hex');
|
|
475
|
+
NONCES.set(nonce, { kind: 'tab', user: main.record.user, client: identity });
|
|
476
|
+
return json(200, { nonce, ward: main.pk });
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
483
|
+
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
484
|
+
function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
|
|
485
|
+
const pks = () => [...harbor.doors.keys()];
|
|
486
|
+
// The route is public and carries sealed bytes, so any origin may POST
|
|
487
|
+
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
488
|
+
// ordinary case, and a binary POST needs the preflight answered.
|
|
489
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
490
|
+
http.mount('/quo', async (req, res, rest) => {
|
|
491
|
+
if (req.method === 'OPTIONS') {
|
|
492
|
+
res.writeHead(204, open);
|
|
493
|
+
return void res.end();
|
|
494
|
+
}
|
|
495
|
+
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
496
|
+
// older than the header is this one. Anything else this door cannot open
|
|
497
|
+
// and says so as nothing delivered, rather than taking bytes that will
|
|
498
|
+
// never open and answering a silence that names no reason.
|
|
499
|
+
const suite = req.headers['quo-suite'];
|
|
500
|
+
if (suite !== undefined && suite !== String(SUITE)) {
|
|
501
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
502
|
+
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
503
|
+
}
|
|
504
|
+
const pk = rest.slice(1);
|
|
505
|
+
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
506
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
507
|
+
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
508
|
+
}
|
|
509
|
+
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
510
|
+
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
511
|
+
if (back === undefined) {
|
|
512
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
513
|
+
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
514
|
+
}
|
|
515
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
516
|
+
res.end(Buffer.from(back));
|
|
517
|
+
});
|
|
518
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
519
|
+
server.on('upgrade', (req, socket, head) => {
|
|
520
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
521
|
+
if (url.pathname.replace(/\/$/, '') !== '/quo') return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
522
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
523
|
+
const s: Held = new Held(
|
|
524
|
+
ws as unknown as Line,
|
|
525
|
+
(pk, bytes) => harbor.deliver(pk, bytes),
|
|
526
|
+
(far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
527
|
+
() => {
|
|
528
|
+
harbor.unbind(s);
|
|
529
|
+
quo.sockets.delete(s);
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
quo.sockets.add(s);
|
|
533
|
+
s.announce(pks());
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
|
|
539
|
+
async function readDial(dir: string): Promise<string[]> {
|
|
540
|
+
const p = join(dir, 'dial.json');
|
|
541
|
+
if (!existsSync(p)) return [];
|
|
542
|
+
const v = JSON.parse(await readFile(p, 'utf8')) as unknown;
|
|
543
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// `<dir>/agents.json`: { <identity>: { command, args?, dir } }, one per agent.
|
|
547
|
+
async function readAgents(dir: string): Promise<Agents> {
|
|
548
|
+
const p = join(dir, 'agents.json');
|
|
549
|
+
if (!existsSync(p)) return {};
|
|
550
|
+
const out: Agents = {};
|
|
551
|
+
for (const [identity, a] of Object.entries(JSON.parse(await readFile(p, 'utf8')) as Record<string, Partial<Agents[string]>>)) {
|
|
552
|
+
if (typeof a.command !== 'string' || typeof a.dir !== 'string') throw new Error(`agents.json: ${identity} needs a command and a dir`);
|
|
553
|
+
out[identity] = { command: a.command, args: Array.isArray(a.args) ? a.args.filter((x): x is string => typeof x === 'string') : [], dir: a.dir };
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
|
|
559
|
+
// `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
|
|
560
|
+
// requests, codes and tokens, each mapping to a client identity at most.
|
|
561
|
+
async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp | null): Promise<OAuth> {
|
|
562
|
+
const file = join(dir, 'oauth.json');
|
|
563
|
+
const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
|
|
564
|
+
let queue = Promise.resolve();
|
|
565
|
+
const persist = (s: Store) =>
|
|
566
|
+
(queue = queue.then(async () => {
|
|
567
|
+
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
568
|
+
await rename(file + '.tmp', file);
|
|
569
|
+
}));
|
|
570
|
+
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
571
|
+
http.mount('/mcp', async (req, res, rest) => {
|
|
572
|
+
if (await oauth.handle(req, res, rest)) return;
|
|
573
|
+
if (rest === '/mcp') {
|
|
574
|
+
const identity = oauth.bearer(req);
|
|
575
|
+
if (identity === null || !mcp) return oauth.challenge(res);
|
|
576
|
+
return mcp.handle(req, res, identity);
|
|
577
|
+
}
|
|
578
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
579
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
580
|
+
});
|
|
581
|
+
return oauth;
|
|
582
|
+
}
|