@quo-systems/dock 0.2.2 → 0.2.4
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 +8 -1
- package/beings/carry.ts +1 -1
- package/beings/quo-dock.md +53 -39
- package/beings/setup.ts +2 -1
- package/beings/user.ts +48 -1
- package/cli/daemon.ts +51 -25
- package/cli/estate/quo.service +0 -1
- package/dist/beings/avatar.js +9 -2
- package/dist/beings/carry.js +2 -2
- package/dist/beings/setup.js +2 -1
- package/dist/beings/user.d.ts +89 -0
- package/dist/beings/user.js +47 -1
- package/dist/cli/daemon.d.ts +10 -2
- package/dist/cli/daemon.js +51 -23
- package/dist/cli/estate/quo.service +0 -1
- package/dist/harbor/capacitor.d.ts +16 -0
- package/dist/harbor/capacitor.js +135 -0
- package/dist/harbor/disk.js +8 -3
- package/dist/harbor/edge/edge.js +2 -1
- package/dist/harbor/edge/exercise.js +2 -1
- package/dist/harbor/edge/storage.d.ts +0 -3
- package/dist/harbor/edge/storage.js +6 -25
- package/dist/harbor/files.d.ts +2 -1
- package/dist/harbor/files.js +87 -22
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/human/door.d.ts +2 -0
- package/dist/human/door.js +19 -7
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +43 -17
- package/dist/human/local.d.ts +10 -0
- package/dist/human/local.js +10 -0
- package/dist/human/screen.js +26 -8
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +59 -3
- package/dist/human/tree.d.ts +40 -0
- package/dist/human/tree.js +89 -0
- package/dist/human/web.d.ts +1 -0
- package/dist/human/web.js +36 -8
- package/dist/mcp/oauth.js +2 -2
- package/dist/mcp/route.js +1 -1
- package/dist/mcp/runner.js +5 -2
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/web/exchange.d.ts +5 -9
- package/dist/mcp/web/exchange.js +47 -98
- package/harbor/capacitor.ts +142 -0
- package/harbor/disk.ts +8 -3
- package/harbor/edge/edge.ts +2 -1
- package/harbor/edge/exercise.ts +2 -1
- package/harbor/edge/storage.ts +6 -25
- package/harbor/files.ts +79 -19
- package/harbor/quo-harbor.md +58 -15
- package/harbor/seal.ts +26 -0
- package/human/door.ts +20 -7
- package/human/html.ts +41 -17
- package/human/local.ts +26 -0
- package/human/quo-human.md +114 -19
- package/human/screen.ts +21 -7
- package/human/tab.ts +50 -5
- package/human/tree.ts +129 -0
- package/human/web.ts +36 -9
- package/mcp/oauth.ts +2 -2
- package/mcp/quo-mcp.md +27 -20
- package/mcp/route.ts +1 -1
- package/mcp/runner.ts +9 -6
- package/mcp/server.ts +1 -1
- package/mcp/web/exchange.ts +62 -96
- package/package.json +13 -2
package/beings/avatar.ts
CHANGED
|
@@ -59,7 +59,14 @@ export class Avatar extends Being {
|
|
|
59
59
|
// is taken all the same, and the way back she did not take is dropped, so
|
|
60
60
|
// any world's public being may admit a guest, hello or not.
|
|
61
61
|
async join(invitation: Invitation): Promise<Blueprint | { error: string }> {
|
|
62
|
-
if (this.standings[USER])
|
|
62
|
+
if (this.standings[USER]) {
|
|
63
|
+
// A standing the far being removed is not one: she drops it and joins
|
|
64
|
+
// afresh, so an identity forgotten at the user being can be let in again.
|
|
65
|
+
const had = await this.tools();
|
|
66
|
+
if (!('error' in had) || had.error !== 'removed') return had;
|
|
67
|
+
this.standings.remove(USER);
|
|
68
|
+
this.occupants.remove(PUSHER);
|
|
69
|
+
}
|
|
63
70
|
const mine = await this.invite(PUSHER);
|
|
64
71
|
if (mine === null) return { error: 'user is taken' };
|
|
65
72
|
const out = await this.knock(invitation, 'hello', { invitation: mine as unknown as JsonObject });
|
package/beings/carry.ts
CHANGED
|
@@ -60,7 +60,7 @@ export class Carrier extends Being {
|
|
|
60
60
|
if (!rec.blueprint) continue;
|
|
61
61
|
const names: string[] = [];
|
|
62
62
|
for (const a of rec.blueprint.asks) {
|
|
63
|
-
if (a.name === 'look') continue;
|
|
63
|
+
if (a.name === 'look' || a.name === 'page') continue; // presentation is hers, never carried as an ask
|
|
64
64
|
const name = `${id}-${a.name}`;
|
|
65
65
|
if (own.has(name)) continue;
|
|
66
66
|
carried[name] = { id, ask: a.name };
|
package/beings/quo-dock.md
CHANGED
|
@@ -120,7 +120,9 @@ that every arrival is named and judged.
|
|
|
120
120
|
reach sees acme's asks on her, and acme sees her.
|
|
121
121
|
- **Look.** What a being may say about how she is presented, as values: a
|
|
122
122
|
name, a logo, colours, a font, and a hint per ask. One optional ask,
|
|
123
|
-
`look`, answers it, and every side reads the part it understands.
|
|
123
|
+
`look`, answers it, and every side reads the part it understands. A
|
|
124
|
+
second optional ask, `page`, answers her page as a tree of values in the
|
|
125
|
+
screen's closed grammar; only a screen reads it.
|
|
124
126
|
- **Route.** A hostname a reverse proxy sends to one process: `web.`, `quo.`,
|
|
125
127
|
`mcp.`. Routes are deployment, never boundaries.
|
|
126
128
|
|
|
@@ -149,6 +151,12 @@ taken when it can be: a knock back that is unreached, a tab whose pk the
|
|
|
149
151
|
far harbor has not bound yet or a phone in a tunnel, spends nothing, so she
|
|
150
152
|
keeps the invitation and takes it at the next push. A refusal is final.
|
|
151
153
|
|
|
154
|
+
A device the root minted may also call her: her `name` ask, gated to
|
|
155
|
+
root-minted devices alone, since the root already trusts that device with
|
|
156
|
+
its id and its reach. A being does not know her own key, so a name is
|
|
157
|
+
always given, by the desk's first hello at setup or by such a device, and
|
|
158
|
+
she keeps the last one. A name is a word, held to the same shape as a key.
|
|
159
|
+
|
|
152
160
|
## Architecture
|
|
153
161
|
|
|
154
162
|
One droplet, one harbor, three routes. Every other placement is a subset.
|
|
@@ -303,7 +311,9 @@ is dropped, not fixed:
|
|
|
303
311
|
|
|
304
312
|
A look is not capability: it is never in a blueprint or a digest, Quo never
|
|
305
313
|
sees it, and a being without one renders and lists exactly as before. The
|
|
306
|
-
`look` ask itself is presentation and is neither a form nor a tool
|
|
314
|
+
`look` ask itself is presentation and is neither a form nor a tool, and so
|
|
315
|
+
is `page`, the one other presentation ask, which a screen alone reads and
|
|
316
|
+
`packages/dock/human/quo-human.md` defines; a carrier carries neither. A
|
|
307
317
|
carrier keeps each standing's look beside her asks in the notes, so acme's
|
|
308
318
|
section on the human's page is painted as acme asked, inside that section
|
|
309
319
|
and nowhere else. No token can carry a URL, a stylesheet or code, so a far
|
|
@@ -342,9 +352,9 @@ hello() -> { asks it will answer, and how to get in }
|
|
|
342
352
|
device({ proof }) -> invitation | { error } proof is what a route produced
|
|
343
353
|
```
|
|
344
354
|
|
|
345
|
-
`proof` is the
|
|
346
|
-
|
|
347
|
-
|
|
355
|
+
`proof` is the daemon's vouch for a process it saw itself: a nonce it
|
|
356
|
+
minted for a side that reached its local socket, the one proof kind there
|
|
357
|
+
is. The front desk checks it with the verifier for
|
|
348
358
|
that proof kind, finds the user being, asks her `invite({ client })`, and
|
|
349
359
|
hands back what she minted. The front desk never mints an invitation itself:
|
|
350
360
|
only the user being invites into her own world, and she keeps the client
|
|
@@ -353,42 +363,41 @@ identity in the occupant's notes.
|
|
|
353
363
|
## The credential exchange
|
|
354
364
|
|
|
355
365
|
The only piece that looks like today's SaaS, and it is deliberately kept as
|
|
356
|
-
thin as one call.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
the
|
|
371
|
-
|
|
372
|
-
|
|
366
|
+
thin as one call. Nothing is ever typed to get in but an invitation: there
|
|
367
|
+
is no password anywhere in the dock, because login is a knock. Every way in
|
|
368
|
+
ends the same way:
|
|
369
|
+
|
|
370
|
+
1. Somebody who may invite mints an invitation for the client identity.
|
|
371
|
+
The root does, on the box, with `quo invite` on the user being, and
|
|
372
|
+
sends it as a link or a line. On the device, a side that reached the
|
|
373
|
+
daemon's side socket is vouched for by the daemon itself, the one
|
|
374
|
+
**proof** there is: `local`, a nonce the daemon minted for that
|
|
375
|
+
connection and the front desk honours once, under that kind alone, so
|
|
376
|
+
the desk asks the user being to mint for it. File permissions on the
|
|
377
|
+
socket are the device's own rule, which the spec allows.
|
|
378
|
+
2. The invitation reaches the avatar for that identity: handed over at the
|
|
379
|
+
allow page of the model side's exchange, opened as a link in a tab, or
|
|
380
|
+
given by the desk to a local side. On a reconnect the avatar already
|
|
381
|
+
holds her standing, nothing is handed and nothing is minted.
|
|
382
|
+
3. The avatar knocks. The heir dies. From here there is no token anywhere,
|
|
383
|
+
and a route's own grant only remembers which avatar a client speaks
|
|
384
|
+
through.
|
|
385
|
+
|
|
386
|
+
Two more things travel with a minting: whether this device may **wake**
|
|
387
|
+
the human's other devices, and whether it may **reach** what she holds.
|
|
388
|
+
The user being writes each as a note on the occupant she mints, and her
|
|
373
389
|
gate reads the note: `push` is shown to the desk and to a device with the
|
|
374
390
|
`wake` note, and her standings are carried for a device with the `reach`
|
|
375
391
|
note, and for nobody else. A device that may wake can hand an agent an event
|
|
376
392
|
through the user being, and she sees who did; a device that may reach sees
|
|
377
393
|
acme on her, and a model sees acme only because the human said it may. The
|
|
378
394
|
notes are written when the occupant is minted; to change one, remove the
|
|
379
|
-
occupant and
|
|
395
|
+
occupant and mint again, because reconnect mints nothing. A device the
|
|
380
396
|
root mints on her directly, with `quo invite`, carries no note: the id the
|
|
381
397
|
root chose is its client identity, and it reaches and may wake, since the
|
|
382
|
-
root owns everything she holds and chose to mint it.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
`local`, a side that reached the daemon's side socket; `web`, a human who
|
|
386
|
-
passed the exchange pages. Each is a nonce the daemon minted for one kind
|
|
387
|
-
and honours once, under that kind alone, so a proof of any other provenance
|
|
388
|
-
names no nonce and is refused. A human at a screen passes no exchange: a
|
|
389
|
-
tab enters by a link, an invitation a user being minted, and nothing is
|
|
390
|
-
ever typed to get in. The root's first device is no exception; the root
|
|
391
|
-
mints its invitation with `quo invite` on the box and opens the link.
|
|
398
|
+
root owns everything she holds and chose to mint it. An identity the user
|
|
399
|
+
being forgot and lets in again on a fresh invitation is the same avatar:
|
|
400
|
+
her old standing hears `removed`, she drops it, and knocks anew.
|
|
392
401
|
|
|
393
402
|
The route's own lifetimes, where it speaks OAuth: a pending request and a
|
|
394
403
|
code live ten minutes, an access token one hour, a refresh token thirty
|
|
@@ -398,6 +407,8 @@ and what nobody allowed must not stay on disk. A code is spent the first
|
|
|
398
407
|
time it is offered, right or wrong. The client identity is chosen by the
|
|
399
408
|
human at the allow page and never taken from the client's registration: a
|
|
400
409
|
client may register afresh on every connect, and the avatar outlives that.
|
|
410
|
+
The world the client lands in is the world the invitation names, so the
|
|
411
|
+
page offers no choice of world.
|
|
401
412
|
|
|
402
413
|
Tokens, cookies and grants exist for the length of step 1 above and are never
|
|
403
414
|
stored on a Quo side. Revocation is `occupants.remove(id)` on the user being:
|
|
@@ -431,7 +442,10 @@ quo estate <dir> --domain D an estate folder of the shape every estate has: a d
|
|
|
431
442
|
holds the seed, the partition and the ask pointer, on every device, the Mac
|
|
432
443
|
included. Two processes over one partition are two wards with one pk, and
|
|
433
444
|
they diverge silently. `init` is the one exception: it creates with no
|
|
434
|
-
daemon running, and exits.
|
|
445
|
+
daemon running, and exits. Its setup, `beings/setup.ts`, boots the desk
|
|
446
|
+
public and one user being, and knocks the desk on her with `hello` and her
|
|
447
|
+
name: a being does not know her own key, so the desk's first word names
|
|
448
|
+
her, and she keeps the name from then on.
|
|
435
449
|
|
|
436
450
|
**Everyone else connects to it locally.** `pilot` and the bare owner asks
|
|
437
451
|
are clients of a unix socket the daemon opens with permissions only the
|
|
@@ -533,7 +547,7 @@ class bodies and placement, and reaches every piece through the dock.
|
|
|
533
547
|
| reach | carrying bytes to a pk | request, socket |
|
|
534
548
|
| store | keeping partitions by seed | memory, disk, IndexedDB, edge storage |
|
|
535
549
|
| side | an avatar speaking to someone outside | screen, MCP server, runner, agent |
|
|
536
|
-
| proof |
|
|
550
|
+
| proof | a device's vouch as an invitation | one verifier, a daemon nonce, one kind, local |
|
|
537
551
|
| envoy | a non-Quo surface as a being | none built: a name until its first user |
|
|
538
552
|
| carrier | a being showing her standings as her own | the user being; any estate being that extends it |
|
|
539
553
|
| look | how a being is presented, as values | read by the screen and the model side |
|
|
@@ -594,8 +608,6 @@ Two rules hold:
|
|
|
594
608
|
keep in step with every change, and the vectors under `packages/quo/vectors/`
|
|
595
609
|
are the hand that will be extended when the time comes.
|
|
596
610
|
|
|
597
|
-
`next.md` is where the next sitting starts.
|
|
598
|
-
|
|
599
611
|
## Glossary
|
|
600
612
|
|
|
601
613
|
- **avatar**: a being that is someone's voice from outside Quo.
|
|
@@ -611,11 +623,13 @@ Two rules hold:
|
|
|
611
623
|
minted by user beings.
|
|
612
624
|
- **gate**: `for(occupant, asker)`; the whole permission model.
|
|
613
625
|
- **look**: how a being is presented, as values; one optional ask.
|
|
626
|
+
- **page**: a being's page as a tree of values in the screen's grammar; one
|
|
627
|
+
optional ask, read by a screen alone.
|
|
614
628
|
- **owner pilot**: `quo pilot`, the model side over a ward's owner asks, reaching
|
|
615
629
|
the daemon's socket; with `--via`, a ward elsewhere through a standing.
|
|
616
630
|
- **daemon**: `quo serve`, the one process on a device that holds a harbor.
|
|
617
|
-
- **proof**:
|
|
618
|
-
trades it for an invitation.
|
|
631
|
+
- **proof**: the daemon's vouch for a local side, a nonce; the front desk
|
|
632
|
+
trades it for an invitation. Everyone else arrives with an invitation.
|
|
619
633
|
- **route**: a hostname a reverse proxy sends to one process.
|
|
620
634
|
- **side**: the interface an avatar speaks through to someone outside Quo.
|
|
621
635
|
- **user being**: one per human, 24/7, holding all of the human's standings.
|
package/beings/setup.ts
CHANGED
|
@@ -20,6 +20,7 @@ export async function setup(hosted: Hosted, user: string): Promise<void> {
|
|
|
20
20
|
if (pub.error) throw new Error(`init: ${pub.error}`);
|
|
21
21
|
await boot({ key: user, class: 'User' });
|
|
22
22
|
const inv = (await hosted.ask('invite', { being: user, id: DESK })) as Invitation;
|
|
23
|
-
|
|
23
|
+
// the desk's first hello names her, since a being does not know her own key
|
|
24
|
+
const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello', args: { name: user } })) as { taken?: string };
|
|
24
25
|
if (placed.taken !== `user:${user}`) throw new Error('init: the desk could not reach the user being');
|
|
25
26
|
}
|
package/beings/user.ts
CHANGED
|
@@ -41,17 +41,24 @@ export class User extends Carrier {
|
|
|
41
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 },
|
|
42
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 },
|
|
43
43
|
chores: { description: 'what the agent may run', input: { type: 'object' }, for: (occ: OccupantRecord | undefined) => client(occ) === 'agent' },
|
|
44
|
+
look: { description: 'how she is shown', input: { type: 'object' }, for: isDevice },
|
|
45
|
+
page: { description: 'her page, as a tree of values', input: { type: 'object' }, for: isDevice },
|
|
46
|
+
name: { description: 'what she is called', input: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, for: rootMinted },
|
|
44
47
|
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 },
|
|
45
48
|
report: { description: 'what a run of yours found', input: { type: 'object', properties: { event: { type: 'object' }, result: {} }, required: ['event', 'result'] }, for: isDevice },
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
// Anyone may say hello. A device that hands her an invitation in the args
|
|
49
52
|
// 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.
|
|
53
|
+
// if it cannot be taken now, it is kept and tried at the next push. The
|
|
54
|
+
// desk's first hello names her: a being does not know her own key, and the
|
|
55
|
+
// root's setup knocks with the name it booted her under. Once named, she
|
|
56
|
+
// keeps it.
|
|
51
57
|
async hello(args: JsonObject, asker: Asker) {
|
|
52
58
|
if (asker.id !== undefined && args.invitation && typeof args.invitation === 'object' && !Array.isArray(args.invitation)) {
|
|
53
59
|
await this.wayBack(asker.id, args.invitation as Invitation);
|
|
54
60
|
}
|
|
61
|
+
if (asker.id === DESK && !this.cells.name && typeof args.name === 'string' && /^[\w.-]{1,80}$/.test(args.name)) this.cells.name = args.name;
|
|
55
62
|
return { welcome: asker.id ?? null, name: this.cells.name };
|
|
56
63
|
}
|
|
57
64
|
|
|
@@ -123,6 +130,46 @@ export class User extends Carrier {
|
|
|
123
130
|
return { chores: ['census', 'report'] };
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
// How a device shows her: her name as the title, and what each ask is
|
|
134
|
+
// called. Values only; the screen and the model side each read their part.
|
|
135
|
+
look() {
|
|
136
|
+
return {
|
|
137
|
+
name: (this.cells.name as string) || 'you',
|
|
138
|
+
order: ['whoami', 'push', 'name'],
|
|
139
|
+
asks: { whoami: { title: 'who am I', readOnly: true }, push: { title: 'wake a device' }, name: { title: 'call her' } },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Her page for a device: her name, who this device is, the worlds she
|
|
144
|
+
// holds as sections, and the two things a human does here by hand, waking
|
|
145
|
+
// another device and naming her. `hello` and `report` are wiring, a
|
|
146
|
+
// device's first word and an agent's callback; they stay in her describe
|
|
147
|
+
// under the gate and off her page, since a page is presentation and the
|
|
148
|
+
// gate is permission. A device that may not do a thing sees no form for
|
|
149
|
+
// it, however the page names it.
|
|
150
|
+
page() {
|
|
151
|
+
return {
|
|
152
|
+
kind: 'stack',
|
|
153
|
+
of: [
|
|
154
|
+
{ kind: 'text', text: (this.cells.name as string) || 'you', role: 'title' },
|
|
155
|
+
{ kind: 'row', of: [{ kind: 'text', text: 'this device', role: 'label' }, { kind: 'answer', ask: 'whoami' }] },
|
|
156
|
+
{ kind: 'standings' },
|
|
157
|
+
{ kind: 'form', ask: 'push' },
|
|
158
|
+
{ kind: 'form', ask: 'name' },
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A device the root minted may say what she is called: the root trusts
|
|
164
|
+
// it with its id and its reach already, and a being does not know her own
|
|
165
|
+
// key. A name is a word; she keeps the last one given.
|
|
166
|
+
name(args: JsonObject) {
|
|
167
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
168
|
+
if (!/^[\w.-]{1,80}$/.test(name)) return { error: 'a name is a word' };
|
|
169
|
+
this.cells.name = name;
|
|
170
|
+
return { named: name };
|
|
171
|
+
}
|
|
172
|
+
|
|
126
173
|
// A device that ran something for her says what it found. Kept, so that
|
|
127
174
|
// whoever renders her can show it; the ask itself is the callback.
|
|
128
175
|
report(args: JsonObject, asker: Asker) {
|
package/cli/daemon.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
55
55
|
import { tmpdir } from 'node:os';
|
|
56
56
|
import { join, resolve } from 'node:path';
|
|
57
57
|
import { createInterface } from 'node:readline';
|
|
58
|
-
import { isSilence, isWord, wordOf, type JsonObject } from '@quo-systems/quo';
|
|
58
|
+
import { isSilence, isWord, wordOf, type Invitation, type JsonObject } from '@quo-systems/quo';
|
|
59
59
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
60
60
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
61
61
|
import { DiskHarbor, type Hosted } from '../harbor/disk.ts';
|
|
@@ -77,7 +77,7 @@ export type Serving = { harbor: DiskHarbor; sock: string; side: string; http: Ht
|
|
|
77
77
|
// The agents this daemon runs, by client identity. From `<dir>/agents.json`
|
|
78
78
|
// on a device, or given.
|
|
79
79
|
export type Agents = Record<string, { command: string; args?: string[]; dir: string }>;
|
|
80
|
-
export type Options = { http?: { port: number; host?: string }; routes?: Routes; agents?: Agents; dial?: string[]
|
|
80
|
+
export type Options = { http?: { port: number; host?: string }; routes?: Routes; agents?: Agents; dial?: string[] };
|
|
81
81
|
|
|
82
82
|
// The sockets live in the harbor's folder, where the folder's own permissions
|
|
83
83
|
// guard them. A unix socket path is short by law, about a hundred bytes on
|
|
@@ -91,14 +91,11 @@ const socketPath = (dir: string, name: string): string => {
|
|
|
91
91
|
export const sockPath = (dir: string) => socketPath(dir, 'quo');
|
|
92
92
|
export const sidePath = (dir: string) => socketPath(dir, 'side');
|
|
93
93
|
|
|
94
|
-
// The nonces this daemon minted, honoured once each
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
94
|
+
// The nonces this daemon minted, honoured once each, for the one proof kind
|
|
95
|
+
// there is: `local`, a side that reached side.sock. That is the daemon
|
|
96
|
+
// vouching for a process it saw itself, by the device's own rule, file
|
|
97
|
+
// permissions on the socket; a proof arriving any other way names no nonce.
|
|
98
98
|
const NONCES = new Map<string, { kind: string; user: string; client: string; wake?: boolean; reach?: boolean }>();
|
|
99
|
-
// A nonce is minted for one kind and honoured under that kind alone: a tab's
|
|
100
|
-
// nonce offered as a `local` proof names no nonce, so a proof of a kind still
|
|
101
|
-
// arrives only the one way that kind is made.
|
|
102
99
|
const honour =
|
|
103
100
|
(kind: string): Verifier =>
|
|
104
101
|
(proof) => {
|
|
@@ -109,14 +106,10 @@ const honour =
|
|
|
109
106
|
return rest;
|
|
110
107
|
};
|
|
111
108
|
Desk.verifiers.local = honour('local');
|
|
112
|
-
Desk.verifiers.web = honour('web');
|
|
113
109
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
// device may wake her others, is read the first time only; to change it,
|
|
118
|
-
// remove the occupant and allow again. The one path for every side.
|
|
119
|
-
export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'web', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
110
|
+
// Her avatar in a ward, found or booted under the identity's key. An
|
|
111
|
+
// identity is a word, and never a being of the ward.
|
|
112
|
+
async function avatarOf(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
|
|
120
113
|
if (!/^[\w.-]+$/.test(identity) || identity === hosted.record.user || identity === 'desk') return { error: 'an identity is a word, and not a being of the ward' };
|
|
121
114
|
const key = `avatar:${identity}`;
|
|
122
115
|
let avatar = hosted.being(key) as Avatar | undefined;
|
|
@@ -125,12 +118,48 @@ export async function admit(hosted: Hosted, identity: string, kind: 'local' | 'w
|
|
|
125
118
|
if (out.error) return { error: out.error };
|
|
126
119
|
avatar = hosted.being(key) as Avatar;
|
|
127
120
|
}
|
|
121
|
+
return { avatar };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Admit a local client identity into a ward: find or boot her avatar, and
|
|
125
|
+
// enter her with a nonce the desk honours once. On a reconnect she already
|
|
126
|
+
// holds `user` and nothing is minted, so `wake` and `reach` are read the
|
|
127
|
+
// first time only; to change one, remove the occupant and admit again. The
|
|
128
|
+
// one path for a side on the device: agents, and sides on side.sock.
|
|
129
|
+
export async function admit(hosted: Hosted, identity: string, kind: 'local', wake = false, reach = false): Promise<{ avatar?: Avatar; error?: string }> {
|
|
130
|
+
const found = await avatarOf(hosted, identity);
|
|
131
|
+
if (!found.avatar) return found;
|
|
128
132
|
const nonce = randomBytes(16).toString('hex');
|
|
129
133
|
NONCES.set(nonce, { kind, user: hosted.record.user, client: identity, ...(wake ? { wake: true } : {}), ...(reach ? { reach: true } : {}) });
|
|
130
|
-
const entered = await avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
134
|
+
const entered = await found.avatar.enter({ ward: hosted.pk }, { kind, nonce });
|
|
131
135
|
NONCES.delete(nonce);
|
|
132
136
|
await hosted.save(); // the knock went through the ward's own door, which the harbor never sees
|
|
133
137
|
if ('error' in entered && !('asks' in entered)) return { error: entered.error };
|
|
138
|
+
return { avatar: found.avatar };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Join a client identity to a ward with an invitation the root minted for
|
|
142
|
+
// it: find or boot her avatar, and she knocks. The one path for an identity
|
|
143
|
+
// from elsewhere, the exchange page. Nothing is typed to get in but the
|
|
144
|
+
// invitation, and the knock spends it.
|
|
145
|
+
export async function letIn(hosted: Hosted, identity: string, invitation: Invitation): Promise<{ avatar?: Avatar; error?: string }> {
|
|
146
|
+
const found = await avatarOf(hosted, identity);
|
|
147
|
+
if (!found.avatar) return found;
|
|
148
|
+
const joined = await found.avatar.join(invitation);
|
|
149
|
+
await hosted.save();
|
|
150
|
+
if ('error' in joined && !('asks' in joined)) return { error: joined.error };
|
|
151
|
+
return { avatar: found.avatar };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Her avatar as she stands, for a session opening under a grant: she is in
|
|
155
|
+
// or she is not, and nothing is minted here. An identity the user being
|
|
156
|
+
// removed hears `removed` on her describe, which the endpoint speaks as 401.
|
|
157
|
+
export async function find(hosted: Hosted, identity: string): Promise<{ avatar?: Avatar; error?: string }> {
|
|
158
|
+
const avatar = hosted.being(`avatar:${identity}`) as Avatar | undefined;
|
|
159
|
+
if (!avatar) return { error: 'not joined' };
|
|
160
|
+
const bp = await avatar.tools();
|
|
161
|
+
await hosted.save();
|
|
162
|
+
if ('error' in bp && !('asks' in bp)) return { error: bp.error };
|
|
134
163
|
return { avatar };
|
|
135
164
|
}
|
|
136
165
|
|
|
@@ -173,22 +202,20 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
173
202
|
http = door;
|
|
174
203
|
http.mount('/quo', quoRoute(harbor, server as HttpServer, quo));
|
|
175
204
|
const routes = options.routes ?? (await readRoutes(harbor.dir));
|
|
176
|
-
const password = options.password ?? (() => process.env.QUO_OWNER_PASSWORD);
|
|
177
205
|
const here = `http://${http.host}:${http.port}`;
|
|
178
206
|
// the worlds' pages: always, on the daemon's own door when no route names a public one
|
|
179
207
|
const web = webRoute(harbor, { at: { quo: routes?.quo ?? `${here}/quo`, web: routes?.web ?? `${here}/web` } });
|
|
180
208
|
if (routes) {
|
|
181
|
-
// the worlds a client may be allowed into: every ward
|
|
209
|
+
// the worlds a client may be allowed into: every ward here, by name and pk, main first
|
|
182
210
|
const worlds = () =>
|
|
183
211
|
[...harbor.wards]
|
|
184
|
-
.filter(([, h]) => (h.partition as { public?: string | null }).public !== null)
|
|
185
212
|
.sort(([a], [b]) => (a === 'main' ? -1 : b === 'main' ? 1 : a.localeCompare(b)))
|
|
186
|
-
.map(([ward, h]) => ({ ward, user: h.record.user }));
|
|
213
|
+
.map(([ward, h]) => ({ ward, pk: h.pk, user: h.record.user }));
|
|
187
214
|
// the MCP endpoint: a bearer names an identity in a world, the identity names her avatar there, the side runs beside her
|
|
188
215
|
mcp = new McpHttp(
|
|
189
216
|
async (identity, ward) => {
|
|
190
217
|
const hosted = harbor.wards.get(ward);
|
|
191
|
-
return hosted ?
|
|
218
|
+
return hosted ? find(hosted, identity) : { error: 'no such world' };
|
|
192
219
|
},
|
|
193
220
|
async () => {
|
|
194
221
|
for (const h of harbor.wards.values()) await h.save();
|
|
@@ -200,12 +227,11 @@ export async function serve(dir: string, options: Options = {}): Promise<Serving
|
|
|
200
227
|
mcp.gone = (identity, ward) => oauth!.revoke(identity, ward); // removal at the ward ends the grant at the route
|
|
201
228
|
exchange = new Exchange({
|
|
202
229
|
oauth,
|
|
203
|
-
password,
|
|
204
230
|
worlds,
|
|
205
|
-
|
|
231
|
+
join: async (identity, invitation, ward) => {
|
|
206
232
|
const hosted = harbor.wards.get(ward);
|
|
207
233
|
if (!hosted) return { error: 'no such world' };
|
|
208
|
-
const r = await
|
|
234
|
+
const r = await letIn(hosted, identity, invitation);
|
|
209
235
|
return r.error ? { error: r.error } : {};
|
|
210
236
|
},
|
|
211
237
|
});
|
package/cli/estate/quo.service
CHANGED
|
@@ -14,7 +14,6 @@ User=quo
|
|
|
14
14
|
Group=quo
|
|
15
15
|
Environment=QUO_DIR=/home/quo/.quo
|
|
16
16
|
Environment=QUO_HTTP=8787
|
|
17
|
-
# QUO_OWNER_PASSWORD, root-only. Without it the exchange pages are closed.
|
|
18
17
|
# CLAUDE_CODE_OAUTH_TOKEN, when an agent folder runs `claude -p`: the agent
|
|
19
18
|
# reads its credential from the device, never from cells.
|
|
20
19
|
EnvironmentFile=-/etc/quo/env
|
package/dist/beings/avatar.js
CHANGED
|
@@ -55,8 +55,15 @@ export class Avatar extends Being {
|
|
|
55
55
|
// is taken all the same, and the way back she did not take is dropped, so
|
|
56
56
|
// any world's public being may admit a guest, hello or not.
|
|
57
57
|
async join(invitation) {
|
|
58
|
-
if (this.standings[USER])
|
|
59
|
-
|
|
58
|
+
if (this.standings[USER]) {
|
|
59
|
+
// A standing the far being removed is not one: she drops it and joins
|
|
60
|
+
// afresh, so an identity forgotten at the user being can be let in again.
|
|
61
|
+
const had = await this.tools();
|
|
62
|
+
if (!('error' in had) || had.error !== 'removed')
|
|
63
|
+
return had;
|
|
64
|
+
this.standings.remove(USER);
|
|
65
|
+
this.occupants.remove(PUSHER);
|
|
66
|
+
}
|
|
60
67
|
const mine = await this.invite(PUSHER);
|
|
61
68
|
if (mine === null)
|
|
62
69
|
return { error: 'user is taken' };
|
package/dist/beings/carry.js
CHANGED
|
@@ -58,8 +58,8 @@ export class Carrier extends Being {
|
|
|
58
58
|
continue;
|
|
59
59
|
const names = [];
|
|
60
60
|
for (const a of rec.blueprint.asks) {
|
|
61
|
-
if (a.name === 'look')
|
|
62
|
-
continue;
|
|
61
|
+
if (a.name === 'look' || a.name === 'page')
|
|
62
|
+
continue; // presentation is hers, never carried as an ask
|
|
63
63
|
const name = `${id}-${a.name}`;
|
|
64
64
|
if (own.has(name))
|
|
65
65
|
continue;
|
package/dist/beings/setup.js
CHANGED
|
@@ -13,7 +13,8 @@ export async function setup(hosted, user) {
|
|
|
13
13
|
throw new Error(`init: ${pub.error}`);
|
|
14
14
|
await boot({ key: user, class: 'User' });
|
|
15
15
|
const inv = (await hosted.ask('invite', { being: user, id: DESK }));
|
|
16
|
-
|
|
16
|
+
// the desk's first hello names her, since a being does not know her own key
|
|
17
|
+
const placed = (await hosted.ask('knock', { being: 'desk', id: `user:${user}`, invitation: inv, method: 'hello', args: { name: user } }));
|
|
17
18
|
if (placed.taken !== `user:${user}`)
|
|
18
19
|
throw new Error('init: the desk could not reach the user being');
|
|
19
20
|
}
|
package/dist/beings/user.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Carrier } from './carry.ts';
|
|
|
3
3
|
export declare const DESK = "desk";
|
|
4
4
|
declare const isDesk: (occ: OccupantRecord | undefined) => boolean;
|
|
5
5
|
declare const isDevice: (occ: OccupantRecord | undefined) => boolean;
|
|
6
|
+
declare const rootMinted: (occ: OccupantRecord | undefined) => boolean;
|
|
6
7
|
declare const mayWake: (occ: OccupantRecord | undefined) => boolean;
|
|
7
8
|
export declare class User extends Carrier {
|
|
8
9
|
static carries(occ: OccupantRecord | undefined): boolean;
|
|
@@ -72,6 +73,33 @@ export declare class User extends Carrier {
|
|
|
72
73
|
};
|
|
73
74
|
for: (occ: OccupantRecord | undefined) => boolean;
|
|
74
75
|
};
|
|
76
|
+
look: {
|
|
77
|
+
description: string;
|
|
78
|
+
input: {
|
|
79
|
+
type: string;
|
|
80
|
+
};
|
|
81
|
+
for: typeof isDevice;
|
|
82
|
+
};
|
|
83
|
+
page: {
|
|
84
|
+
description: string;
|
|
85
|
+
input: {
|
|
86
|
+
type: string;
|
|
87
|
+
};
|
|
88
|
+
for: typeof isDevice;
|
|
89
|
+
};
|
|
90
|
+
name: {
|
|
91
|
+
description: string;
|
|
92
|
+
input: {
|
|
93
|
+
type: string;
|
|
94
|
+
properties: {
|
|
95
|
+
name: {
|
|
96
|
+
type: string;
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
required: string[];
|
|
100
|
+
};
|
|
101
|
+
for: typeof rootMinted;
|
|
102
|
+
};
|
|
75
103
|
forget: {
|
|
76
104
|
description: string;
|
|
77
105
|
input: {
|
|
@@ -127,6 +155,67 @@ export declare class User extends Carrier {
|
|
|
127
155
|
chores(): {
|
|
128
156
|
chores: string[];
|
|
129
157
|
};
|
|
158
|
+
look(): {
|
|
159
|
+
name: string;
|
|
160
|
+
order: string[];
|
|
161
|
+
asks: {
|
|
162
|
+
whoami: {
|
|
163
|
+
title: string;
|
|
164
|
+
readOnly: boolean;
|
|
165
|
+
};
|
|
166
|
+
push: {
|
|
167
|
+
title: string;
|
|
168
|
+
};
|
|
169
|
+
name: {
|
|
170
|
+
title: string;
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
page(): {
|
|
175
|
+
kind: string;
|
|
176
|
+
of: ({
|
|
177
|
+
kind: string;
|
|
178
|
+
text: string;
|
|
179
|
+
role: string;
|
|
180
|
+
of?: undefined;
|
|
181
|
+
ask?: undefined;
|
|
182
|
+
} | {
|
|
183
|
+
kind: string;
|
|
184
|
+
of: ({
|
|
185
|
+
kind: string;
|
|
186
|
+
text: string;
|
|
187
|
+
role: string;
|
|
188
|
+
ask?: undefined;
|
|
189
|
+
} | {
|
|
190
|
+
text?: undefined;
|
|
191
|
+
role?: undefined;
|
|
192
|
+
kind: string;
|
|
193
|
+
ask: string;
|
|
194
|
+
})[];
|
|
195
|
+
text?: undefined;
|
|
196
|
+
role?: undefined;
|
|
197
|
+
ask?: undefined;
|
|
198
|
+
} | {
|
|
199
|
+
of?: undefined;
|
|
200
|
+
text?: undefined;
|
|
201
|
+
role?: undefined;
|
|
202
|
+
kind: string;
|
|
203
|
+
ask?: undefined;
|
|
204
|
+
} | {
|
|
205
|
+
of?: undefined;
|
|
206
|
+
text?: undefined;
|
|
207
|
+
role?: undefined;
|
|
208
|
+
kind: string;
|
|
209
|
+
ask: string;
|
|
210
|
+
})[];
|
|
211
|
+
};
|
|
212
|
+
name(args: JsonObject): {
|
|
213
|
+
error: string;
|
|
214
|
+
named?: undefined;
|
|
215
|
+
} | {
|
|
216
|
+
error?: undefined;
|
|
217
|
+
named: string;
|
|
218
|
+
};
|
|
130
219
|
report(args: JsonObject, asker: Asker): {
|
|
131
220
|
reported: boolean;
|
|
132
221
|
};
|