@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/dist/mcp/oauth.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type Client = {
|
|
|
6
6
|
client_id: string;
|
|
7
7
|
client_name: string;
|
|
8
8
|
redirect_uris: string[];
|
|
9
|
+
exp: number;
|
|
9
10
|
};
|
|
10
11
|
export type Pending = {
|
|
11
12
|
client_id: string;
|
|
@@ -17,6 +18,7 @@ export type Pending = {
|
|
|
17
18
|
};
|
|
18
19
|
export type Grant = {
|
|
19
20
|
identity: string;
|
|
21
|
+
ward: string;
|
|
20
22
|
client_id: string;
|
|
21
23
|
exp: number;
|
|
22
24
|
};
|
|
@@ -25,6 +27,7 @@ export type Store = {
|
|
|
25
27
|
pending: Record<string, Pending>;
|
|
26
28
|
codes: Record<string, Pending & {
|
|
27
29
|
identity: string;
|
|
30
|
+
ward: string;
|
|
28
31
|
}>;
|
|
29
32
|
access: Record<string, Grant>;
|
|
30
33
|
refresh: Record<string, Grant>;
|
|
@@ -62,6 +65,7 @@ export declare class OAuth {
|
|
|
62
65
|
register(body: unknown): Promise<Client | {
|
|
63
66
|
error: string;
|
|
64
67
|
}>;
|
|
68
|
+
client(id: string): Client | undefined;
|
|
65
69
|
authorize(q: URLSearchParams): Promise<{
|
|
66
70
|
redirect: string;
|
|
67
71
|
} | {
|
|
@@ -70,7 +74,7 @@ export declare class OAuth {
|
|
|
70
74
|
pending(id: string): (Pending & {
|
|
71
75
|
client: Client;
|
|
72
76
|
}) | null;
|
|
73
|
-
complete(id: string, identity: string): Promise<{
|
|
77
|
+
complete(id: string, identity: string, ward?: string): Promise<{
|
|
74
78
|
redirect: string;
|
|
75
79
|
} | {
|
|
76
80
|
error: string;
|
|
@@ -81,15 +85,18 @@ export declare class OAuth {
|
|
|
81
85
|
error: string;
|
|
82
86
|
}>;
|
|
83
87
|
token(body: URLSearchParams): Promise<Record<string, unknown>>;
|
|
84
|
-
issue(identity: string, client_id: string): Promise<{
|
|
88
|
+
issue(identity: string, ward: string, client_id: string): Promise<{
|
|
85
89
|
access_token: string;
|
|
86
90
|
token_type: string;
|
|
87
91
|
expires_in: number;
|
|
88
92
|
refresh_token: string;
|
|
89
93
|
scope: string;
|
|
90
94
|
}>;
|
|
91
|
-
bearer(req: IncomingMessage):
|
|
92
|
-
|
|
95
|
+
bearer(req: IncomingMessage): {
|
|
96
|
+
identity: string;
|
|
97
|
+
ward: string;
|
|
98
|
+
} | null;
|
|
99
|
+
revoke(identity: string, ward?: string): Promise<void>;
|
|
93
100
|
sweep(): void;
|
|
94
101
|
handle(req: IncomingMessage, res: ServerResponse, rest: string): Promise<boolean>;
|
|
95
102
|
challenge(res: ServerResponse): void;
|
package/dist/mcp/oauth.js
CHANGED
|
@@ -60,15 +60,22 @@ export class OAuth {
|
|
|
60
60
|
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)) : [];
|
|
61
61
|
if (uris.length === 0)
|
|
62
62
|
return { error: 'invalid_redirect_uri' };
|
|
63
|
-
const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris };
|
|
63
|
+
const client = { client_id: token(), client_name: typeof b.client_name === 'string' ? b.client_name.slice(0, 80) : 'client', redirect_uris: uris, exp: this.now() + CODE_TTL };
|
|
64
64
|
this.store.clients[client.client_id] = client;
|
|
65
|
+
this.sweep();
|
|
65
66
|
await this.o.persist(this.store);
|
|
66
|
-
|
|
67
|
+
const { exp: _, ...shown } = client;
|
|
68
|
+
return shown;
|
|
69
|
+
}
|
|
70
|
+
// A client still alive, by id.
|
|
71
|
+
client(id) {
|
|
72
|
+
const c = own(this.store.clients, id);
|
|
73
|
+
return c && c.exp >= this.now() ? c : undefined;
|
|
67
74
|
}
|
|
68
75
|
// Start a request. What comes back is where to send the browser: the web
|
|
69
76
|
// route with the request id, or the client's redirect with an error.
|
|
70
77
|
async authorize(q) {
|
|
71
|
-
const client =
|
|
78
|
+
const client = this.client(q.get('client_id') ?? '');
|
|
72
79
|
const redirect = q.get('redirect_uri') ?? client?.redirect_uris[0] ?? null;
|
|
73
80
|
if (!client || redirect === null || !client.redirect_uris.includes(redirect))
|
|
74
81
|
return { error: 'invalid_client' };
|
|
@@ -89,17 +96,18 @@ export class OAuth {
|
|
|
89
96
|
const p = own(this.store.pending, id);
|
|
90
97
|
if (!p || p.exp < this.now())
|
|
91
98
|
return null;
|
|
92
|
-
const client =
|
|
99
|
+
const client = this.client(p.client_id);
|
|
93
100
|
return client ? { ...p, client } : null;
|
|
94
101
|
}
|
|
95
|
-
// The web route's last step: the human allowed this client as this
|
|
96
|
-
|
|
102
|
+
// The web route's last step: the human allowed this client as this
|
|
103
|
+
// identity, into this world.
|
|
104
|
+
async complete(id, identity, ward = 'main') {
|
|
97
105
|
const p = this.pending(id);
|
|
98
106
|
if (!p)
|
|
99
107
|
return { error: 'expired' };
|
|
100
108
|
delete this.store.pending[id];
|
|
101
109
|
const code = token();
|
|
102
|
-
this.store.codes[code] = { ...p, identity, exp: this.now() + CODE_TTL };
|
|
110
|
+
this.store.codes[code] = { ...p, identity, ward, exp: this.now() + CODE_TTL };
|
|
103
111
|
await this.o.persist(this.store);
|
|
104
112
|
return { redirect: withQuery(p.redirect_uri, { code, state: p.state }) };
|
|
105
113
|
}
|
|
@@ -124,7 +132,7 @@ export class OAuth {
|
|
|
124
132
|
await this.o.persist(this.store);
|
|
125
133
|
return { error: 'invalid_grant' };
|
|
126
134
|
}
|
|
127
|
-
return this.issue(c.identity, c.client_id);
|
|
135
|
+
return this.issue(c.identity, c.ward ?? 'main', c.client_id);
|
|
128
136
|
}
|
|
129
137
|
if (grant === 'refresh_token') {
|
|
130
138
|
const rt = body.get('refresh_token') ?? '';
|
|
@@ -132,40 +140,43 @@ export class OAuth {
|
|
|
132
140
|
if (!r || r.exp < this.now())
|
|
133
141
|
return { error: 'invalid_grant' };
|
|
134
142
|
delete this.store.refresh[rt]; // rotated: the old one is gone with the new one's birth
|
|
135
|
-
return this.issue(r.identity, r.client_id);
|
|
143
|
+
return this.issue(r.identity, r.ward ?? 'main', r.client_id);
|
|
136
144
|
}
|
|
137
145
|
return { error: 'unsupported_grant_type' };
|
|
138
146
|
}
|
|
139
|
-
async issue(identity, client_id) {
|
|
147
|
+
async issue(identity, ward, client_id) {
|
|
140
148
|
const access_token = token(), refresh_token = token();
|
|
141
|
-
this.store.access[access_token] = { identity, client_id, exp: this.now() + ACCESS_TTL };
|
|
142
|
-
this.store.refresh[refresh_token] = { identity, client_id, exp: this.now() + REFRESH_TTL };
|
|
149
|
+
this.store.access[access_token] = { identity, ward, client_id, exp: this.now() + ACCESS_TTL };
|
|
150
|
+
this.store.refresh[refresh_token] = { identity, ward, client_id, exp: this.now() + REFRESH_TTL };
|
|
151
|
+
const c = own(this.store.clients, client_id);
|
|
152
|
+
if (c)
|
|
153
|
+
c.exp = this.now() + REFRESH_TTL; // allowed: the client lives as long as what it was granted
|
|
143
154
|
this.sweep();
|
|
144
155
|
await this.o.persist(this.store);
|
|
145
156
|
return { access_token, token_type: 'Bearer', expires_in: ACCESS_TTL / 1000, refresh_token, scope: 'quo' };
|
|
146
157
|
}
|
|
147
|
-
// The bearer on an MCP request, to a client identity. Null is 401.
|
|
158
|
+
// The bearer on an MCP request, to a client identity in a world. Null is 401.
|
|
148
159
|
bearer(req) {
|
|
149
160
|
const h = req.headers.authorization ?? '';
|
|
150
161
|
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
151
162
|
const g = own(this.store.access, t);
|
|
152
|
-
return g && g.exp >= this.now() && typeof g.identity === 'string' ? g.identity : null;
|
|
163
|
+
return g && g.exp >= this.now() && typeof g.identity === 'string' ? { identity: g.identity, ward: g.ward ?? 'main' } : null;
|
|
153
164
|
}
|
|
154
|
-
// Every grant an identity holds, gone: the route's half of
|
|
155
|
-
// The other half is the user being removing the occupant.
|
|
156
|
-
async revoke(identity) {
|
|
165
|
+
// Every grant an identity holds in a world, gone: the route's half of
|
|
166
|
+
// revocation. The other half is the user being removing the occupant.
|
|
167
|
+
async revoke(identity, ward = 'main') {
|
|
157
168
|
for (const k of ['access', 'refresh'])
|
|
158
169
|
for (const [t, g] of Object.entries(this.store[k]))
|
|
159
|
-
if (g.identity === identity)
|
|
170
|
+
if (g.identity === identity && (g.ward ?? 'main') === ward)
|
|
160
171
|
delete this.store[k][t];
|
|
161
172
|
await this.o.persist(this.store);
|
|
162
173
|
}
|
|
163
174
|
sweep() {
|
|
164
175
|
const now = this.now();
|
|
165
|
-
for (const k of ['pending', 'codes', 'access', 'refresh'])
|
|
176
|
+
for (const k of ['clients', 'pending', 'codes', 'access', 'refresh'])
|
|
166
177
|
for (const [t, g] of Object.entries(this.store[k]))
|
|
167
|
-
if (g.exp
|
|
168
|
-
delete this.store[k][t];
|
|
178
|
+
if (!(g.exp >= now))
|
|
179
|
+
delete this.store[k][t]; // a record with no exp is from before there was one, and goes too
|
|
169
180
|
}
|
|
170
181
|
// The HTTP face. `rest` is the path under the route.
|
|
171
182
|
async handle(req, res, rest) {
|
package/dist/mcp/pilot.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
-
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
2
|
import type { Serving } from '../beings/side.ts';
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
export declare function pilotSide(dir: string, ward: string, transport: Transport,
|
|
3
|
+
import { type Subject } from './server.ts';
|
|
4
|
+
export declare function owner(dir: string, ward: string, via?: string): Subject;
|
|
5
|
+
export declare function pilotSide(dir: string, ward: string, transport: Transport, via?: string): Promise<Serving>;
|
package/dist/mcp/pilot.js
CHANGED
|
@@ -1,68 +1,22 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The owner pilot: `quo pilot`. An MCP server over stdio that reaches a
|
|
3
|
-
// ward's owner asks through the daemon's root socket, and exposes four tools
|
|
4
|
-
// and no more, plus the one read. Whoever runs this process is the owner, by
|
|
5
|
-
// the device's own rules: the root of the ward here, or, with `via`, an
|
|
6
|
-
// owner at another ward's door, on a standing the user being here holds
|
|
7
|
-
// there. Every call is logged with what it made. The owner creates and
|
|
8
|
-
// places; the work goes through `quo side`, under a gate.
|
|
9
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
1
|
import { ask } from '../cli/client.js';
|
|
12
|
-
|
|
13
|
-
{
|
|
14
|
-
name: 'census',
|
|
15
|
-
description: 'the empty ask: the ward pk and every being, with class, public and digest',
|
|
16
|
-
inputSchema: { type: 'object', properties: {} },
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
name: 'boot',
|
|
20
|
-
description: 'boot a being by class name under a key; public marks the one public being',
|
|
21
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string' }, class: { type: 'string' }, public: { type: 'boolean' } }, required: ['key', 'class'] },
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
name: 'invite',
|
|
25
|
-
description: 'mint an invitation on a being of the ward, under an id she will know the occupant by',
|
|
26
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
name: 'knock',
|
|
30
|
-
description: 'knock for a being of the ward with an invitation, and take the standing under id if answered',
|
|
31
|
-
inputSchema: {
|
|
32
|
-
type: 'object',
|
|
33
|
-
properties: {
|
|
34
|
-
being: { description: 'a key already booted, or { boot: class, key } to boot her first' },
|
|
35
|
-
id: { type: 'string' },
|
|
36
|
-
invitation: { type: 'object' },
|
|
37
|
-
method: { type: 'string' },
|
|
38
|
-
args: { type: 'object' },
|
|
39
|
-
wanted: { type: 'object', properties: { time: { type: 'number' } } },
|
|
40
|
-
},
|
|
41
|
-
required: ['being', 'id', 'invitation'],
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: 'remove',
|
|
46
|
-
description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk, an owner, by the root alone',
|
|
47
|
-
inputSchema: { type: 'object', properties: { being: { type: 'string' }, id: { type: 'string' } }, required: ['being', 'id'] },
|
|
48
|
-
},
|
|
49
|
-
];
|
|
2
|
+
import { mcpSide } from './server.js';
|
|
50
3
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
|
|
4
|
+
// The owner hears objects: the socket's own failure, a silence and a word
|
|
5
|
+
// each come back as an error object named for what it was.
|
|
6
|
+
export function owner(dir, ward, via) {
|
|
7
|
+
const one = async (method, args) => {
|
|
8
|
+
const out = await ask(dir, method, args, ward, via);
|
|
9
|
+
if ('error' in out)
|
|
10
|
+
return { error: out.error };
|
|
11
|
+
const r = out.result;
|
|
12
|
+
if (isRecord(r) && r.silence === true)
|
|
13
|
+
return { error: 'silence' };
|
|
14
|
+
if (isRecord(r) && typeof r.word === 'string')
|
|
15
|
+
return { error: r.word };
|
|
16
|
+
return r;
|
|
17
|
+
};
|
|
18
|
+
return { tools: () => one(undefined, {}), call: (name, args) => one(name, args ?? {}), ears: new Set() };
|
|
19
|
+
}
|
|
20
|
+
export function pilotSide(dir, ward, transport, via) {
|
|
21
|
+
return mcpSide(owner(dir, ward, via), transport);
|
|
68
22
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
2
|
import { type Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import type { Blueprint } from '@quo-systems/quo';
|
|
4
|
-
import type
|
|
5
|
-
import { type
|
|
4
|
+
import { type Serving, type Subject } from '../beings/side.ts';
|
|
5
|
+
import { type Look } from '../beings/look.ts';
|
|
6
6
|
export declare const NAME = "quo";
|
|
7
7
|
export declare const VERSION = "0.0.0";
|
|
8
|
-
export
|
|
9
|
-
export declare
|
|
8
|
+
export type { Subject } from '../beings/side.ts';
|
|
9
|
+
export declare const DESCRIBE: Tool;
|
|
10
|
+
export declare function tools(bp: Blueprint, look?: Look): Tool[];
|
|
11
|
+
export declare function mcpSide(avatar: Subject, transport: Transport, after?: () => Promise<void>): Promise<Serving>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,39 +1,76 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// The model side as an MCP server: one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// The model side as an MCP server: one subject, one transport. The subject
|
|
3
|
+
// is whoever answers the empty ask and a named one: an avatar on her one
|
|
4
|
+
// standing, or a ward's owner asks through the pilot. The mapping is total
|
|
5
|
+
// and needs nothing beyond this envelope. tools/list is her describe, and
|
|
6
|
+
// the empty ask itself is the first tool, so the notes of the describe are
|
|
7
|
+
// readable; tools/call is a named ask, a push is a logging notification,
|
|
8
|
+
// and the three words for "no object" each cross as the table in
|
|
9
|
+
// quo-mcp.md says. The transport is whatever the caller connected: the
|
|
7
10
|
// SDK's in-memory pair in a test, stdio for a local client, HTTP on a route.
|
|
8
11
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
12
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
10
13
|
import { word, wordText, SILENCE_TEXT, UNREACHED_TEXT } from '../beings/side.js';
|
|
14
|
+
import { hintFor, sanitise } from '../beings/look.js';
|
|
15
|
+
import { isSilence, isWord, digest } from '@quo-systems/quo';
|
|
11
16
|
export const NAME = 'quo';
|
|
12
17
|
export const VERSION = '0.0.0';
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
// The empty ask as a tool: her describe whole, asks and notes.
|
|
19
|
+
export const DESCRIBE = { name: 'describe', description: 'the empty ask: her describe, the asks and the notes', inputSchema: { type: 'object' } };
|
|
20
|
+
// Her describe, spoken as tools, the empty ask first. Name, description and
|
|
21
|
+
// input are verbatim; an output schema crosses when she declared one. Her
|
|
22
|
+
// look, when she has one, is the hints: a title, and the annotations a host
|
|
23
|
+
// reads. The `look` ask itself is presentation, and is not a tool.
|
|
24
|
+
export function tools(bp, look = {}) {
|
|
25
|
+
const asks = bp.asks
|
|
26
|
+
.filter((a) => a.name !== 'look')
|
|
27
|
+
.map((a) => {
|
|
17
28
|
const t = { name: a.name, inputSchema: { ...a.input, type: 'object' } };
|
|
18
29
|
if (a.description !== undefined)
|
|
19
30
|
t.description = a.description;
|
|
20
31
|
if (a.output !== undefined)
|
|
21
32
|
t.outputSchema = { ...a.output, type: 'object' };
|
|
33
|
+
const h = hintFor(bp, look, a.name);
|
|
34
|
+
if (h.title !== undefined)
|
|
35
|
+
t.title = h.title;
|
|
36
|
+
const notes = {};
|
|
37
|
+
if (h.readOnly !== undefined)
|
|
38
|
+
notes.readOnlyHint = h.readOnly;
|
|
39
|
+
if (h.destructive !== undefined)
|
|
40
|
+
notes.destructiveHint = h.destructive;
|
|
41
|
+
if (h.idempotent !== undefined)
|
|
42
|
+
notes.idempotentHint = h.idempotent;
|
|
43
|
+
if (Object.keys(notes).length)
|
|
44
|
+
t.annotations = notes;
|
|
22
45
|
return t;
|
|
23
46
|
});
|
|
47
|
+
return [DESCRIBE, ...asks];
|
|
24
48
|
}
|
|
25
49
|
// `after` runs when a call is done: a harbor that must write what the ward
|
|
26
50
|
// changed hooks it, since a same-ward ask never crosses the harbor.
|
|
27
51
|
export async function mcpSide(avatar, transport, after = async () => { }) {
|
|
28
52
|
const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: { listChanged: true }, logging: {} } });
|
|
53
|
+
// Her look, asked once per digest of her describe.
|
|
54
|
+
let seen = null;
|
|
55
|
+
let look = {};
|
|
29
56
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
30
57
|
const bp = await avatar.tools();
|
|
31
58
|
if ('error' in bp && !('asks' in bp))
|
|
32
59
|
return { tools: [] }; // not joined: nothing to show, and nothing to call
|
|
33
|
-
|
|
60
|
+
const d = await digest(bp);
|
|
61
|
+
if (d !== seen) {
|
|
62
|
+
seen = d;
|
|
63
|
+
if (bp.asks.some((a) => a.name === 'look')) {
|
|
64
|
+
const l = await avatar.call('look', {});
|
|
65
|
+
look = isSilence(l) || isWord(l) ? {} : sanitise(l);
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
look = {};
|
|
69
|
+
}
|
|
70
|
+
return { tools: tools(bp, look) };
|
|
34
71
|
});
|
|
35
72
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
36
|
-
const w = word(await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
73
|
+
const w = word(req.params.name === DESCRIBE.name ? await avatar.tools() : await avatar.call(req.params.name, (req.params.arguments ?? {})));
|
|
37
74
|
await after();
|
|
38
75
|
if (w.word === 'object') {
|
|
39
76
|
const structured = w.value !== null && typeof w.value === 'object' && !Array.isArray(w.value) ? { structuredContent: w.value } : {};
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { OAuth } from '../oauth.ts';
|
|
3
3
|
export declare const SESSION_TTL: number;
|
|
4
|
-
export type Admit = (identity: string, wake: boolean) => Promise<{
|
|
4
|
+
export type Admit = (identity: string, wake: boolean, reach: boolean, ward: string) => Promise<{
|
|
5
5
|
error?: string;
|
|
6
6
|
}>;
|
|
7
7
|
export type Options = {
|
|
8
8
|
oauth: OAuth;
|
|
9
9
|
password: () => string | undefined;
|
|
10
10
|
admit: Admit;
|
|
11
|
-
|
|
11
|
+
worlds: () => {
|
|
12
|
+
ward: string;
|
|
13
|
+
user: string;
|
|
14
|
+
}[];
|
|
12
15
|
now?: () => number;
|
|
13
16
|
};
|
|
14
17
|
export declare const suggest: (name: string) => string;
|
package/dist/mcp/web/exchange.js
CHANGED
|
@@ -90,7 +90,7 @@ export class Exchange {
|
|
|
90
90
|
return page(400, `<h1>Nothing to allow</h1><p>This request is gone.</p>`), true;
|
|
91
91
|
if (!this.valid(req))
|
|
92
92
|
return go(`/login?request=${encodeURIComponent(request)}`), true;
|
|
93
|
-
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.
|
|
93
|
+
return page(200, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), this.o.worlds())), true;
|
|
94
94
|
}
|
|
95
95
|
if (rest === '/allow' && req.method === 'POST') {
|
|
96
96
|
const f = await readForm(req);
|
|
@@ -104,13 +104,17 @@ export class Exchange {
|
|
|
104
104
|
const out = await this.o.oauth.deny(request);
|
|
105
105
|
return 'redirect' in out ? go(out.redirect) : page(400, `<h1>Gone</h1>`), true;
|
|
106
106
|
}
|
|
107
|
+
const worlds = this.o.worlds();
|
|
108
|
+
const world = worlds.find((w) => w.ward === (f.get('ward') ?? worlds[0]?.ward));
|
|
109
|
+
if (!world)
|
|
110
|
+
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'That is not a world of this harbor.')), true;
|
|
107
111
|
const identity = word(f.get('identity'));
|
|
108
|
-
if (identity === null || identity ===
|
|
109
|
-
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name),
|
|
110
|
-
const admitted = await this.o.admit(identity, f.get('wake') === 'on');
|
|
112
|
+
if (identity === null || identity === world.user || identity === 'desk')
|
|
113
|
+
return page(400, allowForm(request, p.client.client_name, p.redirect_uri, suggest(p.client.client_name), worlds, 'An identity is one word, and not the user or the desk.')), true;
|
|
114
|
+
const admitted = await this.o.admit(identity, f.get('wake') === 'on', f.get('reach') === 'on', world.ward);
|
|
111
115
|
if (admitted.error)
|
|
112
116
|
return page(500, `<h1>Not admitted</h1><p>${esc(admitted.error)}</p>`), true;
|
|
113
|
-
const out = await this.o.oauth.complete(request, identity);
|
|
117
|
+
const out = await this.o.oauth.complete(request, identity, world.ward);
|
|
114
118
|
return 'redirect' in out ? go(out.redirect, { 'set-cookie': `${COOKIE}=; Path=/; Max-Age=0` }) : page(400, `<h1>Gone</h1>`), true;
|
|
115
119
|
}
|
|
116
120
|
return false;
|
|
@@ -125,12 +129,22 @@ ${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
|
125
129
|
<form method="post" action="/login"><input type="hidden" name="request" value="${esc(request)}">
|
|
126
130
|
<label for="p">Owner password</label><input id="p" name="password" type="password" autocomplete="current-password" autofocus required>
|
|
127
131
|
<button type="submit">Log in</button></form>`;
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// The world is a choice when the harbor has more than one; the user named
|
|
133
|
+
// is the first world's, and the page says which world each identity lands in.
|
|
134
|
+
const allowForm = (request, client, redirect, identity, worlds, err = '') => {
|
|
135
|
+
const user = worlds[0]?.user ?? '';
|
|
136
|
+
const pick = worlds.length > 1
|
|
137
|
+
? `<label for="wd">World</label><select id="wd" name="ward">${worlds.map((w) => `<option value="${esc(w.ward)}">${esc(w.ward)}, ${esc(w.user)}'s</option>`).join('')}</select>`
|
|
138
|
+
: `<input type="hidden" name="ward" value="${esc(worlds[0]?.ward ?? 'main')}">`;
|
|
139
|
+
return `<h1>Allow ${esc(client)}?</h1>
|
|
140
|
+
<div class="who"><p><strong>${esc(client)}</strong> asks to be an occupant of <strong>${esc(user)}</strong>${worlds.length > 1 ? ', or of another world below' : ''}.</p>
|
|
130
141
|
<p>It will see exactly what ${esc(user)} shows the identity below, and nothing else. You can remove it any time.</p>
|
|
131
142
|
<p>It returns to <code>${esc(redirect)}</code>.</p></div>
|
|
132
143
|
${err ? `<p class="err">${esc(err)}</p>` : ''}
|
|
133
144
|
<form method="post" action="/allow"><input type="hidden" name="request" value="${esc(request)}">
|
|
145
|
+
${pick}
|
|
134
146
|
<label for="i">Identity</label><input id="i" name="identity" value="${esc(identity)}" pattern="[\\w.-]{1,40}" required>
|
|
147
|
+
<label for="r"><input id="r" name="reach" type="checkbox" style="width:auto"> May reach what ${esc(user)} holds: her standings, acme and the rest, as asks of hers</label>
|
|
135
148
|
<label for="w"><input id="w" name="wake" type="checkbox" style="width:auto"> May wake your other devices: hand an agent an event through ${esc(user)}</label>
|
|
136
149
|
<button type="submit" name="decision" value="allow">Allow</button><button type="submit" name="decision" value="deny">Deny</button></form>`;
|
|
150
|
+
};
|
package/harbor/edge/exercise.ts
CHANGED
|
@@ -70,7 +70,8 @@ function probe(base: string, state: State, env: Env): () => Promise<World> {
|
|
|
70
70
|
learn(i, w);
|
|
71
71
|
}
|
|
72
72
|
wardOf.set(key, name);
|
|
73
|
-
assert.deepEqual(await w.ask('boot', { key, class: Class.name
|
|
73
|
+
assert.deepEqual(await w.ask('boot', { key, class: Class.name }), { booted: key });
|
|
74
|
+
if (isPublic) assert.deepEqual(await w.ask('public', { key }), { public: key });
|
|
74
75
|
if (Object.keys(cells).length) Object.assign(at(name).p.beings[key]!, cells);
|
|
75
76
|
return {
|
|
76
77
|
pk: w.pk,
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -88,6 +88,38 @@ the new harbor pointed at the same code. Same seed, same pk, every standing
|
|
|
88
88
|
anyone holds still points at her. The old harbor deletes its copies first:
|
|
89
89
|
two harbors holding one seed are two wards with one pk, diverging silently.
|
|
90
90
|
|
|
91
|
+
What a move costs is not the three parts, which are bytes, but how many
|
|
92
|
+
peers wrote down where the ward was. A pk is permanent by arithmetic; a
|
|
93
|
+
location is soft state in every other harbor's directory, and only that goes
|
|
94
|
+
stale. So the bill is proportional to who hard-coded it, and there are two
|
|
95
|
+
kinds of ward.
|
|
96
|
+
|
|
97
|
+
A ward whose harbor dials out is free. It announces what it holds when a
|
|
98
|
+
line opens and whenever that changes, the listener proves each claim at the
|
|
99
|
+
door and binds it, and a ward that arrives on a new box is reachable as soon
|
|
100
|
+
as its harbor dials. Nobody is told and no peer acts.
|
|
101
|
+
|
|
102
|
+
A ward that is dialed keeps a second name. Peers hold a hint, pk at a URL,
|
|
103
|
+
in their own stores and across their own restarts, so that URL is a promise
|
|
104
|
+
the same way the pk is. Moving boxes does not break it, because a hostname
|
|
105
|
+
is not a location: DNS and the proxy point it wherever the harbor now runs,
|
|
106
|
+
which is the whole reason the route is a hostname and not an address.
|
|
107
|
+
Changing the URL itself is the move that costs, and it cannot be announced
|
|
108
|
+
to a peer this ward cannot reach.
|
|
109
|
+
|
|
110
|
+
When it must change, what is republished is a hint and never an invitation.
|
|
111
|
+
The relation, its keys and its count are untouched, and the invitation is
|
|
112
|
+
as good as it was: that is why the two travel separately, a link being one
|
|
113
|
+
next to the other. Peers on a rendezvous this ward also dials relearn by
|
|
114
|
+
themselves, since a binding is proven at the door and names no URL; peers
|
|
115
|
+
holding a hint need a new one, and a hint is not a capability, so it may be
|
|
116
|
+
published in the open to all of them at once. Nobody is invited twice.
|
|
117
|
+
|
|
118
|
+
The rule, then: a dialable ward keeps two names and owes the second the same
|
|
119
|
+
care as the first, and a ward that only dials keeps one, which is why a
|
|
120
|
+
laptop, a tab and a phone move home without ceremony and the droplet does
|
|
121
|
+
not.
|
|
122
|
+
|
|
91
123
|
### Code is a harbor decision
|
|
92
124
|
|
|
93
125
|
A ward's code has an origin: a folder the owner wrote, a bundle a world
|
|
@@ -235,6 +267,11 @@ request POST <url>/<pk>, the sealed ask as the body, the sealed reply as th
|
|
|
235
267
|
<url> is the world's quo. route, which the proxy maps onto the daemon's /quo
|
|
236
268
|
404 is "no reach for that pk" and comes back as nothing; a line lost after
|
|
237
269
|
sending answers nothing at all, and the ward's bound ends the ask
|
|
270
|
+
at most 1 MiB is read, and a body over it is answered by that same 404: an
|
|
271
|
+
ask too big for this door is nothing delivered, never a silence, so it is
|
|
272
|
+
safe to send again and it will fail again. The number is this daemon's and
|
|
273
|
+
is on no wire; a caller cannot ask for it and is never told which of the
|
|
274
|
+
two a 404 was
|
|
238
275
|
socket a WebSocket at <url>, held by the dialer, used both ways
|
|
239
276
|
text frame { announce: [pk, ...] } the ward pks this side holds
|
|
240
277
|
ask frame [0][id 4][pk 64][bytes] a frame id matches the reply
|
package/human/guest.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A guest at a world's door: the world's public being, whatever class she
|
|
3
|
+
// is, as a subject the screen can render. Her describe for a stranger is the
|
|
4
|
+
// guest page; a form on it is a knock on the public invitation; and an
|
|
5
|
+
// answer that is an invitation is the way in, which the tab takes by
|
|
6
|
+
// joining. Nothing here knows the desk: the desk's `device` is one ask
|
|
7
|
+
// that answers an invitation, and any being may write another.
|
|
8
|
+
import { isSilence, isWord, wordOf } from '@quo-systems/quo';
|
|
9
|
+
import type { Answer, Blueprint, Invitation, JsonObject, Wanted } from '@quo-systems/quo';
|
|
10
|
+
import type { Avatar } from '../beings/avatar.ts';
|
|
11
|
+
import type { Subject } from '../beings/side.ts';
|
|
12
|
+
|
|
13
|
+
export function guest(avatar: Avatar, ward: string): Subject {
|
|
14
|
+
const at: Invitation = { ward };
|
|
15
|
+
return {
|
|
16
|
+
tools: async () => {
|
|
17
|
+
const bp = await avatar.knock(at);
|
|
18
|
+
if (isSilence(bp)) return { error: 'silence' };
|
|
19
|
+
if (isWord(bp)) return { error: wordOf(bp) };
|
|
20
|
+
if (bp === null || typeof bp !== 'object' || Array.isArray(bp) || !Array.isArray((bp as Blueprint).asks)) return { error: 'nobody is home' };
|
|
21
|
+
return bp as Blueprint;
|
|
22
|
+
},
|
|
23
|
+
call: (name: string, args: JsonObject = {}, wanted?: Wanted): Promise<Answer> => avatar.knock(at, name, args, wanted),
|
|
24
|
+
ears: new Set(),
|
|
25
|
+
};
|
|
26
|
+
}
|