@quo-systems/dock 0.2.0 → 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 +9 -3
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +3 -1
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +163 -277
- package/beings/side.ts +10 -1
- package/beings/user.ts +67 -13
- package/cli/daemon.ts +58 -251
- package/cli/http.ts +70 -0
- package/cli/quo.ts +13 -5
- package/dist/beings/avatar.js +10 -3
- package/dist/beings/carry.d.ts +10 -0
- package/dist/beings/carry.js +106 -0
- package/dist/beings/desk.d.ts +1 -0
- package/dist/beings/desk.js +1 -1
- package/dist/beings/index.d.ts +2 -0
- package/dist/beings/index.js +3 -1
- package/dist/beings/link.d.ts +7 -0
- package/dist/beings/link.js +42 -0
- package/dist/beings/look.d.ts +27 -0
- package/dist/beings/look.js +71 -0
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +29 -3
- package/dist/beings/user.js +71 -14
- package/dist/cli/daemon.d.ts +7 -19
- package/dist/cli/daemon.js +51 -240
- package/dist/cli/http.d.ts +17 -0
- package/dist/cli/http.js +60 -0
- package/dist/cli/quo.js +15 -5
- 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/guest.d.ts +3 -0
- package/dist/human/guest.js +25 -0
- package/dist/human/html.d.ts +10 -2
- package/dist/human/html.js +61 -10
- package/dist/human/screen.d.ts +8 -3
- package/dist/human/screen.js +26 -6
- package/dist/human/tab.d.ts +5 -0
- package/dist/human/tab.js +161 -43
- 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/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +9 -4
- package/dist/mcp/oauth.js +15 -14
- package/dist/mcp/pilot.js +1 -1
- package/dist/mcp/route.d.ts +12 -0
- package/dist/mcp/route.js +34 -0
- package/dist/mcp/server.d.ts +5 -10
- package/dist/mcp/server.js +35 -4
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/quo-harbor.md +67 -30
- package/harbor/quo.ts +67 -0
- package/human/door.ts +26 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +158 -67
- package/human/screen.ts +34 -8
- package/human/tab.ts +193 -53
- package/human/web.ts +123 -0
- package/human/worlds.ts +52 -0
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +20 -17
- package/mcp/pilot.ts +1 -1
- package/mcp/quo-mcp.md +20 -2
- package/mcp/route.ts +39 -0
- package/mcp/server.ts +37 -18
- package/mcp/web/exchange.ts +23 -9
- package/package.json +2 -2
package/dist/cli/daemon.d.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import { Socket as Held, type Dialer } from '@quo-systems/quo/harbor';
|
|
3
1
|
import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
|
|
4
2
|
import { type Avatar } from '../beings/index.ts';
|
|
5
3
|
import { type Agent } from '../mcp/agent.ts';
|
|
6
|
-
import { OAuth } from '../mcp/oauth.ts';
|
|
4
|
+
import type { OAuth } from '../mcp/oauth.ts';
|
|
7
5
|
import { Exchange } from '../mcp/web/exchange.ts';
|
|
8
6
|
import { McpHttp } from '../mcp/http.ts';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
mount(prefix: string, handler: Handler): void;
|
|
14
|
-
};
|
|
15
|
-
export type Quo = {
|
|
16
|
-
sockets: Set<Held>;
|
|
17
|
-
dialers: Dialer[];
|
|
18
|
-
};
|
|
7
|
+
import { type Routes } from '../mcp/route.ts';
|
|
8
|
+
import { type Http, type Quo } from './http.ts';
|
|
9
|
+
export type { Handler, Http, Quo } from './http.ts';
|
|
10
|
+
export type { Routes } from '../mcp/route.ts';
|
|
19
11
|
export type Serving = {
|
|
20
12
|
harbor: DiskHarbor;
|
|
21
13
|
sock: string;
|
|
@@ -28,11 +20,6 @@ export type Serving = {
|
|
|
28
20
|
quo: Quo;
|
|
29
21
|
close(): Promise<void>;
|
|
30
22
|
};
|
|
31
|
-
export type Routes = {
|
|
32
|
-
mcp: string;
|
|
33
|
-
web: string;
|
|
34
|
-
quo?: string;
|
|
35
|
-
};
|
|
36
23
|
export type Agents = Record<string, {
|
|
37
24
|
command: string;
|
|
38
25
|
args?: string[];
|
|
@@ -50,8 +37,9 @@ export type Options = {
|
|
|
50
37
|
};
|
|
51
38
|
export declare const sockPath: (dir: string) => string;
|
|
52
39
|
export declare const sidePath: (dir: string) => string;
|
|
53
|
-
export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean): Promise<{
|
|
40
|
+
export declare function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake?: boolean, reach?: boolean): Promise<{
|
|
54
41
|
avatar?: Avatar;
|
|
55
42
|
error?: string;
|
|
56
43
|
}>;
|
|
57
44
|
export declare function serve(dir: string, options?: Options): Promise<Serving>;
|
|
45
|
+
export declare function readRoutes(dir: string): Promise<Routes | null>;
|
package/dist/cli/daemon.js
CHANGED
|
@@ -37,28 +37,20 @@
|
|
|
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 } from 'node:net';
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import { build } from 'esbuild';
|
|
57
|
-
import { fileURLToPath } from 'node:url';
|
|
58
|
-
import { Socket as Held, dial, SUITE } from '@quo-systems/quo/harbor';
|
|
59
|
-
import { unlink, chmod, readFile, writeFile, rename } from 'node:fs/promises';
|
|
50
|
+
import { dial } from '@quo-systems/quo/harbor';
|
|
51
|
+
import { unlink, chmod, readFile } from 'node:fs/promises';
|
|
60
52
|
import { existsSync } from 'node:fs';
|
|
61
|
-
import { createHash, randomBytes
|
|
53
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
62
54
|
import { tmpdir } from 'node:os';
|
|
63
55
|
import { join, resolve } from 'node:path';
|
|
64
56
|
import { createInterface } from 'node:readline';
|
|
@@ -68,9 +60,12 @@ import { Desk } from '../beings/index.js';
|
|
|
68
60
|
import { mcpSide } from '../mcp/server.js';
|
|
69
61
|
import { runnerSide } from '../mcp/runner.js';
|
|
70
62
|
import { agentSide, processRun } from '../mcp/agent.js';
|
|
71
|
-
import { OAuth, emptyStore } from '../mcp/oauth.js';
|
|
72
63
|
import { Exchange } from '../mcp/web/exchange.js';
|
|
73
64
|
import { McpHttp } from '../mcp/http.js';
|
|
65
|
+
import { mcpRoute } from '../mcp/route.js';
|
|
66
|
+
import { webRoute } from '../human/web.js';
|
|
67
|
+
import { quoRoute } from '../harbor/quo.js';
|
|
68
|
+
import { listenHttp } from './http.js';
|
|
74
69
|
// The sockets live in the harbor's folder, where the folder's own permissions
|
|
75
70
|
// guard them. A unix socket path is short by law, about a hundred bytes on
|
|
76
71
|
// macOS, so a deep folder gets its sockets in the temp dir under a name only
|
|
@@ -101,27 +96,12 @@ const honour = (kind) => (proof) => {
|
|
|
101
96
|
};
|
|
102
97
|
Desk.verifiers.local = honour('local');
|
|
103
98
|
Desk.verifiers.web = honour('web');
|
|
104
|
-
Desk.verifiers.tab = honour('tab'); // a tab that gave the owner password at the web route
|
|
105
|
-
// What a request may carry, on the doors that read a body: enough for any ask
|
|
106
|
-
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
107
|
-
const BODY = 1 << 20;
|
|
108
|
-
async function readAll(req) {
|
|
109
|
-
const chunks = [];
|
|
110
|
-
let size = 0;
|
|
111
|
-
for await (const c of req) {
|
|
112
|
-
size += c.length;
|
|
113
|
-
if (size > BODY)
|
|
114
|
-
return undefined;
|
|
115
|
-
chunks.push(c);
|
|
116
|
-
}
|
|
117
|
-
return Buffer.concat(chunks);
|
|
118
|
-
}
|
|
119
99
|
// Admit a client identity into a ward: find or boot her avatar, and enter
|
|
120
100
|
// her with a nonce the desk honours once. On a reconnect she already holds
|
|
121
101
|
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
122
102
|
// device may wake her others, is read the first time only; to change it,
|
|
123
103
|
// remove the occupant and allow again. The one path for every side.
|
|
124
|
-
export async function admit(hosted, identity, kind, wake = false) {
|
|
104
|
+
export async function admit(hosted, identity, kind, wake = false, reach = false) {
|
|
125
105
|
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk')
|
|
126
106
|
return { error: 'an identity is a word, and not a being of the ward' };
|
|
127
107
|
const key = `avatar:${identity}`;
|
|
@@ -133,7 +113,7 @@ export async function admit(hosted, identity, kind, wake = false) {
|
|
|
133
113
|
avatar = hosted.being(key);
|
|
134
114
|
}
|
|
135
115
|
const nonce = randomBytes(16).toString('hex');
|
|
136
|
-
NONCES.set(nonce,
|
|
116
|
+
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
137
117
|
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
138
118
|
NONCES.delete(nonce);
|
|
139
119
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
@@ -175,34 +155,46 @@ export async function serve(dir, options = {}) {
|
|
|
175
155
|
const { server, ...door } = await listenHttp(harbor, options.http.host ?? '127.0.0.1', options.http.port, quo);
|
|
176
156
|
servers.push(server);
|
|
177
157
|
http = door;
|
|
178
|
-
|
|
158
|
+
http.mount('/quo', quoRoute(harbor, server, quo));
|
|
179
159
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
180
160
|
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
181
|
-
const main = harbor.wards.get('main');
|
|
182
161
|
const here = `http://${http.host}:${http.port}`;
|
|
183
|
-
// the
|
|
184
|
-
const
|
|
162
|
+
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
163
|
+
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
185
164
|
if (routes) {
|
|
186
|
-
// the
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
165
|
+
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
166
|
+
const worlds = () => [...harbor.wards]
|
|
167
|
+
.filter(([, h]) => h.partition.public !== null)
|
|
168
|
+
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
169
|
+
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
170
|
+
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
171
|
+
mcp = new McpHttp(async (identity, ward) => {
|
|
172
|
+
const hosted = harbor.wards.get(ward);
|
|
173
|
+
return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
|
|
174
|
+
}, async () => {
|
|
175
|
+
for (const h of harbor.wards.values())
|
|
176
|
+
await h.save();
|
|
177
|
+
});
|
|
178
|
+
const route = await mcpRoute(harbor.dir, routes, mcp);
|
|
179
|
+
oauth = route.oauth;
|
|
180
|
+
http.mount('/mcp', route.handler);
|
|
181
|
+
mcp.gone = (identity, ward) => oauth.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
182
|
+
exchange = new Exchange({
|
|
183
|
+
oauth,
|
|
184
|
+
password,
|
|
185
|
+
worlds,
|
|
186
|
+
admit: async (identity, wake, reach, ward) => {
|
|
187
|
+
const hosted = harbor.wards.get(ward);
|
|
188
|
+
if (!hosted)
|
|
189
|
+
return { error: 'no such world' };
|
|
190
|
+
const r = await admit(hosted, identity, 'web', wake, reach);
|
|
191
|
+
return r.error ? { error: r.error } : {};
|
|
192
|
+
},
|
|
193
|
+
});
|
|
202
194
|
}
|
|
203
195
|
const ex = exchange;
|
|
204
196
|
http.mount('/web', async (req, res, rest) => {
|
|
205
|
-
if (
|
|
197
|
+
if (await web(req, res, rest))
|
|
206
198
|
return;
|
|
207
199
|
if (ex && (await ex.handle(req, res, rest)))
|
|
208
200
|
return;
|
|
@@ -258,48 +250,6 @@ export async function serve(dir, options = {}) {
|
|
|
258
250
|
},
|
|
259
251
|
};
|
|
260
252
|
}
|
|
261
|
-
// The HTTP door. Loopback only; the proxy faces the world. A route mounts
|
|
262
|
-
// under a prefix and gets the rest of the path. `/health` is the daemon's
|
|
263
|
-
// own: the wards it hosts, by name and pk, and nothing a stranger can use.
|
|
264
|
-
async function listenHttp(harbor, host, port, quo) {
|
|
265
|
-
const mounts = new Map();
|
|
266
|
-
const server = createHttp((req, res) => {
|
|
267
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
268
|
-
if (url.pathname === '/health') {
|
|
269
|
-
// the wards it hosts, and the directory: pks and whether each is a
|
|
270
|
-
// socket held here, which is what an operator needs to see a dialer.
|
|
271
|
-
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
272
|
-
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
273
|
-
res.end(JSON.stringify({
|
|
274
|
-
ok: true,
|
|
275
|
-
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
276
|
-
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
277
|
-
sockets: quo.sockets.size,
|
|
278
|
-
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
279
|
-
}));
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
for (const [prefix, handler] of mounts) {
|
|
283
|
-
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
284
|
-
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
285
|
-
if (!res.headersSent)
|
|
286
|
-
res.writeHead(500);
|
|
287
|
-
res.end();
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
293
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
294
|
-
});
|
|
295
|
-
await new Promise((ok, no) => {
|
|
296
|
-
server.once('error', no);
|
|
297
|
-
server.listen(port, host, () => ok());
|
|
298
|
-
});
|
|
299
|
-
const address = server.address();
|
|
300
|
-
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
301
|
-
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
302
|
-
}
|
|
303
253
|
// A side connection: hello, then MCP over the same lines, or, with `run`,
|
|
304
254
|
// the human's lines in and the model's final texts out.
|
|
305
255
|
async function sideConnection(harbor, c) {
|
|
@@ -407,7 +357,7 @@ async function handle(harbor, line) {
|
|
|
407
357
|
}
|
|
408
358
|
}
|
|
409
359
|
// `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
|
|
410
|
-
async function readRoutes(dir) {
|
|
360
|
+
export async function readRoutes(dir) {
|
|
411
361
|
const p = join(dir, 'routes.json');
|
|
412
362
|
if (!existsSync(p))
|
|
413
363
|
return null;
|
|
@@ -419,119 +369,6 @@ async function readRoutes(dir) {
|
|
|
419
369
|
out.quo = r.quo.replace(/\/$/, '');
|
|
420
370
|
return out;
|
|
421
371
|
}
|
|
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, at, password) {
|
|
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;
|
|
436
|
-
const built = () => (bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0].text));
|
|
437
|
-
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>
|
|
438
|
-
<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>
|
|
439
|
-
</head><body><script type="module">import { start } from './tab.js'; start(${JSON.stringify(at)});</script></body></html>`;
|
|
440
|
-
return async (req, res, rest) => {
|
|
441
|
-
if (rest === '/tab' && req.method === 'GET') {
|
|
442
|
-
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
443
|
-
res.end(html);
|
|
444
|
-
return true;
|
|
445
|
-
}
|
|
446
|
-
if (rest === '/tab.js' && req.method === 'GET') {
|
|
447
|
-
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
448
|
-
res.end(await built());
|
|
449
|
-
return true;
|
|
450
|
-
}
|
|
451
|
-
if (rest === '/tab/login' && req.method === 'POST') {
|
|
452
|
-
const raw = await readAll(req);
|
|
453
|
-
let body = {};
|
|
454
|
-
try {
|
|
455
|
-
body = JSON.parse(raw?.toString() || '{}');
|
|
456
|
-
}
|
|
457
|
-
catch {
|
|
458
|
-
/* nothing to read */
|
|
459
|
-
}
|
|
460
|
-
const json = (status, v) => {
|
|
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)
|
|
467
|
-
return json(503, { error: 'closed: no owner password is set' });
|
|
468
|
-
const got = typeof body.password === 'string' ? body.password : '';
|
|
469
|
-
if (got.length !== want.length || !timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
|
|
470
|
-
await new Promise((ok) => setTimeout(ok, 300));
|
|
471
|
-
return json(401, { error: 'that is not the password' });
|
|
472
|
-
}
|
|
473
|
-
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
474
|
-
if (!/^[\w.-]{1,40}$/.test(identity) || identity === main.record.user || identity === 'desk')
|
|
475
|
-
return json(400, { error: 'an identity is one word, and not the user or the desk' });
|
|
476
|
-
const nonce = randomBytes(16).toString('hex');
|
|
477
|
-
NONCES.set(nonce, { kind: 'tab', user: main.record.user, client: identity });
|
|
478
|
-
return json(200, { nonce, ward: main.pk });
|
|
479
|
-
}
|
|
480
|
-
return false;
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
484
|
-
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
485
|
-
function mountQuo(harbor, http, server, quo) {
|
|
486
|
-
const pks = () => [...harbor.doors.keys()];
|
|
487
|
-
// The route is public and carries sealed bytes, so any origin may POST
|
|
488
|
-
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
489
|
-
// ordinary case, and a binary POST needs the preflight answered.
|
|
490
|
-
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
491
|
-
http.mount('/quo', async (req, res, rest) => {
|
|
492
|
-
if (req.method === 'OPTIONS') {
|
|
493
|
-
res.writeHead(204, open);
|
|
494
|
-
return void res.end();
|
|
495
|
-
}
|
|
496
|
-
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
497
|
-
// older than the header is this one. Anything else this door cannot open
|
|
498
|
-
// and says so as nothing delivered, rather than taking bytes that will
|
|
499
|
-
// never open and answering a silence that names no reason.
|
|
500
|
-
const suite = req.headers['quo-suite'];
|
|
501
|
-
if (suite !== undefined && suite !== String(SUITE)) {
|
|
502
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
503
|
-
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
504
|
-
}
|
|
505
|
-
const pk = rest.slice(1);
|
|
506
|
-
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
507
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
508
|
-
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
509
|
-
}
|
|
510
|
-
const raw = await readAll(req);
|
|
511
|
-
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
512
|
-
if (back === undefined) {
|
|
513
|
-
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
514
|
-
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
515
|
-
}
|
|
516
|
-
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
517
|
-
res.end(Buffer.from(back));
|
|
518
|
-
});
|
|
519
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
520
|
-
server.on('upgrade', (req, socket, head) => {
|
|
521
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
522
|
-
if (url.pathname.replace(/\/$/, '') !== '/quo')
|
|
523
|
-
return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
524
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
525
|
-
const s = new Held(ws, (pk, bytes) => harbor.deliver(pk, bytes), (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
526
|
-
() => {
|
|
527
|
-
harbor.unbind(s);
|
|
528
|
-
quo.sockets.delete(s);
|
|
529
|
-
});
|
|
530
|
-
quo.sockets.add(s);
|
|
531
|
-
s.announce(pks());
|
|
532
|
-
});
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
372
|
// `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
|
|
536
373
|
async function readDial(dir) {
|
|
537
374
|
const p = join(dir, 'dial.json');
|
|
@@ -553,29 +390,3 @@ async function readAgents(dir) {
|
|
|
553
390
|
}
|
|
554
391
|
return out;
|
|
555
392
|
}
|
|
556
|
-
// The mcp route: OAuth today, the MCP endpoint at step 9. Its store is
|
|
557
|
-
// `<dir>/oauth.json`, the route's own and never a ward's: clients, pending
|
|
558
|
-
// requests, codes and tokens, each mapping to a client identity at most.
|
|
559
|
-
async function mountOAuth(dir, http, routes, mcp) {
|
|
560
|
-
const file = join(dir, 'oauth.json');
|
|
561
|
-
const store = existsSync(file) ? { ...emptyStore(), ...JSON.parse(await readFile(file, 'utf8')) } : emptyStore();
|
|
562
|
-
let queue = Promise.resolve();
|
|
563
|
-
const persist = (s) => (queue = queue.then(async () => {
|
|
564
|
-
await writeFile(file + '.tmp', JSON.stringify(s), { mode: 0o600 });
|
|
565
|
-
await rename(file + '.tmp', file);
|
|
566
|
-
}));
|
|
567
|
-
const oauth = new OAuth({ issuer: routes.mcp, resource: `${routes.mcp}/mcp`, finish: (id) => `${routes.web}/login?request=${id}`, store, persist });
|
|
568
|
-
http.mount('/mcp', async (req, res, rest) => {
|
|
569
|
-
if (await oauth.handle(req, res, rest))
|
|
570
|
-
return;
|
|
571
|
-
if (rest === '/mcp') {
|
|
572
|
-
const identity = oauth.bearer(req);
|
|
573
|
-
if (identity === null || !mcp)
|
|
574
|
-
return oauth.challenge(res);
|
|
575
|
-
return mcp.handle(req, res, identity);
|
|
576
|
-
}
|
|
577
|
-
res.writeHead(404, { 'content-type': 'application/json' });
|
|
578
|
-
res.end(JSON.stringify({ error: 'no such route' }));
|
|
579
|
-
});
|
|
580
|
-
return oauth;
|
|
581
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse, type Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { Socket as Held, Dialer } from '@quo-systems/quo/harbor';
|
|
3
|
+
import type { DiskHarbor } from '../harbor/disk.ts';
|
|
4
|
+
export type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => void | Promise<void>;
|
|
5
|
+
export type Http = {
|
|
6
|
+
port: number;
|
|
7
|
+
host: string;
|
|
8
|
+
mount(prefix: string, handler: Handler): void;
|
|
9
|
+
};
|
|
10
|
+
export type Quo = {
|
|
11
|
+
sockets: Set<Held>;
|
|
12
|
+
dialers: Dialer[];
|
|
13
|
+
};
|
|
14
|
+
export declare function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined>;
|
|
15
|
+
export declare function listenHttp(harbor: DiskHarbor, host: string, port: number, quo: Quo): Promise<Http & {
|
|
16
|
+
server: HttpServer;
|
|
17
|
+
}>;
|
package/dist/cli/http.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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 } from 'node:http';
|
|
8
|
+
// What a request may carry, on the doors that read a body: enough for any ask
|
|
9
|
+
// a ward seals, and not a body chosen by whoever is on the other end.
|
|
10
|
+
const BODY = 1 << 20;
|
|
11
|
+
export async function readAll(req) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let size = 0;
|
|
14
|
+
for await (const c of req) {
|
|
15
|
+
size += c.length;
|
|
16
|
+
if (size > BODY)
|
|
17
|
+
return undefined;
|
|
18
|
+
chunks.push(c);
|
|
19
|
+
}
|
|
20
|
+
return Buffer.concat(chunks);
|
|
21
|
+
}
|
|
22
|
+
export async function listenHttp(harbor, host, port, quo) {
|
|
23
|
+
const mounts = new Map();
|
|
24
|
+
const server = createHttp((req, res) => {
|
|
25
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
26
|
+
if (url.pathname === '/health') {
|
|
27
|
+
// the wards it hosts, and the directory: pks and whether each is a
|
|
28
|
+
// socket held here, which is what an operator needs to see a dialer.
|
|
29
|
+
// Open to any origin: a tab reads it, and it holds nothing a stranger can use.
|
|
30
|
+
res.writeHead(200, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
|
31
|
+
res.end(JSON.stringify({
|
|
32
|
+
ok: true,
|
|
33
|
+
wards: Object.fromEntries([...harbor.wards].map(([n, h]) => [n, h.pk])),
|
|
34
|
+
reaches: Object.fromEntries([...harbor.reaches].map(([pk, b]) => [pk, b.held ? 'held' : 'reach'])),
|
|
35
|
+
sockets: quo.sockets.size,
|
|
36
|
+
dialers: quo.dialers.map((d) => ({ url: d.url, open: d.socket !== null })),
|
|
37
|
+
}));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const [prefix, handler] of mounts) {
|
|
41
|
+
if (url.pathname === prefix || url.pathname.startsWith(prefix + '/')) {
|
|
42
|
+
void Promise.resolve(handler(req, res, url.pathname.slice(prefix.length))).catch(() => {
|
|
43
|
+
if (!res.headersSent)
|
|
44
|
+
res.writeHead(500);
|
|
45
|
+
res.end();
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ error: 'no such route' }));
|
|
52
|
+
});
|
|
53
|
+
await new Promise((ok, no) => {
|
|
54
|
+
server.once('error', no);
|
|
55
|
+
server.listen(port, host, () => ok());
|
|
56
|
+
});
|
|
57
|
+
const address = server.address();
|
|
58
|
+
const bound = typeof address === 'object' && address !== null ? address.port : port;
|
|
59
|
+
return { server, host, port: bound, mount: (prefix, handler) => mounts.set(prefix.replace(/\/$/, ''), handler) };
|
|
60
|
+
}
|
package/dist/cli/quo.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
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
|
+
// quo unboot [--dir D] [--ward W] [--via S] <json> { being }: a being out of the ward, with every relation she holds
|
|
14
15
|
// quo side [--dir D] [--ward W] --as NAME an avatar over stdio for a local MCP client; NAME is the client identity
|
|
15
16
|
// quo run [--dir D] [--ward W] --as NAME --url URL --model NAME [--turns N] a human at a terminal talking to a model through an avatar: a line in, the model's final text out
|
|
16
17
|
// quo pilot [--dir D] [--ward W] [--via S] the owner pilot over stdio: census, boot, invite, knock, remove
|
|
@@ -29,11 +30,12 @@ import { homedir, userInfo } from 'node:os';
|
|
|
29
30
|
import { join } from 'node:path';
|
|
30
31
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
31
32
|
import { DiskHarbor } from '../harbor/disk.js';
|
|
32
|
-
import { serve } from './daemon.js';
|
|
33
|
+
import { serve, readRoutes } from './daemon.js';
|
|
33
34
|
import { ask, side, reach } from './client.js';
|
|
35
|
+
import { link, isInvitation } from '../beings/link.js';
|
|
34
36
|
import { pilotSide } from '../mcp/pilot.js';
|
|
35
37
|
import { estate } from './estate.js';
|
|
36
|
-
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove']);
|
|
38
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
37
39
|
function parse(argv) {
|
|
38
40
|
const flags = {};
|
|
39
41
|
const rest = [];
|
|
@@ -113,13 +115,21 @@ async function main(argv) {
|
|
|
113
115
|
if (cmd === 'census' || (cmd !== undefined && OWNER_ASKS.has(cmd))) {
|
|
114
116
|
const args = json ? JSON.parse(json) : {};
|
|
115
117
|
const out = await ask(dir, cmd === 'census' ? undefined : cmd, args, ward, via);
|
|
116
|
-
|
|
118
|
+
// an invitation minted here is handed on as a link when the harbor's
|
|
119
|
+
// routes name a web. origin: the world's page with the invitation in
|
|
120
|
+
// the fragment, one string to send to the person it is for
|
|
121
|
+
const minted = cmd === 'invite' && !via && 'result' in out && isInvitation(out.result) ? out.result : null;
|
|
122
|
+
const routes = minted ? await readRoutes(dir) : null;
|
|
123
|
+
if (minted && routes)
|
|
124
|
+
print({ ...minted, link: link(`${routes.web}/${encodeURIComponent(ward)}`, minted) });
|
|
125
|
+
else
|
|
126
|
+
print('error' in out ? { error: out.error } : out.result);
|
|
117
127
|
// an error object the ward answered is an ordinary answer, and still a
|
|
118
128
|
// non-zero exit, so a script can tell a boot that happened from one that did not.
|
|
119
129
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
120
130
|
return failed ? 1 : 0;
|
|
121
131
|
}
|
|
122
|
-
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
132
|
+
console.error('usage: quo init|serve|census|boot|public|invite|knock|remove|unboot|side|run|pilot|reach|estate [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
123
133
|
return 2;
|
|
124
134
|
}
|
|
125
135
|
main(process.argv.slice(2)).then((code) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { Socket as Held, SUITE } from '@quo-systems/quo/harbor';
|
|
3
|
+
import { readAll } from '../cli/http.js';
|
|
4
|
+
export function quoRoute(harbor, server, quo) {
|
|
5
|
+
const pks = () => [...harbor.doors.keys()];
|
|
6
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
7
|
+
server.on('upgrade', (req, socket, head) => {
|
|
8
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
9
|
+
if (url.pathname.replace(/\/$/, '') !== '/quo')
|
|
10
|
+
return void socket.destroy(); // the proxy rewrites the route root to /quo/
|
|
11
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
12
|
+
const s = new Held(ws, (pk, bytes) => harbor.deliver(pk, bytes), (far) => void harbor.bind(far, s, true), // a dialer's claims, each proven at its door: held here, reachable through this socket
|
|
13
|
+
() => {
|
|
14
|
+
harbor.unbind(s);
|
|
15
|
+
quo.sockets.delete(s);
|
|
16
|
+
});
|
|
17
|
+
quo.sockets.add(s);
|
|
18
|
+
s.announce(pks());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
// The route is public and carries sealed bytes, so any origin may POST
|
|
22
|
+
// to it: a tab on a world's web. reaching another world's quo. is the
|
|
23
|
+
// ordinary case, and a binary POST needs the preflight answered.
|
|
24
|
+
const open = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, quo-suite' };
|
|
25
|
+
return async (req, res, rest) => {
|
|
26
|
+
if (req.method === 'OPTIONS') {
|
|
27
|
+
res.writeHead(204, open);
|
|
28
|
+
return void res.end();
|
|
29
|
+
}
|
|
30
|
+
// The wire suite the caller speaks. Absent is this one, because a caller
|
|
31
|
+
// older than the header is this one. Anything else this door cannot open
|
|
32
|
+
// and says so as nothing delivered, rather than taking bytes that will
|
|
33
|
+
// never open and answering a silence that names no reason.
|
|
34
|
+
const suite = req.headers['quo-suite'];
|
|
35
|
+
if (suite !== undefined && suite !== String(SUITE)) {
|
|
36
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
37
|
+
return void res.end(JSON.stringify({ error: `this door speaks wire suite ${SUITE}` }));
|
|
38
|
+
}
|
|
39
|
+
const pk = rest.slice(1);
|
|
40
|
+
if (req.method !== 'POST' || !/^[0-9a-f]{128}$/.test(pk)) {
|
|
41
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
42
|
+
return void res.end(JSON.stringify({ error: 'POST /quo/<pk>' }));
|
|
43
|
+
}
|
|
44
|
+
const raw = await readAll(req);
|
|
45
|
+
const back = raw === undefined ? undefined : await harbor.deliver(pk, new Uint8Array(raw));
|
|
46
|
+
if (back === undefined) {
|
|
47
|
+
res.writeHead(404, { 'content-type': 'application/json', ...open });
|
|
48
|
+
return void res.end(JSON.stringify({ error: 'no reach for that pk' }));
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', ...open });
|
|
51
|
+
res.end(Buffer.from(back));
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The door page: what a stranger sees where nothing lets them in. No
|
|
3
|
+
// invitation in the fragment, no relation in this tab's harbor, and nobody
|
|
4
|
+
// at the ward's door; or a harbor's root with no world to list. It is
|
|
5
|
+
// Quo's page and not the estate's: it says what this is and that a world
|
|
6
|
+
// here is entered by a link someone sends you, and it asks for nothing,
|
|
7
|
+
// since there is nothing a stranger could type that would let them in. A
|
|
8
|
+
// world that wants a face of its own has a public being with a look, or
|
|
9
|
+
// its own page on its own origin; this is the default and nothing more.
|
|
10
|
+
// Pure, strings in and strings out, under the tab's policy: no script, no
|
|
11
|
+
// image that is not a data URI, styles from the one stylesheet.
|
|
12
|
+
import { escape } from './html.js';
|
|
13
|
+
// The mark: a ring with a gap, one being's voice reaching another's door.
|
|
14
|
+
const MARK = 'data:image/svg+xml;base64,' +
|
|
15
|
+
btoa(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"><circle cx="32" cy="32" r="22" stroke-dasharray="110 30" transform="rotate(-60 32 32)"/><circle cx="32" cy="32" r="5" fill="currentColor" stroke="none"/></svg>`);
|
|
16
|
+
export function door(at) {
|
|
17
|
+
const where = at.world ? `<p class="where">${escape(at.world)} <span>at ${escape(at.host)}</span></p>` : `<p class="where">${escape(at.host)}</p>`;
|
|
18
|
+
return `<main class="door"><img class="mark" alt="" src="${MARK}"><h1>quo</h1>${where}<p class="lead">${at.world ? 'This world is entered by invitation.' : 'The worlds here are entered by invitation.'}</p><p>An invitation is a link someone sends you. Open it here, and you are in: no account, no password, nothing to type. What you can do inside is what the world shows you, and it is yours to keep on this device.</p><p class="quiet">Nothing on this page asks anything of you. If you were sent here without a link, ask the person who sent you for one.</p></main>`;
|
|
19
|
+
}
|