@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/beings/user.ts
CHANGED
|
@@ -5,45 +5,79 @@
|
|
|
5
5
|
// special only by the id the root chose for them: `desk`, the front desk,
|
|
6
6
|
// who may ask her to invite a device; and each device, whose id is the
|
|
7
7
|
// client identity the desk established.
|
|
8
|
-
import {
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
9
|
import type { Asker, Invitation, JsonObject, OccupantRecord } from '@quo-systems/quo';
|
|
10
|
+
import { Carrier } from './carry.ts';
|
|
10
11
|
|
|
11
12
|
export const DESK = 'desk';
|
|
12
13
|
const isDesk = (occ: OccupantRecord | undefined) => occ?.id === DESK;
|
|
13
14
|
const isDevice = (occ: OccupantRecord | undefined) => occ !== undefined && occ.id !== DESK;
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
15
|
+
// A device is known by its client identity. The desk writes it as a note
|
|
16
|
+
// when it asks her to mint; the root, minting on her directly, writes
|
|
17
|
+
// nothing, and then the id the root chose is the identity.
|
|
18
|
+
const rootMinted = (occ: OccupantRecord | undefined) => isDevice(occ) && occ?.notes.client === undefined;
|
|
19
|
+
const client = (occ: OccupantRecord | undefined) => (typeof occ?.notes.client === 'string' ? occ.notes.client : rootMinted(occ) ? occ!.id : null);
|
|
20
|
+
// A device the human allowed to wake her other devices: the note says so,
|
|
21
|
+
// or the root minted it, and the root's word needs no note.
|
|
22
|
+
const mayWake = (occ: OccupantRecord | undefined) => isDesk(occ) || occ?.notes.wake === true || rootMinted(occ);
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
// She carries her standings, acme, the calendar, the house, for a device the
|
|
25
|
+
// human allowed to reach them at the exchange: the note says so. A device
|
|
26
|
+
// without the note sees her own asks alone, and a model sees acme only
|
|
27
|
+
// because the human said it may. A device the root minted reaches: the root
|
|
28
|
+
// is the owner of everything she holds and chose to mint it.
|
|
29
|
+
export class User extends Carrier {
|
|
30
|
+
static override carries(occ: OccupantRecord | undefined): boolean {
|
|
31
|
+
return isDevice(occ) && (occ?.notes.reach === true || rootMinted(occ));
|
|
32
|
+
}
|
|
33
|
+
// `ways` holds a device's way back that could not be taken when it was
|
|
34
|
+
// handed over: the knock was unreached, a tab whose pk the far harbor had
|
|
35
|
+
// not bound yet, a phone in a tunnel. Nothing delivered spends nothing,
|
|
36
|
+
// so the invitation is tried again at the next push.
|
|
37
|
+
static override cells = { name: '', reports: [] as JsonObject[], ways: {} as Record<string, Invitation> };
|
|
20
38
|
static override asks = {
|
|
21
39
|
hello: { description: 'say hello, and hand back an invitation so she can reach you', input: { type: 'object', properties: { invitation: { type: 'object' } } } },
|
|
22
40
|
whoami: { description: 'who she thinks you are', input: { type: 'object' }, for: isDevice },
|
|
23
|
-
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
41
|
+
device: { description: 'mint an invitation for a device', input: { type: 'object', properties: { client: { type: 'string' }, wake: { type: 'boolean' }, reach: { type: 'boolean' } }, required: ['client'] }, for: isDesk },
|
|
24
42
|
push: { description: 'push an object to a device: wake it with an event', input: { type: 'object', properties: { client: { type: 'string' }, object: { type: 'object' } }, required: ['client', 'object'] }, for: mayWake },
|
|
25
43
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
|
|
44
|
+
forget: { description: 'revoke a device: drop its way in and her way back to it, in one act', input: { type: 'object', properties: { client: { type: 'string' } }, required: ['client'] }, for: isDesk },
|
|
26
45
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
27
46
|
};
|
|
28
47
|
|
|
29
48
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
30
|
-
// is taken as a standing under its own id, so she can push to it later
|
|
49
|
+
// is taken as a standing under its own id, so she can push to it later;
|
|
50
|
+
// if it cannot be taken now, it is kept and tried at the next push.
|
|
31
51
|
async hello(args: JsonObject, asker: Asker) {
|
|
32
52
|
if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
|
|
33
|
-
|
|
34
|
-
const back = await this.knock(inv);
|
|
35
|
-
if (!isSilence(back) && !isWord(back)) await this.take(`to:${asker.id}`, inv);
|
|
53
|
+
await this.wayBack(asker.id, args.invitation as Invitation);
|
|
36
54
|
}
|
|
37
55
|
return { welcome: asker.id ?? null, name: this.cells.name };
|
|
38
56
|
}
|
|
39
57
|
|
|
58
|
+
// Take a device's way back, or keep it for later when the knock did not
|
|
59
|
+
// reach. A refusal spends the heir and is final; the way is dropped.
|
|
60
|
+
async wayBack(id: string, inv: Invitation): Promise<boolean> {
|
|
61
|
+
const ways = this.cells.ways as Record<string, Invitation>;
|
|
62
|
+
const back = await this.knock(inv);
|
|
63
|
+
if (!isSilence(back) && !isWord(back)) {
|
|
64
|
+
await this.take(`to:${id}`, inv);
|
|
65
|
+
delete ways[id];
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (isWord(back) && wordOf(back) !== 'unreached') delete ways[id];
|
|
69
|
+
else ways[id] = inv;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
whoami(_args: JsonObject, asker: Asker) {
|
|
41
74
|
return { id: asker.id ?? null, client: client(this.occupant(asker)) };
|
|
42
75
|
}
|
|
43
76
|
|
|
44
77
|
// The front desk asks; she mints. The client identity goes into the
|
|
45
78
|
// occupant's notes, and that is what every gate reads. `wake` is the
|
|
46
|
-
// human's word at the exchange that this device may wake her others
|
|
79
|
+
// human's word at the exchange that this device may wake her others, and
|
|
80
|
+
// `reach` that it may see and ask what she holds: her standings, carried.
|
|
47
81
|
async device(args: JsonObject) {
|
|
48
82
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
49
83
|
if (c === null) return { error: 'client is a string' };
|
|
@@ -53,18 +87,38 @@ export class User extends Being {
|
|
|
53
87
|
if (rec) {
|
|
54
88
|
rec.notes.client = c;
|
|
55
89
|
if (args.wake === true) rec.notes.wake = true;
|
|
90
|
+
if (args.reach === true) rec.notes.reach = true;
|
|
56
91
|
}
|
|
57
92
|
return inv as unknown as JsonObject;
|
|
58
93
|
}
|
|
59
94
|
|
|
95
|
+
// Her way out and the device's way in are two relations, and Quo keeps
|
|
96
|
+
// them apart. She joins them herself, here, as her own rule: she does
|
|
97
|
+
// not reach a device she no longer admits. Without it a revoked device
|
|
98
|
+
// stops being able to ask her and keeps receiving everything she pushes.
|
|
60
99
|
async push(args: JsonObject) {
|
|
61
100
|
const c = typeof args.client === 'string' ? args.client : null;
|
|
62
|
-
|
|
101
|
+
if (c === null || !this.cells.occupants[c]) return { error: 'no such device, or it gave no way back' };
|
|
102
|
+
const kept = (this.cells.ways as Record<string, Invitation>)[c];
|
|
103
|
+
if (!this.standings[`to:${c}`] && kept) await this.wayBack(c, kept);
|
|
104
|
+
const st = this.standings[`to:${c}`];
|
|
63
105
|
if (!st) return { error: 'no such device, or it gave no way back' };
|
|
64
106
|
const out = await st.ask('notify', (args.object as JsonObject) ?? {});
|
|
65
107
|
return isSilence(out) ? { error: 'silence' } : isWord(out) ? { error: wordOf(out) } : { pushed: out };
|
|
66
108
|
}
|
|
67
109
|
|
|
110
|
+
// Revocation is one act at her, because only she knows both ids. A side
|
|
111
|
+
// that had to remove two relations by hand could leave half of one
|
|
112
|
+
// standing, and every side would have to remember which half.
|
|
113
|
+
forget(args: JsonObject) {
|
|
114
|
+
const c = typeof args.client === 'string' ? args.client : null;
|
|
115
|
+
if (c === null) return { error: 'client is a string' };
|
|
116
|
+
this.occupants.remove(c);
|
|
117
|
+
this.standings.remove(`to:${c}`);
|
|
118
|
+
delete (this.cells.ways as Record<string, Invitation>)[c];
|
|
119
|
+
return { forgot: c };
|
|
120
|
+
}
|
|
121
|
+
|
|
68
122
|
chores() {
|
|
69
123
|
return { chores: ['census', 'report'] };
|
|
70
124
|
}
|
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 }>;
|
|
@@ -102,7 +95,7 @@ export const sidePath = (dir: string) => socketPath(dir, 'side');
|
|
|
102
95
|
// them: `local`, for a side that reached side.sock, and `web`, for a human
|
|
103
96
|
// who passed the exchange pages. Both are the daemon vouching for someone it
|
|
104
97
|
// 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 }>();
|
|
98
|
+
const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
|
|
106
99
|
// A nonce is minted for one kind and honoured under that kind alone: a tab's
|
|
107
100
|
// nonce offered as a `local` proof names no nonce, so a proof of a kind still
|
|
108
101
|
// arrives only the one way that kind is made.
|
|
@@ -117,28 +110,13 @@ 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
|
|
138
116
|
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
139
117
|
// device may wake her others, is read the first time only; to change it,
|
|
140
118
|
// 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 }> {
|
|
119
|
+
export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
142
120
|
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
121
|
const key = `avatar:${identity}`;
|
|
144
122
|
let avatar = hosted.being(key) as Avatar | undefined;
|
|
@@ -148,7 +126,7 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
|
|
|
148
126
|
avatar = hosted.being(key) as Avatar;
|
|
149
127
|
}
|
|
150
128
|
const nonce = randomBytes(16).toString('hex');
|
|
151
|
-
NONCES.set(nonce,
|
|
129
|
+
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
152
130
|
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
153
131
|
NONCES.delete(nonce);
|
|
154
132
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
@@ -193,33 +171,48 @@ 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
|
-
const main = harbor.wards.get('main');
|
|
200
177
|
const here = `http://${http.host}:${http.port}`;
|
|
201
|
-
// the
|
|
202
|
-
const
|
|
178
|
+
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
179
|
+
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
203
180
|
if (routes) {
|
|
204
|
-
// the
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
181
|
+
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
182
|
+
const worlds = () =>
|
|
183
|
+
[...harbor.wards]
|
|
184
|
+
.filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
|
|
185
|
+
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
186
|
+
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
187
|
+
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
188
|
+
mcp = new McpHttp(
|
|
189
|
+
async (identity, ward) => {
|
|
190
|
+
const hosted = harbor.wards.get(ward);
|
|
191
|
+
return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
|
|
192
|
+
},
|
|
193
|
+
async () => {
|
|
194
|
+
for (const h of harbor.wards.values()) await h.save();
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
const route = await mcpRoute(harbor.dir, routes, mcp);
|
|
198
|
+
oauth = route.oauth;
|
|
199
|
+
http.mount('/mcp', route.handler);
|
|
200
|
+
mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
201
|
+
exchange = new Exchange({
|
|
202
|
+
oauth,
|
|
203
|
+
password,
|
|
204
|
+
worlds,
|
|
205
|
+
admit: async (identity, wake, reach, ward) => {
|
|
206
|
+
const hosted = harbor.wards.get(ward);
|
|
207
|
+
if (!hosted) return { error: 'no such world' };
|
|
208
|
+
const r = await admit(hosted, identity, 'web', wake, reach);
|
|
209
|
+
return r.error ? { error: r.error } : {};
|
|
210
|
+
},
|
|
211
|
+
});
|
|
219
212
|
}
|
|
220
213
|
const ex = exchange;
|
|
221
214
|
http.mount('/web', async (req, res, rest) => {
|
|
222
|
-
if (
|
|
215
|
+
if (await web(req, res, rest)) return;
|
|
223
216
|
if (ex && (await ex.handle(req, res, rest))) return;
|
|
224
217
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
225
218
|
res.end('no such page');
|
|
@@ -264,50 +257,6 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
264
257
|
};
|
|
265
258
|
}
|
|
266
259
|
|
|
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
260
|
// A side connection: hello, then MCP over the same lines, or, with `run`,
|
|
312
261
|
// the human's lines in and the model's final texts out.
|
|
313
262
|
async function sideConnection(harbor: DiskHarbor, c: Socket): Promise<void> {
|
|
@@ -409,7 +358,7 @@ async function handle(harbor: DiskHarbor, line: string): Promise<string> {
|
|
|
409
358
|
}
|
|
410
359
|
|
|
411
360
|
// `<dir>/routes.json`: { mcp, web, quo? }, the public origins the proxy serves.
|
|
412
|
-
async function readRoutes(dir: string): Promise<Routes | null> {
|
|
361
|
+
export async function readRoutes(dir: string): Promise<Routes | null> {
|
|
413
362
|
const p = join(dir, 'routes.json');
|
|
414
363
|
if (!existsSync(p)) return null;
|
|
415
364
|
const r = JSON.parse(await readFile(p, 'utf8')) as Partial<Routes>;
|
|
@@ -419,122 +368,6 @@ async function readRoutes(dir: string): Promise<Routes | null> {
|
|
|
419
368
|
return out;
|
|
420
369
|
}
|
|
421
370
|
|
|
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
371
|
// `<dir>/dial.json`: [url, ...], the quo. routes this daemon holds a socket to.
|
|
539
372
|
async function readDial(dir: string): Promise<string[]> {
|
|
540
373
|
const p = join(dir, 'dial.json');
|
|
@@ -554,29 +387,3 @@ async function readAgents(dir: string): Promise<Agents> {
|
|
|
554
387
|
}
|
|
555
388
|
return out;
|
|
556
389
|
}
|
|
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
|
-
}
|
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
|
+
}
|