@quo-systems/dock 0.2.1 → 0.2.2
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/beings/avatar.ts +3 -1
- package/beings/index.ts +1 -1
- package/beings/quo-dock.md +77 -301
- package/beings/user.ts +39 -11
- package/cli/daemon.ts +26 -273
- package/cli/http.ts +70 -0
- package/cli/quo.ts +10 -3
- package/dist/beings/avatar.js +3 -1
- package/dist/beings/index.js +1 -1
- package/dist/beings/user.d.ts +3 -1
- package/dist/beings/user.js +42 -12
- package/dist/cli/daemon.d.ts +6 -18
- package/dist/cli/daemon.js +22 -265
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +12 -3
- package/dist/harbor/quo.d.ts +4 -0
- package/dist/harbor/quo.js +53 -0
- package/dist/human/door.d.ts +5 -0
- package/dist/human/door.js +19 -0
- package/dist/human/screen.js +1 -0
- package/dist/human/tab.d.ts +5 -2
- package/dist/human/tab.js +121 -88
- package/dist/human/web.d.ts +9 -0
- package/dist/human/web.js +95 -0
- package/dist/human/worlds.d.ts +10 -0
- package/dist/human/worlds.js +38 -0
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/harbor/quo-harbor.md +30 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/quo-human.md +58 -24
- package/human/screen.ts +7 -2
- package/human/tab.ts +140 -102
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/quo-mcp.md +3 -1
- package/mcp/route.ts +39 -0
- package/package.json +2 -2
package/cli/daemon.ts
CHANGED
|
@@ -37,28 +37,21 @@
|
|
|
37
37
|
// her from the user being runs the command in the folder with the event.
|
|
38
38
|
//
|
|
39
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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
|
|
40
|
+
// localhost that a reverse proxy fronts, `http.ts`. Routes mount on it by
|
|
41
|
+
// path, and each lives in the folder whose truth it is: `/web`, the worlds'
|
|
42
|
+
// pages and the exchange, in `human/web.ts` and `mcp/web/`; `/mcp`, the
|
|
43
|
+
// model side behind its credential exchange, in `mcp/route.ts`; `/quo`, the
|
|
44
|
+
// socket door, in `harbor/quo.ts`. It listens only when asked, and only on
|
|
45
|
+
// loopback: the proxy is what faces the world, and the daemon never does.
|
|
46
|
+
// With `--dial URL`, or `<dir>/dial.json`, this daemon is itself a dialer:
|
|
47
|
+
// it holds one socket to each URL, announces its wards, and reconnects with
|
|
52
48
|
// backoff, so a Mac behind NAT is reached through the droplet it dialed.
|
|
53
49
|
import { createServer, type Server, type Socket } from 'node:net';
|
|
54
|
-
import
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
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';
|
|
50
|
+
import type { Server as HttpServer } from 'node:http';
|
|
51
|
+
import { dial } from '@quo-systems/quo/harbor';
|
|
52
|
+
import { unlink, chmod, readFile } from 'node:fs/promises';
|
|
60
53
|
import { existsSync } from 'node:fs';
|
|
61
|
-
import { createHash, randomBytes
|
|
54
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
62
55
|
import { tmpdir } from 'node:os';
|
|
63
56
|
import { join, resolve } from 'node:path';
|
|
64
57
|
import { createInterface } from 'node:readline';
|
|
@@ -70,17 +63,17 @@ import { Desk, type Avatar, type User, type Verifier } from '../beings/index.ts'
|
|
|
70
63
|
import { mcpSide } from '../mcp/server.ts';
|
|
71
64
|
import { runnerSide, type Model } from '../mcp/runner.ts';
|
|
72
65
|
import { agentSide, processRun, type Agent } from '../mcp/agent.ts';
|
|
73
|
-
import { OAuth
|
|
66
|
+
import type { OAuth } from '../mcp/oauth.ts';
|
|
74
67
|
import { Exchange } from '../mcp/web/exchange.ts';
|
|
75
68
|
import { McpHttp } from '../mcp/http.ts';
|
|
69
|
+
import { mcpRoute, type Routes } from '../mcp/route.ts';
|
|
70
|
+
import { webRoute } from '../human/web.ts';
|
|
71
|
+
import { quoRoute } from '../harbor/quo.ts';
|
|
72
|
+
import { listenHttp, type Http, type Quo } from './http.ts';
|
|
76
73
|
|
|
77
|
-
export type Handler
|
|
78
|
-
export type
|
|
79
|
-
export type Quo = { sockets: Set<Held>; dialers: Dialer[] };
|
|
74
|
+
export type { Handler, Http, Quo } from './http.ts';
|
|
75
|
+
export type { Routes } from '../mcp/route.ts';
|
|
80
76
|
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
77
|
// The agents this daemon runs, by client identity. From `<dir>/agents.json`
|
|
85
78
|
// on a device, or given.
|
|
86
79
|
export type Agents = Record<string, { command: string; args?: string[]; dir: string }>;
|
|
@@ -117,21 +110,6 @@ const honour =
|
|
|
117
110
|
};
|
|
118
111
|
Desk.verifiers.local = honour('local');
|
|
119
112
|
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
113
|
|
|
136
114
|
// Admit a client identity into a ward: find or boot her avatar, and enter
|
|
137
115
|
// her with a nonce the desk honours once. On a reconnect she already holds
|
|
@@ -193,12 +171,12 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
193
171
|
const { server, ...door } = await listenHttp(harbor, options.http.host ?? '127.0.0.1', options.http.port, quo);
|
|
194
172
|
servers.push(server);
|
|
195
173
|
http = door;
|
|
196
|
-
|
|
174
|
+
http.mount('/quo', quoRoute(harbor, server as HttpServer, quo));
|
|
197
175
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
198
176
|
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
199
177
|
const here = `http://${http.host}:${http.port}`;
|
|
200
178
|
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
201
|
-
const
|
|
179
|
+
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
202
180
|
if (routes) {
|
|
203
181
|
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
204
182
|
const worlds = () =>
|
|
@@ -216,7 +194,9 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
216
194
|
for (const h of harbor.wards.values()) await h.save();
|
|
217
195
|
},
|
|
218
196
|
);
|
|
219
|
-
|
|
197
|
+
const route = await mcpRoute(harbor.dir, routes, mcp);
|
|
198
|
+
oauth = route.oauth;
|
|
199
|
+
http.mount('/mcp', route.handler);
|
|
220
200
|
mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
221
201
|
exchange = new Exchange({
|
|
222
202
|
oauth,
|
|
@@ -232,7 +212,7 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
232
212
|
}
|
|
233
213
|
const ex = exchange;
|
|
234
214
|
http.mount('/web', async (req, res, rest) => {
|
|
235
|
-
if (await
|
|
215
|
+
if (await web(req, res, rest)) return;
|
|
236
216
|
if (ex && (await ex.handle(req, res, rest))) return;
|
|
237
217
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
238
218
|
res.end('no such page');
|
|
@@ -277,50 +257,6 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
277
257
|
};
|
|
278
258
|
}
|
|
279
259
|
|
|
280
|
-
// The HTTP door. Loopback only; the proxy faces the world. A route mounts
|
|
281
|
-
// under a prefix and gets the rest of the path. `/health` is the daemon's
|
|
282
|
-
// own: the wards it hosts, by name and pk, and nothing a stranger can use.
|
|
283
|
-
async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
|
|
284
|
-
const mounts = new Map<string, Handler>();
|
|
285
|
-
const server = createHttp((req, res) => {
|
|
286
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
287
|
-
if (url.pathname === '/health') {
|
|
288
|
-
// the wards it hosts, and the directory: pks and whether each is a
|
|
289
|
-
// socket held here, which is what an operator needs to see a dialer.
|
|
290
|
-
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
291
|
-
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
292
|
-
res.end(
|
|
293
|
-
JSON.stringify({
|
|
294
|
-
ok: true,
|
|
295
|
-
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
296
|
-
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
297
|
-
sockets: quo.sockets.size,
|
|
298
|
-
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
299
|
-
}),
|
|
300
|
-
);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
for (const [prefix, handler] of mounts) {
|
|
304
|
-
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
305
|
-
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
306
|
-
if (!res.headersSent) res.writeHead(500);
|
|
307
|
-
res.end();
|
|
308
|
-
});
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
313
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
314
|
-
});
|
|
315
|
-
await new Promise<void>((ok, no) => {
|
|
316
|
-
server.once('error', no);
|
|
317
|
-
server.listen(port, host, () => ok());
|
|
318
|
-
});
|
|
319
|
-
const address = server.address();
|
|
320
|
-
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
321
|
-
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
322
|
-
}
|
|
323
|
-
|
|
324
260
|
// A side connection: hello, then MCP over the same lines, or, with `run`,
|
|
325
261
|
// the human's lines in and the model's final texts out.
|
|
326
262
|
async function sideConnection(harbor: DiskHarbor, c: Socket): Promise<void> {
|
|
@@ -422,7 +358,7 @@ async function handle(harbor: DiskHarbor, line: string): Promise<string> {
|
|
|
422
358
|
}
|
|
423
359
|
|
|
424
360
|
// `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
|
|
425
|
-
async function readRoutes(dir: string): Promise<Routes | null> {
|
|
361
|
+
export async function readRoutes(dir: string): Promise<Routes | null> {
|
|
426
362
|
const p = join(dir, 'routes.json');
|
|
427
363
|
if (!existsSync(p)) return null;
|
|
428
364
|
const r = JSON.parse(await readFile(p, 'utf8')) as Partial<Routes>;
|
|
@@ -432,163 +368,6 @@ async function readRoutes(dir: string): Promise<Routes | null> {
|
|
|
432
368
|
return out;
|
|
433
369
|
}
|
|
434
370
|
|
|
435
|
-
// The worlds on the web route. A world is a ward with a public being, and
|
|
436
|
-
// its address is `/web/<ward>`: the tab page, `human/tab.ts`, told which
|
|
437
|
-
// ward and which pk, so that its guest is that ward's public being rendered
|
|
438
|
-
// by the screen, whatever class she is. `/web/` lists the worlds. The bundle
|
|
439
|
-
// is built once from the source beside this file, `.ts` in the tree, `.js`
|
|
440
|
-
// once emitted into the package's dist. `/web/<ward>/login` is the one call
|
|
441
|
-
// of the dock's own way in a tab makes: the owner password for a nonce the
|
|
442
|
-
// desk honours under `tab`. The avatar that knocks with it lives in the tab,
|
|
443
|
-
// not here, so nothing is admitted on this side.
|
|
444
|
-
//
|
|
445
|
-
// The page carries a content security policy: scripts from this origin
|
|
446
|
-
// only and never inline, connections to this origin and the quo. route the
|
|
447
|
-
// tab dials, images from data URIs and this origin, and nothing else. So
|
|
448
|
-
// even a bug in a renderer cannot become a script, and no look can reach a
|
|
449
|
-
// server. The config crosses in a JSON script, which the policy allows.
|
|
450
|
-
const RESERVED_PATHS = new Set(['login', 'allow', 'tab.js']);
|
|
451
|
-
function worldPages(harbor: DiskHarbor, at: { quo: string; web: string }, password: () => string | undefined): (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<boolean> {
|
|
452
|
-
const tabEntry = () => {
|
|
453
|
-
const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
|
|
454
|
-
return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
|
|
455
|
-
};
|
|
456
|
-
let bundle: Promise<string> | undefined;
|
|
457
|
-
const built = () =>
|
|
458
|
-
(bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0]!.text));
|
|
459
|
-
const quoOrigin = (() => {
|
|
460
|
-
try {
|
|
461
|
-
const u = new URL(at.quo);
|
|
462
|
-
return `${u.origin} ${u.origin.replace(/^http/, 'ws')}`;
|
|
463
|
-
} catch {
|
|
464
|
-
return '';
|
|
465
|
-
}
|
|
466
|
-
})();
|
|
467
|
-
const policy = `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ${quoOrigin}; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
|
|
468
|
-
const shell = (body: string) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>quo</title>
|
|
469
|
-
<style>${CSS}</style>
|
|
470
|
-
</head><body>${body}</body></html>`;
|
|
471
|
-
const esc = (v: string) => v.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
472
|
-
const publicOf = (h: Hosted) => (h.partition as { public?: string | null }).public ?? null;
|
|
473
|
-
const html = (status: number, res: ServerResponse, body: string) => {
|
|
474
|
-
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': policy, 'referrer-policy': 'no-referrer' });
|
|
475
|
-
res.end(shell(body));
|
|
476
|
-
return true;
|
|
477
|
-
};
|
|
478
|
-
return async (req, res, rest) => {
|
|
479
|
-
const parts = rest.split('/').filter(Boolean);
|
|
480
|
-
if (req.method === 'GET' && (rest === '' || rest === '/')) {
|
|
481
|
-
const list = [...harbor.wards]
|
|
482
|
-
.filter(([, h]) => publicOf(h) !== null)
|
|
483
|
-
.map(([n, h]) => `<li><a href="${at.web}/${encodeURIComponent(n)}">${esc(n)}</a> <small>${esc(publicOf(h) ?? '')} at the door, <code>${h.pk.slice(0, 16)}…</code></small></li>`)
|
|
484
|
-
.join('');
|
|
485
|
-
return html(200, res, `<h1>worlds</h1>${list ? `<ul>${list}</ul>` : '<p>no ward here has a public being</p>'}`);
|
|
486
|
-
}
|
|
487
|
-
if (rest === '/tab.js' && req.method === 'GET') {
|
|
488
|
-
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
489
|
-
res.end(await built());
|
|
490
|
-
return true;
|
|
491
|
-
}
|
|
492
|
-
const wardName = parts[0] ?? '';
|
|
493
|
-
if (!wardName || RESERVED_PATHS.has(wardName)) return false;
|
|
494
|
-
const hosted = harbor.wards.get(wardName);
|
|
495
|
-
if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
496
|
-
if (parts.length === 1 && req.method === 'GET') {
|
|
497
|
-
if (publicOf(hosted) === null) return html(404, res, `<h1>not a world</h1><p>ward ${esc(wardName)} has no public being, so nobody is at its door.</p>`);
|
|
498
|
-
const cfg = { quo: at.quo, web: at.web, ward: wardName, pk: hosted.pk };
|
|
499
|
-
return html(200, res, `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${at.web}/tab.js"></script>`);
|
|
500
|
-
}
|
|
501
|
-
if (parts.length === 2 && parts[1] === 'login' && req.method === 'POST') {
|
|
502
|
-
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
503
|
-
let body: { password?: unknown; identity?: unknown } = {};
|
|
504
|
-
try {
|
|
505
|
-
body = JSON.parse(raw?.toString() || '{}') as typeof body;
|
|
506
|
-
} catch {
|
|
507
|
-
/* nothing to read */
|
|
508
|
-
}
|
|
509
|
-
const json = (status: number, v: unknown) => {
|
|
510
|
-
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
511
|
-
res.end(JSON.stringify(v));
|
|
512
|
-
return true;
|
|
513
|
-
};
|
|
514
|
-
const want = password();
|
|
515
|
-
if (want === undefined) return json(503, { error: 'closed: no owner password is set' });
|
|
516
|
-
const got = typeof body.password === 'string' ? body.password : '';
|
|
517
|
-
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
518
|
-
await new Promise((ok) => setTimeout(ok, 300));
|
|
519
|
-
return json(401, { error: 'that is not the password' });
|
|
520
|
-
}
|
|
521
|
-
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
522
|
-
if (!/^[\w.-]{1,40}$/.test(identity) || identity === hosted.record.user || identity === 'desk') return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
523
|
-
const nonce = randomBytes(16).toString('hex');
|
|
524
|
-
NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
|
|
525
|
-
return json(200, { nonce, ward: hosted.pk });
|
|
526
|
-
}
|
|
527
|
-
return false;
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// The tab's stylesheet: one, light and dark, honouring the variables a look
|
|
532
|
-
// sets on a section. The page owns layout; a far being paints inside her
|
|
533
|
-
// section and nowhere else.
|
|
534
|
-
const CSS = ":root{color-scheme:light dark;--accent:#3b6ef5;--bg:transparent;--fg:inherit;--font:system-ui,sans-serif;--radius:6px}body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2rem auto;padding:0 1rem}nav.worlds{display:flex;flex-wrap:wrap;gap:.5rem 1rem;font-size:.9rem;opacity:.8}nav.worlds a[aria-current]{font-weight:600}header,main{background:var(--bg);color:var(--fg);font-family:var(--font)}header{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}header .notice{width:100%;margin:0}input,select,textarea,button{font:inherit;padding:.4rem;margin:.2rem;border-radius:var(--radius)}button{background:var(--accent);color:#fff;border:0;padding:.4rem .9rem}fieldset{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);margin:.5rem 0}section.standing{background:var(--bg);color:var(--fg);font-family:var(--font);border-left:4px solid var(--accent);border-radius:var(--radius);padding:.25rem 1rem;margin:1.5rem 0}section.standing h2{display:flex;align-items:center;gap:.5rem;font-size:1.1rem}img.logo{height:1.6rem}table{border-collapse:collapse}td,th{padding:.15rem .5rem;text-align:left}.answer{margin:.5rem 0 1rem;padding:.5rem;border-left:3px solid var(--accent)}.answer.error{border-color:#c33}.answer.silence,.answer.word,.answer.unreached{border-color:#c93}pre{padding:.75rem;overflow:auto}";
|
|
535
|
-
|
|
536
|
-
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
537
|
-
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
538
|
-
function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
|
|
539
|
-
const pks = () => [...harbor.doors.keys()];
|
|
540
|
-
// The route is public and carries sealed bytes, so any origin may POST
|
|
541
|
-
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
542
|
-
// ordinary case, and a binary POST needs the preflight answered.
|
|
543
|
-
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
544
|
-
http.mount('/quo', async (req, res, rest) => {
|
|
545
|
-
if (req.method === 'OPTIONS') {
|
|
546
|
-
res.writeHead(204, open);
|
|
547
|
-
return void res.end();
|
|
548
|
-
}
|
|
549
|
-
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
550
|
-
// older than the header is this one. Anything else this door cannot open
|
|
551
|
-
// and says so as nothing delivered, rather than taking bytes that will
|
|
552
|
-
// never open and answering a silence that names no reason.
|
|
553
|
-
const suite = req.headers['quo-suite'];
|
|
554
|
-
if (suite !== undefined && suite !== String(SUITE)) {
|
|
555
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
556
|
-
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
557
|
-
}
|
|
558
|
-
const pk = rest.slice(1);
|
|
559
|
-
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
560
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
561
|
-
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
562
|
-
}
|
|
563
|
-
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
564
|
-
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
565
|
-
if (back === undefined) {
|
|
566
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
567
|
-
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
568
|
-
}
|
|
569
|
-
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
570
|
-
res.end(Buffer.from(back));
|
|
571
|
-
});
|
|
572
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
573
|
-
server.on('upgrade', (req, socket, head) => {
|
|
574
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
575
|
-
if (url.pathname.replace(/\/$/, '') !== '/quo') return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
576
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
577
|
-
const s: Held = new Held(
|
|
578
|
-
ws as unknown as Line,
|
|
579
|
-
(pk, bytes) => harbor.deliver(pk, bytes),
|
|
580
|
-
(far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
581
|
-
() => {
|
|
582
|
-
harbor.unbind(s);
|
|
583
|
-
quo.sockets.delete(s);
|
|
584
|
-
},
|
|
585
|
-
);
|
|
586
|
-
quo.sockets.add(s);
|
|
587
|
-
s.announce(pks());
|
|
588
|
-
});
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
|
|
592
371
|
// `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
|
|
593
372
|
async function readDial(dir: string): Promise<string[]> {
|
|
594
373
|
const p = join(dir, 'dial.json');
|
|
@@ -608,29 +387,3 @@ async function readAgents(dir: string): Promise<Agents> {
|
|
|
608
387
|
}
|
|
609
388
|
return out;
|
|
610
389
|
}
|
|
611
|
-
|
|
612
|
-
// The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
|
|
613
|
-
// `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
|
|
614
|
-
// requests, codes and tokens, each mapping to a client identity at most.
|
|
615
|
-
async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp | null): Promise<OAuth> {
|
|
616
|
-
const file = join(dir, 'oauth.json');
|
|
617
|
-
const store: Store = existsSync(file) ? { ...emptyStore(), ...(JSON.parse(await readFile(file, 'utf8')) as Partial<Store>) } : emptyStore();
|
|
618
|
-
let queue = Promise.resolve();
|
|
619
|
-
const persist = (s: Store) =>
|
|
620
|
-
(queue = queue.then(async () => {
|
|
621
|
-
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
622
|
-
await rename(file + '.tmp', file);
|
|
623
|
-
}));
|
|
624
|
-
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
625
|
-
http.mount('/mcp', async (req, res, rest) => {
|
|
626
|
-
if (await oauth.handle(req, res, rest)) return;
|
|
627
|
-
if (rest === '/mcp') {
|
|
628
|
-
const grant = oauth.bearer(req);
|
|
629
|
-
if (grant === null || !mcp) return oauth.challenge(res);
|
|
630
|
-
return mcp.handle(req, res, grant.identity, grant.ward);
|
|
631
|
-
}
|
|
632
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
633
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
634
|
-
});
|
|
635
|
-
return oauth;
|
|
636
|
-
}
|
package/cli/http.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The HTTP door: one listener on loopback that a reverse proxy fronts, and
|
|
3
|
+
// the routes mount on it by path prefix. `/health` is the daemon's own: the
|
|
4
|
+
// wards it hosts, by name and pk, and nothing a stranger can use. Each route
|
|
5
|
+
// lives in its own folder, `human/web.ts`, `harbor/quo.ts`, `mcp/route.ts`,
|
|
6
|
+
// and this file knows none of them: a route is a handler under a prefix.
|
|
7
|
+
import { createServer as createHttp, type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http';
|
|
8
|
+
import type { Socket as Held, Dialer } from '@quo-systems/quo/harbor';
|
|
9
|
+
import type { DiskHarbor } from '../harbor/disk.ts';
|
|
10
|
+
|
|
11
|
+
export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
|
|
12
|
+
export type Http = { port: number; host: string; mount(prefix: string, handler: Handler): void };
|
|
13
|
+
// What the quo. route holds: the sockets held here and the dialers this
|
|
14
|
+
// daemon runs. `/health` reports them.
|
|
15
|
+
export type Quo = { sockets: Set<Held>; dialers: Dialer[] };
|
|
16
|
+
|
|
17
|
+
// What a request may carry, on the doors that read a body: enough for any ask
|
|
18
|
+
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
19
|
+
const BODY = 1 << 20;
|
|
20
|
+
export async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined> {
|
|
21
|
+
const chunks: Buffer[] = [];
|
|
22
|
+
let size = 0;
|
|
23
|
+
for await (const c of req) {
|
|
24
|
+
size += c.length;
|
|
25
|
+
if (size > BODY) return undefined;
|
|
26
|
+
chunks.push(c);
|
|
27
|
+
}
|
|
28
|
+
return Buffer.concat(chunks);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & { server: HttpServer }> {
|
|
32
|
+
const mounts = new Map<string, Handler>();
|
|
33
|
+
const server = createHttp((req, res) => {
|
|
34
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
35
|
+
if (url.pathname === '/health') {
|
|
36
|
+
// the wards it hosts, and the directory: pks and whether each is a
|
|
37
|
+
// socket held here, which is what an operator needs to see a dialer.
|
|
38
|
+
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
39
|
+
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
40
|
+
res.end(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
ok: true,
|
|
43
|
+
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
44
|
+
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
45
|
+
sockets: quo.sockets.size,
|
|
46
|
+
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const [prefix, handler] of mounts) {
|
|
52
|
+
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
53
|
+
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
54
|
+
if (!res.headersSent) res.writeHead(500);
|
|
55
|
+
res.end();
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
61
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
62
|
+
});
|
|
63
|
+
await new Promise<void>((ok, no) => {
|
|
64
|
+
server.once('error', no);
|
|
65
|
+
server.listen(port, host, () => ok());
|
|
66
|
+
});
|
|
67
|
+
const address = server.address();
|
|
68
|
+
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
69
|
+
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
70
|
+
}
|
package/cli/quo.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// quo serve [--dir D] [--http PORT] [--dial URL] the daemon: harbor from disk, the two sockets, the HTTP door on loopback, a socket held to URL
|
|
9
9
|
// quo census [--dir D] [--ward W] [--via S] the empty ask: pk and every being
|
|
10
10
|
// quo boot [--dir D] [--ward W] [--via S] <json> { key, class, public? }
|
|
11
|
-
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
|
|
11
|
+
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }; with routes.json naming web., the answer carries the link
|
|
12
12
|
// quo knock [--dir D] [--ward W] [--via S] <json> { being | { boot, key }, id, invitation, method?, args?, wanted? }
|
|
13
13
|
// quo remove [--dir D] [--ward W] [--via S] <json> { being, id }: a relation out of a being; on the ward pk, an owner
|
|
14
14
|
// quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
|
|
@@ -30,8 +30,9 @@ import { homedir, userInfo } from 'node:os';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
32
32
|
import { DiskHarbor } from '../harbor/disk.ts';
|
|
33
|
-
import { serve } from './daemon.ts';
|
|
33
|
+
import { serve, readRoutes } from './daemon.ts';
|
|
34
34
|
import { ask, side, reach, type Run } from './client.ts';
|
|
35
|
+
import { link, isInvitation } from '../beings/link.ts';
|
|
35
36
|
import { pilotSide } from '../mcp/pilot.ts';
|
|
36
37
|
import { estate } from './estate.ts';
|
|
37
38
|
|
|
@@ -116,7 +117,13 @@ async function main(argv: string[]): Promise<number> {
|
|
|
116
117
|
if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
|
|
117
118
|
const args = json ? (JSON.parse(json) as Record<string, unknown>) : {};
|
|
118
119
|
const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
|
|
119
|
-
|
|
120
|
+
// an invitation minted here is handed on as a link when the harbor's
|
|
121
|
+
// routes name a web. origin: the world's page with the invitation in
|
|
122
|
+
// the fragment, one string to send to the person it is for
|
|
123
|
+
const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
|
|
124
|
+
const routes = minted ? await readRoutes(dir) : null;
|
|
125
|
+
if (minted && routes) print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
|
|
126
|
+
else print('error' in out ? { error: out.error } : out.result);
|
|
120
127
|
// an error object the ward answered is an ordinary answer, and still a
|
|
121
128
|
// non-zero exit, so a script can tell a boot that happened from one that did not.
|
|
122
129
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
package/dist/beings/avatar.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// exactly one standing, `user`, and everything her side asks goes through
|
|
5
5
|
// it; the user being reaches her only to push, and she hands the push to
|
|
6
6
|
// every side she has. A side is in-process with her, holds the object, and
|
|
7
|
-
// calls the
|
|
7
|
+
// calls the four methods below: one of the two ways in, `enter` behind a
|
|
8
|
+
// route's proof or `join` with an invitation, then `tools` and `call`.
|
|
9
|
+
// Nothing of a side is in her cells.
|
|
8
10
|
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
11
|
// Her one standing, and the id under which the user being pushes to her.
|
|
10
12
|
// Two ids for one far being, because standings and occupants share one
|
package/dist/beings/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The beings every side shares. See quo-
|
|
2
|
+
// The beings every side shares. See quo-dock.md, the trunk.
|
|
3
3
|
export { User, DESK } from './user.js';
|
|
4
4
|
export { Desk } from './desk.js';
|
|
5
5
|
export { Avatar, USER, PUSHER } from './avatar.js';
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Asker, JsonObject, OccupantRecord } from '@quo-systems/quo';
|
|
1
|
+
import type { Asker, Invitation, JsonObject, OccupantRecord } from '@quo-systems/quo';
|
|
2
2
|
import { Carrier } from './carry.ts';
|
|
3
3
|
export declare const DESK = "desk";
|
|
4
4
|
declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
|
|
@@ -9,6 +9,7 @@ export declare class User extends Carrier {
|
|
|
9
9
|
static cells: {
|
|
10
10
|
name: string;
|
|
11
11
|
reports: JsonObject[];
|
|
12
|
+
ways: Record<string, Invitation>;
|
|
12
13
|
};
|
|
13
14
|
static asks: {
|
|
14
15
|
hello: {
|
|
@@ -103,6 +104,7 @@ export declare class User extends Carrier {
|
|
|
103
104
|
welcome: string | null;
|
|
104
105
|
name: import("@quo-systems/quo").Json;
|
|
105
106
|
}>;
|
|
107
|
+
wayBack(id: string, inv: Invitation): Promise<boolean>;
|
|
106
108
|
whoami(_args: JsonObject, asker: Asker): {
|
|
107
109
|
id: string | null;
|
|
108
110
|
client: string | null;
|
package/dist/beings/user.js
CHANGED
|
@@ -10,18 +10,28 @@ import { Carrier } from './carry.js';
|
|
|
10
10
|
export const DESK = 'desk';
|
|
11
11
|
const isDesk = (occ) => occ?.id === DESK;
|
|
12
12
|
const isDevice = (occ) => occ !== undefined && occ.id !== DESK;
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
13
|
+
// A device is known by its client identity. The desk writes it as a note
|
|
14
|
+
// when it asks her to mint; the root, minting on her directly, writes
|
|
15
|
+
// nothing, and then the id the root chose is the identity.
|
|
16
|
+
const rootMinted = (occ) => isDevice(occ) && occ?.notes.client === undefined;
|
|
17
|
+
const client = (occ) => (typeof occ?.notes.client === 'string' ? occ.notes.client : rootMinted(occ) ? occ.id : null);
|
|
18
|
+
// A device the human allowed to wake her other devices: the note says so,
|
|
19
|
+
// or the root minted it, and the root's word needs no note.
|
|
20
|
+
const mayWake = (occ) => isDesk(occ) || occ?.notes.wake === true || rootMinted(occ);
|
|
16
21
|
// She carries her standings, acme, the calendar, the house, for a device the
|
|
17
22
|
// human allowed to reach them at the exchange: the note says so. A device
|
|
18
23
|
// without the note sees her own asks alone, and a model sees acme only
|
|
19
|
-
// because the human said it may.
|
|
24
|
+
// because the human said it may. A device the root minted reaches: the root
|
|
25
|
+
// is the owner of everything she holds and chose to mint it.
|
|
20
26
|
export class User extends Carrier {
|
|
21
27
|
static carries(occ) {
|
|
22
|
-
return isDevice(occ) && occ?.notes.reach === true;
|
|
28
|
+
return isDevice(occ) && (occ?.notes.reach === true || rootMinted(occ));
|
|
23
29
|
}
|
|
24
|
-
|
|
30
|
+
// `ways` holds a device's way back that could not be taken when it was
|
|
31
|
+
// handed over: the knock was unreached, a tab whose pk the far harbor had
|
|
32
|
+
// not bound yet, a phone in a tunnel. Nothing delivered spends nothing,
|
|
33
|
+
// so the invitation is tried again at the next push.
|
|
34
|
+
static cells = { name: '', reports: [], ways: {} };
|
|
25
35
|
static asks = {
|
|
26
36
|
hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
|
|
27
37
|
whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
|
|
@@ -32,16 +42,30 @@ export class User extends Carrier {
|
|
|
32
42
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
33
43
|
};
|
|
34
44
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
35
|
-
// is taken as a standing under its own id, so she can push to it later
|
|
45
|
+
// is taken as a standing under its own id, so she can push to it later;
|
|
46
|
+
// if it cannot be taken now, it is kept and tried at the next push.
|
|
36
47
|
async hello(args, asker) {
|
|
37
48
|
if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
|
|
38
|
-
|
|
39
|
-
const back = await this.knock(inv);
|
|
40
|
-
if (!isSilence(back) && !isWord(back))
|
|
41
|
-
await this.take(`to:${asker.id}`, inv);
|
|
49
|
+
await this.wayBack(asker.id, args.invitation);
|
|
42
50
|
}
|
|
43
51
|
return { welcome: asker.id ?? null, name: this.cells.name };
|
|
44
52
|
}
|
|
53
|
+
// Take a device's way back, or keep it for later when the knock did not
|
|
54
|
+
// reach. A refusal spends the heir and is final; the way is dropped.
|
|
55
|
+
async wayBack(id, inv) {
|
|
56
|
+
const ways = this.cells.ways;
|
|
57
|
+
const back = await this.knock(inv);
|
|
58
|
+
if (!isSilence(back) && !isWord(back)) {
|
|
59
|
+
await this.take(`to:${id}`, inv);
|
|
60
|
+
delete ways[id];
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (isWord(back) && wordOf(back) !== 'unreached')
|
|
64
|
+
delete ways[id];
|
|
65
|
+
else
|
|
66
|
+
ways[id] = inv;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
45
69
|
whoami(_args, asker) {
|
|
46
70
|
return { id: asker.id ?? null, client: client(this.occupant(asker)) };
|
|
47
71
|
}
|
|
@@ -72,7 +96,12 @@ export class User extends Carrier {
|
|
|
72
96
|
// stops being able to ask her and keeps receiving everything she pushes.
|
|
73
97
|
async push(args) {
|
|
74
98
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
75
|
-
|
|
99
|
+
if (c === null || !this.cells.occupants[c])
|
|
100
|
+
return { error: 'no such device, or it gave no way back' };
|
|
101
|
+
const kept = this.cells.ways[c];
|
|
102
|
+
if (!this.standings[`to:${c}`] && kept)
|
|
103
|
+
await this.wayBack(c, kept);
|
|
104
|
+
const st = this.standings[`to:${c}`];
|
|
76
105
|
if (!st)
|
|
77
106
|
return { error: 'no such device, or it gave no way back' };
|
|
78
107
|
const out = await st.ask('notify', args.object ?? {});
|
|
@@ -87,6 +116,7 @@ export class User extends Carrier {
|
|
|
87
116
|
return { error: 'client is a string' };
|
|
88
117
|
this.occupants.remove(c);
|
|
89
118
|
this.standings.remove(`to:${c}`);
|
|
119
|
+
delete this.cells.ways[c];
|
|
90
120
|
return { forgot: c };
|
|
91
121
|
}
|
|
92
122
|
chores() {
|