@quo-systems/dock 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/beings/avatar.ts +6 -2
- package/beings/carry.ts +101 -0
- package/beings/desk.ts +2 -2
- package/beings/index.ts +2 -0
- package/beings/link.ts +54 -0
- package/beings/look.ts +96 -0
- package/beings/quo-dock.md +185 -38
- package/beings/setup.ts +3 -1
- package/beings/side.ts +10 -1
- package/beings/user.ts +31 -5
- package/cli/daemon.ts +98 -44
- package/cli/estate/Caddyfile +25 -0
- package/cli/estate/quo.service +36 -0
- package/cli/estate.ts +44 -0
- package/cli/quo.ts +14 -3
- package/dist/beings/avatar.js +7 -2
- 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 +2 -0
- 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/setup.js +4 -1
- package/dist/beings/side.d.ts +8 -1
- package/dist/beings/user.d.ts +26 -2
- package/dist/beings/user.js +32 -5
- package/dist/cli/daemon.d.ts +1 -1
- package/dist/cli/daemon.js +98 -44
- package/dist/cli/estate/Caddyfile +25 -0
- package/dist/cli/estate/quo.service +36 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +46 -0
- package/dist/cli/quo.js +14 -3
- package/dist/harbor/edge/exercise.js +3 -1
- package/dist/harbor/edge/platform.d.ts +28 -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 +25 -6
- package/dist/human/tab.d.ts +2 -0
- package/dist/human/tab.js +127 -42
- package/dist/mcp/http.d.ts +4 -3
- package/dist/mcp/http.js +6 -6
- package/dist/mcp/oauth.d.ts +11 -4
- package/dist/mcp/oauth.js +32 -21
- package/dist/mcp/pilot.d.ts +3 -4
- package/dist/mcp/pilot.js +19 -65
- package/dist/mcp/server.d.ts +6 -4
- package/dist/mcp/server.js +48 -11
- package/dist/mcp/web/exchange.d.ts +5 -2
- package/dist/mcp/web/exchange.js +21 -7
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/quo-harbor.md +37 -0
- package/human/guest.ts +26 -0
- package/human/html.ts +58 -10
- package/human/quo-human.md +123 -66
- package/human/screen.ts +28 -7
- package/human/tab.ts +153 -51
- package/mcp/http.ts +10 -9
- package/mcp/oauth.ts +39 -23
- package/mcp/pilot.ts +26 -65
- package/mcp/quo-mcp.md +34 -19
- package/mcp/server.ts +52 -19
- package/mcp/web/exchange.ts +23 -9
- package/package.json +7 -3
package/cli/daemon.ts
CHANGED
|
@@ -102,7 +102,7 @@ export const sidePath = (dir: string) => socketPath(dir, 'side');
|
|
|
102
102
|
// them: `local`, for a side that reached side.sock, and `web`, for a human
|
|
103
103
|
// who passed the exchange pages. Both are the daemon vouching for someone it
|
|
104
104
|
// saw itself; a proof of either kind arriving any other way names no nonce.
|
|
105
|
-
const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean }>();
|
|
105
|
+
const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
|
|
106
106
|
// A nonce is minted for one kind and honoured under that kind alone: a tab's
|
|
107
107
|
// nonce offered as a `local` proof names no nonce, so a proof of a kind still
|
|
108
108
|
// arrives only the one way that kind is made.
|
|
@@ -138,7 +138,7 @@ async function readAll(req: AsyncIterable<Buffer>): Promise<Buffer | undefined>
|
|
|
138
138
|
// `user` and nothing is minted, so `wake`, the human's word that this
|
|
139
139
|
// device may wake her others, is read the first time only; to change it,
|
|
140
140
|
// remove the occupant and allow again. The one path for every side.
|
|
141
|
-
export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
141
|
+
export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
142
142
|
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
|
|
143
143
|
const key = `avatar:${identity}`;
|
|
144
144
|
let avatar = hosted.being(key) as Avatar | undefined;
|
|
@@ -148,7 +148,7 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
|
|
|
148
148
|
avatar = hosted.being(key) as Avatar;
|
|
149
149
|
}
|
|
150
150
|
const nonce = randomBytes(16).toString('hex');
|
|
151
|
-
NONCES.set(nonce,
|
|
151
|
+
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
152
152
|
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
153
153
|
NONCES.delete(nonce);
|
|
154
154
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
@@ -196,30 +196,43 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
196
196
|
mountQuo(harbor, http, server, quo);
|
|
197
197
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
198
198
|
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
199
|
-
const main = harbor.wards.get('main');
|
|
200
199
|
const here = `http://${http.host}:${http.port}`;
|
|
201
|
-
// the
|
|
202
|
-
const tab =
|
|
200
|
+
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
201
|
+
const tab = worldPages(harbor, { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` }, password);
|
|
203
202
|
if (routes) {
|
|
204
|
-
// the
|
|
205
|
-
|
|
203
|
+
// the worlds a client may be allowed into: every ward with a public being, main first
|
|
204
|
+
const worlds = () =>
|
|
205
|
+
[...harbor.wards]
|
|
206
|
+
.filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
|
|
207
|
+
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
208
|
+
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
209
|
+
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
210
|
+
mcp = new McpHttp(
|
|
211
|
+
async (identity, ward) => {
|
|
212
|
+
const hosted = harbor.wards.get(ward);
|
|
213
|
+
return hosted ? admit(hosted, identity, 'web') : { error: 'no such world' };
|
|
214
|
+
},
|
|
215
|
+
async () => {
|
|
216
|
+
for (const h of harbor.wards.values()) await h.save();
|
|
217
|
+
},
|
|
218
|
+
);
|
|
206
219
|
oauth = await mountOAuth(harbor.dir, http, routes, mcp);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
220
|
+
mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
221
|
+
exchange = new Exchange({
|
|
222
|
+
oauth,
|
|
223
|
+
password,
|
|
224
|
+
worlds,
|
|
225
|
+
admit: async (identity, wake, reach, ward) => {
|
|
226
|
+
const hosted = harbor.wards.get(ward);
|
|
227
|
+
if (!hosted) return { error: 'no such world' };
|
|
228
|
+
const r = await admit(hosted, identity, 'web', wake, reach);
|
|
229
|
+
return r.error ? { error: r.error } : {};
|
|
230
|
+
},
|
|
231
|
+
});
|
|
219
232
|
}
|
|
220
233
|
const ex = exchange;
|
|
221
234
|
http.mount('/web', async (req, res, rest) => {
|
|
222
|
-
if (
|
|
235
|
+
if (await tab(req, res, rest)) return;
|
|
223
236
|
if (ex && (await ex.handle(req, res, rest))) return;
|
|
224
237
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
225
238
|
res.end('no such page');
|
|
@@ -419,15 +432,23 @@ async function readRoutes(dir: string): Promise<Routes | null> {
|
|
|
419
432
|
return out;
|
|
420
433
|
}
|
|
421
434
|
|
|
422
|
-
// The
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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> {
|
|
431
452
|
const tabEntry = () => {
|
|
432
453
|
const js = fileURLToPath(new URL('../human/tab.js', import.meta.url));
|
|
433
454
|
return existsSync(js) ? js : fileURLToPath(new URL('../human/tab.ts', import.meta.url));
|
|
@@ -435,21 +456,49 @@ function tabPages(main: Hosted, at: { quo: string; web: string }, password: () =
|
|
|
435
456
|
let bundle: Promise<string> | undefined;
|
|
436
457
|
const built = () =>
|
|
437
458
|
(bundle ??= build({ entryPoints: [tabEntry()], bundle: true, format: 'esm', platform: 'browser', target: 'es2023', write: false }).then((o) => o.outputFiles[0]!.text));
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
};
|
|
441
478
|
return async (req, res, rest) => {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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>'}`);
|
|
446
486
|
}
|
|
447
487
|
if (rest === '/tab.js' && req.method === 'GET') {
|
|
448
488
|
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
|
449
489
|
res.end(await built());
|
|
450
490
|
return true;
|
|
451
491
|
}
|
|
452
|
-
|
|
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') {
|
|
453
502
|
const raw = await readAll(req as AsyncIterable<Buffer>);
|
|
454
503
|
let body: { password?: unknown; identity?: unknown } = {};
|
|
455
504
|
try {
|
|
@@ -470,15 +519,20 @@ function tabPages(main: Hosted, at: { quo: string; web: string }, password: () =
|
|
|
470
519
|
return json(401, { error: 'that is not the password' });
|
|
471
520
|
}
|
|
472
521
|
const identity = typeof body.identity === 'string' ? body.identity : '';
|
|
473
|
-
if (!/^[\w.-]{1,40}$/.test(identity) || 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' });
|
|
474
523
|
const nonce = randomBytes(16).toString('hex');
|
|
475
|
-
NONCES.set(nonce, { kind: 'tab', user:
|
|
476
|
-
return json(200, { nonce, ward:
|
|
524
|
+
NONCES.set(nonce, { kind: 'tab', user: hosted.record.user, client: identity, reach: true });
|
|
525
|
+
return json(200, { nonce, ward: hosted.pk });
|
|
477
526
|
}
|
|
478
527
|
return false;
|
|
479
528
|
};
|
|
480
529
|
}
|
|
481
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
|
+
|
|
482
536
|
// The quo. route. A request carries one ask to a pk; an upgrade is a held
|
|
483
537
|
// socket. Bytes from here go to an own door or a held socket, never onward.
|
|
484
538
|
function mountQuo(harbor: DiskHarbor, http: Http, server: HttpServer, quo: Quo): void {
|
|
@@ -571,9 +625,9 @@ async function mountOAuth(dir: string, http: Http, routes: Routes, mcp: McpHttp
|
|
|
571
625
|
http.mount('/mcp', async (req, res, rest) => {
|
|
572
626
|
if (await oauth.handle(req, res, rest)) return;
|
|
573
627
|
if (rest === '/mcp') {
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
576
|
-
return mcp.handle(req, res, identity);
|
|
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);
|
|
577
631
|
}
|
|
578
632
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
579
633
|
res.end(JSON.stringify({ error: 'no such route' }));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Three hostnames, one daemon, one loopback port. Caddy faces the world and
|
|
2
|
+
# terminates TLS; the daemon never does. Each hostname is a route, and a
|
|
3
|
+
# route is a path on the daemon's HTTP door. Replace DOMAIN.
|
|
4
|
+
#
|
|
5
|
+
# mcp. the model side: streamable HTTP MCP, with the credential exchange in front
|
|
6
|
+
# web. the bundle for plain tabs, and the exchange pages
|
|
7
|
+
# quo. the socket door for other harbors: requests in, sockets held, the rendezvous
|
|
8
|
+
#
|
|
9
|
+
# There is no cli. route. The root owner is the unix socket, reached on the
|
|
10
|
+
# device or over SSH, and never through here.
|
|
11
|
+
|
|
12
|
+
mcp.DOMAIN {
|
|
13
|
+
reverse_proxy 127.0.0.1:8787
|
|
14
|
+
rewrite * /mcp{uri}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
web.DOMAIN {
|
|
18
|
+
reverse_proxy 127.0.0.1:8787
|
|
19
|
+
rewrite * /web{uri}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
quo.DOMAIN {
|
|
23
|
+
reverse_proxy 127.0.0.1:8787
|
|
24
|
+
rewrite * /quo{uri}
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# The daemon on a droplet, as one user, forever. Install with:
|
|
2
|
+
# sudo cp droplet/quo.service /etc/systemd/system/quo.service
|
|
3
|
+
# sudo systemctl enable --now quo
|
|
4
|
+
# The unit runs as the `quo` user, whose home holds the estate folder and
|
|
5
|
+
# the harbor folder, so the seed, the lease and the sockets are that user's
|
|
6
|
+
# alone. The HTTP door is loopback only; Caddy fronts it. See Caddyfile
|
|
7
|
+
# beside this file.
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=quo: one harbor on this device
|
|
10
|
+
After=network.target
|
|
11
|
+
|
|
12
|
+
[Service]
|
|
13
|
+
User=quo
|
|
14
|
+
Group=quo
|
|
15
|
+
Environment=QUO_DIR=/home/quo/.quo
|
|
16
|
+
Environment=QUO_HTTP=8787
|
|
17
|
+
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
|
+
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
|
+
# reads its credential from the device, never from cells.
|
|
20
|
+
EnvironmentFile=-/etc/quo/env
|
|
21
|
+
WorkingDirectory=/home/quo/ESTATE
|
|
22
|
+
ExecStart=/usr/bin/node /home/quo/ESTATE/node_modules/@quo-systems/dock/dist/cli/quo.js serve
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
KillSignal=SIGTERM
|
|
26
|
+
TimeoutStopSec=10
|
|
27
|
+
NoNewPrivileges=true
|
|
28
|
+
ProtectSystem=strict
|
|
29
|
+
# Agents run in their folders and Claude Code keeps its own state in the
|
|
30
|
+
# user's home; both must be writable for a run to happen, and a harbor
|
|
31
|
+
# with no agent has neither.
|
|
32
|
+
ReadWritePaths=/home/quo/.quo -/home/quo/agents -/home/quo/.claude -/home/quo/.claude.json /tmp
|
|
33
|
+
PrivateTmp=false
|
|
34
|
+
|
|
35
|
+
[Install]
|
|
36
|
+
WantedBy=multi-user.target
|
package/cli/estate.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// `quo estate DIR --domain D`: an estate folder, written once, of the shape
|
|
3
|
+
// every estate has. One folder per harbor, here the first, `droplet/`: the
|
|
4
|
+
// quo directory that device runs minus what it mints, its routes under the
|
|
5
|
+
// domain, no agents, no classes of its own, and that device's unit and
|
|
6
|
+
// Caddyfile. A package file that depends on this dock and nothing else, and
|
|
7
|
+
// one document to fill in. An estate needs nothing the dock does not give
|
|
8
|
+
// it, and this is the dock giving it.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
// This package's own version, read beside the emitted or the source tree.
|
|
15
|
+
async function version(): Promise<string> {
|
|
16
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
17
|
+
const at = fileURLToPath(new URL(rel, import.meta.url));
|
|
18
|
+
if (!existsSync(at)) continue;
|
|
19
|
+
const pkg = JSON.parse(await readFile(at, 'utf8')) as { name?: string; version?: string };
|
|
20
|
+
if (pkg.name === '@quo-systems/dock' && pkg.version) return pkg.version;
|
|
21
|
+
}
|
|
22
|
+
return '0.1.0';
|
|
23
|
+
}
|
|
24
|
+
const template = (name: string) => readFile(fileURLToPath(new URL(`estate/${name}`, import.meta.url)), 'utf8');
|
|
25
|
+
|
|
26
|
+
export async function estate(dir: string, domain: string): Promise<{ dir: string; name: string; files: string[] }> {
|
|
27
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) throw new Error(`estate: ${domain} is not a domain`);
|
|
28
|
+
if (existsSync(join(dir, 'package.json'))) throw new Error(`estate: ${dir} already holds a package`);
|
|
29
|
+
const name = basename(dir);
|
|
30
|
+
const files: Record<string, string> = {
|
|
31
|
+
'package.json': JSON.stringify({ name, private: true, description: `The ${name} estate: one owner's harbors, built on the dock and nothing else.`, type: 'module', dependencies: { '@quo-systems/dock': `^${await version()}` } }, null, 2) + '\n',
|
|
32
|
+
[`${name}.md`]: `# ${name}\n\nAn estate: all of one owner's harbors. One folder per harbor, each the quo\ndirectory that device runs minus what it mints, plus that device's unit.\n\`droplet/\` is the first: its routes under \`${domain}\`, its agents, its\nclasses, its systemd unit and its Caddyfile. This folder depends on the\ndock and nothing else.\n\n## The droplet\n\nWhere it is, how it is reached, and what was done to stand it up: yours\nto write.\n`,
|
|
33
|
+
'droplet/routes.json': JSON.stringify({ mcp: `https://mcp.${domain}`, web: `https://web.${domain}`, quo: `https://quo.${domain}` }) + '\n',
|
|
34
|
+
'droplet/agents.json': '{}\n',
|
|
35
|
+
'droplet/classes/index.ts': '// The classes this harbor holds beside the dock\'s built-in ones, exported\n// by name.\nexport {};\n',
|
|
36
|
+
'droplet/quo.service': (await template('quo.service')).replaceAll('ESTATE', name),
|
|
37
|
+
'droplet/Caddyfile': (await template('Caddyfile')).replaceAll('DOMAIN', domain),
|
|
38
|
+
};
|
|
39
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
40
|
+
await mkdir(join(dir, rel, '..'), { recursive: true });
|
|
41
|
+
await writeFile(join(dir, rel), text);
|
|
42
|
+
}
|
|
43
|
+
return { dir, name, files: Object.keys(files) };
|
|
44
|
+
}
|
package/cli/quo.ts
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
// quo invite [--dir D] [--ward W] [--via S] <json> { being, id }
|
|
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
|
|
17
18
|
// quo reach [--dir D] <pk> <url> a hint for the harbor's directory: that ward pk lives at that quo. route
|
|
19
|
+
// quo estate <dir> --domain D an estate folder of the shape every estate has: a droplet's quo directory, unit and Caddyfile, on this dock
|
|
18
20
|
//
|
|
19
21
|
// D defaults to $QUO_DIR, then ~/.quo. W defaults to main. init takes
|
|
20
22
|
// --user NAME for the user being, defaulting to the device's user. run
|
|
@@ -31,8 +33,9 @@ import { DiskHarbor } from '../harbor/disk.ts';
|
|
|
31
33
|
import { serve } from './daemon.ts';
|
|
32
34
|
import { ask, side, reach, type Run } from './client.ts';
|
|
33
35
|
import { pilotSide } from '../mcp/pilot.ts';
|
|
36
|
+
import { estate } from './estate.ts';
|
|
34
37
|
|
|
35
|
-
const OWNER_ASKS = new Set(['boot', 'invite', 'knock', 'remove']);
|
|
38
|
+
const OWNER_ASKS = new Set(['boot', 'public', 'invite', 'knock', 'remove', 'unboot']);
|
|
36
39
|
|
|
37
40
|
function parse(argv: string[]) {
|
|
38
41
|
const flags: Record<string, string> = {};
|
|
@@ -80,7 +83,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
80
83
|
}
|
|
81
84
|
if (cmd === 'pilot') {
|
|
82
85
|
await ask(dir, undefined, {}, ward); // no daemon is a clear word now, not at the first tool call
|
|
83
|
-
const serving = await pilotSide(dir, ward, new StdioServerTransport(),
|
|
86
|
+
const serving = await pilotSide(dir, ward, new StdioServerTransport(), via);
|
|
84
87
|
process.stdin.once('end', () => void serving.close().then(() => process.exit(0)));
|
|
85
88
|
return -1;
|
|
86
89
|
}
|
|
@@ -93,6 +96,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
93
96
|
process.once('SIGTERM', stop);
|
|
94
97
|
return -1; // stays up
|
|
95
98
|
}
|
|
99
|
+
if (cmd === 'estate') {
|
|
100
|
+
if (!json || !flags.domain) {
|
|
101
|
+
console.error('quo estate needs <dir> --domain D');
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
print(await estate(json, flags.domain));
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
96
107
|
if (cmd === 'reach') {
|
|
97
108
|
if (!json || !more) {
|
|
98
109
|
console.error('quo reach needs <pk> <url>');
|
|
@@ -111,7 +122,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
111
122
|
const failed = 'error' in out || (typeof out.result === 'object' && out.result !== null && 'error' in out.result);
|
|
112
123
|
return failed ? 1 : 0;
|
|
113
124
|
}
|
|
114
|
-
console.error('usage: quo init|serve|census|boot|invite|knock|remove|side|run|pilot|reach [--dir D] [--ward W] [--via S] [--as NAME] [--url URL --model NAME] [--dial URL] [json]');
|
|
125
|
+
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]');
|
|
115
126
|
return 2;
|
|
116
127
|
}
|
|
117
128
|
|
package/dist/beings/avatar.js
CHANGED
|
@@ -47,8 +47,11 @@ export class Avatar extends Being {
|
|
|
47
47
|
return inv ?? { error: 'no invitation' };
|
|
48
48
|
return this.join(inv);
|
|
49
49
|
}
|
|
50
|
-
// Knock with an invitation, hand the
|
|
51
|
-
// and take her as `user`. The only moment a standing is born here.
|
|
50
|
+
// Knock with an invitation, hand the far being one back so she can push,
|
|
51
|
+
// and take her as `user`. The only moment a standing is born here. A being
|
|
52
|
+
// who answers the hello with an error object is a being who answered: she
|
|
53
|
+
// is taken all the same, and the way back she did not take is dropped, so
|
|
54
|
+
// any world's public being may admit a guest, hello or not.
|
|
52
55
|
async join(invitation) {
|
|
53
56
|
if (this.standings[USER])
|
|
54
57
|
return this.tools();
|
|
@@ -60,6 +63,8 @@ export class Avatar extends Being {
|
|
|
60
63
|
this.occupants.remove(PUSHER);
|
|
61
64
|
return { error: isSilence(out) ? 'silence' : wordOf(out) };
|
|
62
65
|
}
|
|
66
|
+
if (out !== null && typeof out === 'object' && !Array.isArray(out) && 'error' in out)
|
|
67
|
+
this.occupants.remove(PUSHER);
|
|
63
68
|
await this.take(USER, invitation);
|
|
64
69
|
return this.tools();
|
|
65
70
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Being } from '@quo-systems/quo';
|
|
2
|
+
import type { Asker, Blueprint, JsonObject, OccupantRecord, Reply } from '@quo-systems/quo';
|
|
3
|
+
export declare class Carrier extends Being {
|
|
4
|
+
static carries(_occupant: OccupantRecord | undefined, _asker: Asker): boolean;
|
|
5
|
+
static hidden(id: string): boolean;
|
|
6
|
+
private get carried();
|
|
7
|
+
private get looks();
|
|
8
|
+
blueprint(asker: Asker): Promise<Blueprint>;
|
|
9
|
+
answer(asker: Asker, method?: string, args?: JsonObject): Promise<Reply>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A carrier: a being who shows the asks of the standings she holds as asks
|
|
3
|
+
// of her own, and forwards. Whoever she carries for sees, in one describe,
|
|
4
|
+
// what she can be asked and what every being she holds can be asked, each
|
|
5
|
+
// under that standing's name; asking one is an ask on that standing, in her
|
|
6
|
+
// name, so the far being sees her and never who is behind her. A user being
|
|
7
|
+
// carries acme for the human's devices; a house being could carry its
|
|
8
|
+
// rooms; nothing here knows what she is.
|
|
9
|
+
//
|
|
10
|
+
// A carried ask is `<id>-<name>`, and the mapping back is kept in her cells
|
|
11
|
+
// and never parsed from the name, so an id with a dash in it is no trouble.
|
|
12
|
+
// The notes say which asks are whose, `standings: { id: { asks, look? } }`,
|
|
13
|
+
// so a screen draws one section per standing and a model side keeps the
|
|
14
|
+
// flat list it needs. Her own asks come first and are never shadowed: a
|
|
15
|
+
// carried name that collides with one of hers is dropped.
|
|
16
|
+
//
|
|
17
|
+
// She asks a standing's describe again when its digest moved, and its
|
|
18
|
+
// `look` once per digest, and keeps both in her cells. So a describe of
|
|
19
|
+
// hers may cost one ask per stale standing, which is the price of a page
|
|
20
|
+
// that is never more than one ask behind.
|
|
21
|
+
import { Being, isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
22
|
+
import { sanitise } from './look.js';
|
|
23
|
+
export class Carrier extends Being {
|
|
24
|
+
// Who sees what she carries. Nobody, until a subclass says who.
|
|
25
|
+
static carries(_occupant, _asker) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
// Standings she never carries: a way back to a device is one, by the
|
|
29
|
+
// dock's own convention, and a subclass may name more.
|
|
30
|
+
static hidden(id) {
|
|
31
|
+
return id.startsWith('to:');
|
|
32
|
+
}
|
|
33
|
+
get carried() {
|
|
34
|
+
return (this.cells.carried ??= {});
|
|
35
|
+
}
|
|
36
|
+
get looks() {
|
|
37
|
+
return (this.cells.looks ??= {});
|
|
38
|
+
}
|
|
39
|
+
// Her describe with her standings carried into it, for an asker who may
|
|
40
|
+
// see them. Every other asker gets her describe alone.
|
|
41
|
+
async blueprint(asker) {
|
|
42
|
+
const C = this.constructor;
|
|
43
|
+
const bp = this.describe(asker);
|
|
44
|
+
if (!C.carries(this.occupant(asker), asker))
|
|
45
|
+
return bp;
|
|
46
|
+
const own = new Set(bp.asks.map((a) => a.name));
|
|
47
|
+
const carried = {};
|
|
48
|
+
const groups = {};
|
|
49
|
+
for (const [id, rec] of Object.entries(this.cells.standings)) {
|
|
50
|
+
if (C.hidden(id))
|
|
51
|
+
continue;
|
|
52
|
+
const st = this.standings[id];
|
|
53
|
+
if (!st)
|
|
54
|
+
continue;
|
|
55
|
+
if (!rec.blueprint || rec.seen !== rec.digest)
|
|
56
|
+
await st.ask(); // her ward writes the record
|
|
57
|
+
if (!rec.blueprint)
|
|
58
|
+
continue;
|
|
59
|
+
const names = [];
|
|
60
|
+
for (const a of rec.blueprint.asks) {
|
|
61
|
+
if (a.name === 'look')
|
|
62
|
+
continue;
|
|
63
|
+
const name = `${id}-${a.name}`;
|
|
64
|
+
if (own.has(name))
|
|
65
|
+
continue;
|
|
66
|
+
carried[name] = { id, ask: a.name };
|
|
67
|
+
names.push(name);
|
|
68
|
+
bp.asks.push({ ...a, name });
|
|
69
|
+
}
|
|
70
|
+
const group = { asks: names };
|
|
71
|
+
if (rec.blueprint.asks.some((a) => a.name === 'look')) {
|
|
72
|
+
const kept = this.looks[id];
|
|
73
|
+
if (!kept || kept.digest !== rec.digest) {
|
|
74
|
+
const l = await st.ask('look');
|
|
75
|
+
this.looks[id] = { digest: rec.digest, look: isSilence(l) || isWord(l) ? {} : sanitise(l) };
|
|
76
|
+
}
|
|
77
|
+
group.look = this.looks[id].look;
|
|
78
|
+
}
|
|
79
|
+
groups[id] = group;
|
|
80
|
+
}
|
|
81
|
+
for (const id of Object.keys(this.looks))
|
|
82
|
+
if (!(id in groups))
|
|
83
|
+
delete this.looks[id];
|
|
84
|
+
this.cells.carried = carried;
|
|
85
|
+
const notes = bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? bp.notes : {};
|
|
86
|
+
return { asks: bp.asks, notes: { ...notes, standings: groups } };
|
|
87
|
+
}
|
|
88
|
+
async answer(asker, method, args = {}) {
|
|
89
|
+
if (method === undefined)
|
|
90
|
+
return this.blueprint(asker);
|
|
91
|
+
const C = this.constructor;
|
|
92
|
+
const to = Object.hasOwn(this.carried, method) ? this.carried[method] : undefined;
|
|
93
|
+
if (to && C.carries(this.occupant(asker), asker)) {
|
|
94
|
+
const st = this.standings[to.id];
|
|
95
|
+
if (!st)
|
|
96
|
+
return { error: 'unknown ask' };
|
|
97
|
+
const out = await st.ask(to.ask, args);
|
|
98
|
+
if (isSilence(out))
|
|
99
|
+
return out;
|
|
100
|
+
if (isWord(out))
|
|
101
|
+
return { error: wordOf(out) };
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return super.answer(asker, method, args);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/beings/desk.d.ts
CHANGED
package/dist/beings/desk.js
CHANGED
|
@@ -33,7 +33,7 @@ export class Desk extends Being {
|
|
|
33
33
|
const user = this.standings[`user:${who.user}`];
|
|
34
34
|
if (!user)
|
|
35
35
|
return { error: 'no such user' };
|
|
36
|
-
const inv = await user.ask('device', who.wake === true ? {
|
|
36
|
+
const inv = await user.ask('device', { client: who.client, ...(who.wake === true ? { wake: true } : {}), ...(who.reach === true ? { reach: true } : {}) });
|
|
37
37
|
if (isSilence(inv))
|
|
38
38
|
return { error: 'silence' };
|
|
39
39
|
if (isWord(inv))
|
package/dist/beings/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { User, DESK } from './user.ts';
|
|
2
2
|
export { Desk, type Proof, type Verified, type Verifier } from './desk.ts';
|
|
3
3
|
export { Avatar, USER, PUSHER } from './avatar.ts';
|
|
4
|
+
export { Carrier } from './carry.ts';
|
|
5
|
+
export { sanitise, hint, hintFor, groups, type Look, type Hint } from './look.ts';
|
package/dist/beings/index.js
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Invitation } from '@quo-systems/quo';
|
|
2
|
+
export declare const KEY = "quo";
|
|
3
|
+
export declare function format(inv: Invitation): string;
|
|
4
|
+
export declare function parse(hash: string): Invitation | null;
|
|
5
|
+
export declare function strip(hash: string): string;
|
|
6
|
+
export declare function link(page: string, inv: Invitation): string;
|
|
7
|
+
export declare function isInvitation(v: unknown): v is Invitation;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const KEY = 'quo';
|
|
2
|
+
const HEX = (n) => `[0-9a-f]{${n}}`;
|
|
3
|
+
const FULL = new RegExp(`^(${HEX(128)})\\.(${HEX(64)})\\.(${HEX(64)})$`);
|
|
4
|
+
const PUBLIC = new RegExp(`^${HEX(128)}$`);
|
|
5
|
+
export function format(inv) {
|
|
6
|
+
return inv.heir && inv.secret ? `${inv.ward}.${inv.heir}.${inv.secret}` : inv.ward;
|
|
7
|
+
}
|
|
8
|
+
// The invitation in a fragment, or null. Anything else in the fragment is
|
|
9
|
+
// the page's own and is left alone.
|
|
10
|
+
export function parse(hash) {
|
|
11
|
+
const m = /(?:^#|&)quo=([^&]+)/.exec(hash.startsWith('#') ? hash : `#${hash}`);
|
|
12
|
+
if (!m)
|
|
13
|
+
return null;
|
|
14
|
+
const v = decodeURIComponent(m[1]);
|
|
15
|
+
const full = FULL.exec(v);
|
|
16
|
+
if (full)
|
|
17
|
+
return { ward: full[1], heir: full[2], secret: full[3] };
|
|
18
|
+
return PUBLIC.test(v) ? { ward: v } : null;
|
|
19
|
+
}
|
|
20
|
+
// The fragment without the invitation, so the page keeps whatever else it
|
|
21
|
+
// put there and the capability is gone from the address bar.
|
|
22
|
+
export function strip(hash) {
|
|
23
|
+
const rest = (hash.startsWith('#') ? hash.slice(1) : hash)
|
|
24
|
+
.split('&')
|
|
25
|
+
.filter((p) => !p.startsWith(`${KEY}=`))
|
|
26
|
+
.join('&');
|
|
27
|
+
return rest ? `#${rest}` : '';
|
|
28
|
+
}
|
|
29
|
+
export function link(page, inv) {
|
|
30
|
+
return `${page}#${KEY}=${format(inv)}`;
|
|
31
|
+
}
|
|
32
|
+
// Whether an answer is an invitation: a ward pk, and a heir with its secret
|
|
33
|
+
// or neither. What a guest's form answers with to be let in.
|
|
34
|
+
export function isInvitation(v) {
|
|
35
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
36
|
+
return false;
|
|
37
|
+
const o = v;
|
|
38
|
+
if (typeof o.ward !== 'string' || !PUBLIC.test(o.ward))
|
|
39
|
+
return false;
|
|
40
|
+
const heir = typeof o.heir === 'string', secret = typeof o.secret === 'string';
|
|
41
|
+
return (heir && secret) || (!heir && !secret && !('heir' in o) && !('secret' in o));
|
|
42
|
+
}
|