@lanes-sh/link 0.2.1 → 0.3.0
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 +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from '#connectivity';
|
|
2
|
-
import { clearUpstreamTokens } from '#connectivity/auth/index.ts';
|
|
2
|
+
import { clearMintedTokens, clearUpstreamTokens } from '#connectivity/auth/index.ts';
|
|
3
3
|
import type { ProfileRuntime } from '#server/mcp';
|
|
4
4
|
import { Generation } from './generation.ts';
|
|
5
5
|
|
|
@@ -52,6 +52,8 @@ export interface GenerationDeps {
|
|
|
52
52
|
readonly primary: string;
|
|
53
53
|
readonly log: Logger;
|
|
54
54
|
readonly version?: string | undefined;
|
|
55
|
+
/** Whether an authorization surface is published. See `BuildServerOptions`. */
|
|
56
|
+
readonly remoteClients?: boolean | undefined;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** What a reload did, as the `/reload` route reports it. */
|
|
@@ -182,12 +184,21 @@ export class Generations {
|
|
|
182
184
|
// cloud target is a network write, so this is reachable rather than
|
|
183
185
|
// theoretical.
|
|
184
186
|
try {
|
|
185
|
-
// Module-global and keyed per connection, so
|
|
187
|
+
// Module-global and keyed per connection, so they survive a reload that
|
|
186
188
|
// replaced everything else. Re-connecting `<provider>.<id>` to a different
|
|
187
189
|
// account would otherwise serve the previous account's access token until
|
|
188
190
|
// it expired — up to an hour after the config said otherwise. Unchanged
|
|
189
191
|
// connections pay one refresh.
|
|
192
|
+
//
|
|
193
|
+
// Both caches, because a connection authenticates one way at a time and
|
|
194
|
+
// re-connecting is how it changes: the route is settled by what `connect`
|
|
195
|
+
// last stored, so a reload that cleared only the authorization-code cache
|
|
196
|
+
// would keep serving a token minted from a key for a connection that no
|
|
197
|
+
// longer authenticates with one — and, worse, keep acting as the
|
|
198
|
+
// previously impersonated user, since the minted cache is keyed by
|
|
199
|
+
// connection with no subject in it.
|
|
190
200
|
clearUpstreamTokens();
|
|
201
|
+
clearMintedTokens();
|
|
191
202
|
|
|
192
203
|
// After the swap: a request arriving during the retire already gets the
|
|
193
204
|
// new generation, and this only waits on requests that started before it.
|
package/src/server/harness.ts
CHANGED
|
@@ -86,6 +86,14 @@ export { parseConfig } from '#profile';
|
|
|
86
86
|
export interface HarnessOptions {
|
|
87
87
|
profile: string;
|
|
88
88
|
log?: Logger;
|
|
89
|
+
/**
|
|
90
|
+
* The clock the authorization server and its store share.
|
|
91
|
+
*
|
|
92
|
+
* Shared deliberately: a tombstone's `consumedAt` is written by the store and
|
|
93
|
+
* compared by the server, so two clocks would make the reuse interval
|
|
94
|
+
* untestable in the one direction that matters. Absent means `Date.now`.
|
|
95
|
+
*/
|
|
96
|
+
now?: () => number;
|
|
89
97
|
port: number;
|
|
90
98
|
policy: string;
|
|
91
99
|
token?: string;
|
|
@@ -113,6 +121,8 @@ export interface HarnessOptions {
|
|
|
113
121
|
* serving the old generation" case is reached; absent means nothing new.
|
|
114
122
|
*/
|
|
115
123
|
reopen?: () => Promise<ReadonlyMap<string, ProfileRuntime>>;
|
|
124
|
+
/** Serve `/dashboard`, as `lanes link start` does and a container never does. */
|
|
125
|
+
dashboard?: boolean;
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
/**
|
|
@@ -173,6 +183,10 @@ export function wireProfiles(options: HarnessOptions): WiredProfiles {
|
|
|
173
183
|
dispatcher,
|
|
174
184
|
policy,
|
|
175
185
|
...(options.refreshSkills ? { refreshSkills: () => options.refreshSkills!(registry) } : {}),
|
|
186
|
+
// As `profileRuntimes` supplies them for real. A harness that claims to
|
|
187
|
+
// be the real wiring and omits a field leaves that field untested.
|
|
188
|
+
target: 'local',
|
|
189
|
+
connections: () => state.connections.list(),
|
|
176
190
|
}),
|
|
177
191
|
);
|
|
178
192
|
|
|
@@ -218,13 +232,17 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
218
232
|
// The real wiring from `endpoint.ts`, not a stand-in: the flow under test is
|
|
219
233
|
// the one a connector drives over HTTP, and a fake authorization server would
|
|
220
234
|
// demonstrate that the fake works.
|
|
221
|
-
const
|
|
235
|
+
const log = options.log ?? silentLogger();
|
|
236
|
+
|
|
237
|
+
const store = options.authorization ? new OAuthStore(state.kv, options.now) : null;
|
|
222
238
|
const gate = store
|
|
223
239
|
? {
|
|
224
240
|
surface: {
|
|
225
241
|
server: new OAuthServer({
|
|
226
242
|
store,
|
|
227
243
|
accessTokenTtlMs: 3_600_000,
|
|
244
|
+
log,
|
|
245
|
+
...(options.now ? { now: options.now } : {}),
|
|
228
246
|
verifyOwner: (presented) => Promise.resolve(tokensMatch(presented, token)),
|
|
229
247
|
}),
|
|
230
248
|
issuer: (origin: string) => origin,
|
|
@@ -235,8 +253,6 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
235
253
|
}
|
|
236
254
|
: null;
|
|
237
255
|
|
|
238
|
-
const log = options.log ?? silentLogger();
|
|
239
|
-
|
|
240
256
|
const nothing = () => Promise.resolve();
|
|
241
257
|
const generations = new Generations(
|
|
242
258
|
{ profiles, close: nothing },
|
|
@@ -249,6 +265,7 @@ export function startHarness(options: HarnessOptions): Harness {
|
|
|
249
265
|
primary: options.profile,
|
|
250
266
|
authenticator: gate ? new AuthenticatorChain([bearer, gate.authenticator]) : bearer,
|
|
251
267
|
...(gate ? { authorization: gate.surface } : {}),
|
|
268
|
+
...(options.dashboard ? { dashboard: true } : {}),
|
|
252
269
|
log,
|
|
253
270
|
});
|
|
254
271
|
|
package/src/server/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { challenge, type Authenticator } from '#auth';
|
|
1
|
+
import { challenge, type Authenticator, type AuthOutcome, type ChallengeError } from '#auth';
|
|
2
2
|
import type { Logger } from '#connectivity';
|
|
3
3
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
4
4
|
import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
|
|
5
5
|
import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
|
|
6
|
+
import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
|
|
7
|
+
import {
|
|
8
|
+
DASHBOARD_PATH,
|
|
9
|
+
dashboardSessions,
|
|
10
|
+
handleDashboard,
|
|
11
|
+
servesDashboard,
|
|
12
|
+
} from './dashboard.ts';
|
|
6
13
|
import type { Generation } from './generation.ts';
|
|
7
14
|
import type { Generations } from './generations.ts';
|
|
8
15
|
import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
|
|
@@ -49,8 +56,45 @@ export interface ServerOptions {
|
|
|
49
56
|
readonly authorization?: AuthorizationSurface | undefined;
|
|
50
57
|
/** Hostnames this endpoint answers to. See `./rebinding.ts`. */
|
|
51
58
|
readonly allowedHostnames?: readonly string[] | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Serve the dashboard.
|
|
61
|
+
*
|
|
62
|
+
* Off unless asked for, and `serve()` withholds it anyway when the bind
|
|
63
|
+
* address is not loopback. `lanes link start` asks; `container.ts` does not.
|
|
64
|
+
* See `./dashboard.ts` for why a deployed instance has no browser-shaped door
|
|
65
|
+
* to put it behind.
|
|
66
|
+
*/
|
|
67
|
+
readonly dashboard?: boolean | undefined;
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What a caller should do about each refusal.
|
|
74
|
+
*
|
|
75
|
+
* `invalid` is the only one a client can act on by itself: it presented a
|
|
76
|
+
* credential and this endpoint did not accept it, which is what a refresh is
|
|
77
|
+
* for. RFC 6750 §3.1 has a name for that and clients branch on it; the others
|
|
78
|
+
* mean there is nothing to refresh, and §3 says to stay quiet rather than send
|
|
79
|
+
* a client after a token it does not hold. `malformed` says nothing either —
|
|
80
|
+
* `invalid_request` carries a SHOULD of a 400 status, and changing that path's
|
|
81
|
+
* status is a larger question than this answers.
|
|
82
|
+
*/
|
|
83
|
+
const CHALLENGE: Partial<Record<RefusalReason, ChallengeError>> = {
|
|
84
|
+
invalid: {
|
|
85
|
+
code: 'invalid_token',
|
|
86
|
+
description: 'The credential is expired, revoked, or not one this endpoint issued.',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The same four, for whoever is reading the body rather than the header. */
|
|
91
|
+
const HINTS: Record<RefusalReason, string> = {
|
|
92
|
+
missing: 'Present the profile token as: Authorization: Bearer <token>',
|
|
93
|
+
malformed: 'Present the profile token as: Authorization: Bearer <token>',
|
|
94
|
+
invalid: 'Refresh the credential. Authorize again only if the refresh is refused too.',
|
|
95
|
+
not_configured: 'This profile has no token yet. Run: lanes link token rotate',
|
|
96
|
+
};
|
|
97
|
+
|
|
54
98
|
export const MCP_PATH = '/mcp';
|
|
55
99
|
export const RELOAD_PATH = '/reload';
|
|
56
100
|
|
|
@@ -82,6 +126,7 @@ export interface RequestHandler {
|
|
|
82
126
|
export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
83
127
|
let probedAt = 0;
|
|
84
128
|
const failedAuth = failedAuthLimiter();
|
|
129
|
+
const sessions = dashboardSessions();
|
|
85
130
|
|
|
86
131
|
/**
|
|
87
132
|
* Re-read the config because a call named a tool we do not serve.
|
|
@@ -141,6 +186,19 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
141
186
|
});
|
|
142
187
|
}
|
|
143
188
|
|
|
189
|
+
// Above the 404 gate and outside the bearer path below, because a
|
|
190
|
+
// top-level browser navigation carries no `Authorization` header — so it
|
|
191
|
+
// authenticates itself, against the same authenticator. Unset, this is
|
|
192
|
+
// never reached and `/dashboard` is a 404 like any other unknown path.
|
|
193
|
+
if (options.dashboard && url.pathname === DASHBOARD_PATH) {
|
|
194
|
+
return await handleDashboard(request, {
|
|
195
|
+
generations: options.generations,
|
|
196
|
+
authenticator: options.authenticator,
|
|
197
|
+
primary: options.primary,
|
|
198
|
+
sessions,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
144
202
|
if (
|
|
145
203
|
url.pathname !== MCP_PATH &&
|
|
146
204
|
url.pathname !== ATTACHMENTS_PATH &&
|
|
@@ -174,16 +232,13 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
174
232
|
JSON.stringify({
|
|
175
233
|
error: 'unauthorized',
|
|
176
234
|
reason: outcome.reason,
|
|
177
|
-
hint:
|
|
178
|
-
outcome.reason === 'not_configured'
|
|
179
|
-
? 'This profile has no token yet. Run: lanes link token rotate'
|
|
180
|
-
: 'Present the profile token as: Authorization: Bearer <token>',
|
|
235
|
+
hint: HINTS[outcome.reason],
|
|
181
236
|
}),
|
|
182
237
|
{
|
|
183
238
|
status: 401,
|
|
184
239
|
headers: {
|
|
185
240
|
'content-type': 'application/json',
|
|
186
|
-
'www-authenticate': challenge(metadata),
|
|
241
|
+
'www-authenticate': challenge(metadata, CHALLENGE[outcome.reason]),
|
|
187
242
|
},
|
|
188
243
|
},
|
|
189
244
|
);
|
|
@@ -310,17 +365,26 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
310
365
|
const host = options.host ?? primary.config.instance.host;
|
|
311
366
|
const port = options.port ?? primary.config.instance.port;
|
|
312
367
|
|
|
313
|
-
const
|
|
368
|
+
const loopback = isLoopback(host);
|
|
369
|
+
const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, loopback);
|
|
370
|
+
|
|
371
|
+
// Cross-origin access, and its absence, are decided here for the same reason
|
|
372
|
+
// `allowedHostnames` and `dashboard` are: they are all properties of what this
|
|
373
|
+
// is bound to. The two are mutually exclusive and the exclusion is the
|
|
374
|
+
// decision — see `./cors.ts`, and ADR-040.
|
|
375
|
+
const cors: CorsPolicy | undefined = loopback
|
|
376
|
+
? undefined
|
|
377
|
+
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|
|
314
378
|
const handler = createRequestHandler({
|
|
315
379
|
...options,
|
|
380
|
+
dashboard: servesDashboard(options.dashboard, loopback),
|
|
316
381
|
...(allowedHostnames ? { allowedHostnames } : {}),
|
|
317
382
|
});
|
|
318
383
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
});
|
|
384
|
+
const route = (request: Request): Promise<Response> => handler.fetch(request);
|
|
385
|
+
const fetch = cors ? corsAware(route, [MCP_PATH, ATTACHMENTS_PATH], cors) : route;
|
|
386
|
+
|
|
387
|
+
const server = Bun.serve({ hostname: host, port, fetch });
|
|
324
388
|
|
|
325
389
|
return {
|
|
326
390
|
url: `http://${host}:${port}${MCP_PATH}`,
|
package/src/server/mcp/build.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function buildMcpServer(options: BuildServerOptions): McpServer {
|
|
|
46
46
|
// and `Implementation` would take it as an unknown extra and drop it from
|
|
47
47
|
// `initialize` without complaining.
|
|
48
48
|
{
|
|
49
|
-
instructions: serverInstructions(names, merged),
|
|
49
|
+
instructions: serverInstructions(names, merged, options.remoteClients),
|
|
50
50
|
// Declared `false` because it is false, and the SDK defaults it to `true`.
|
|
51
51
|
//
|
|
52
52
|
// `listChanged` is a promise to send `notifications/tools/list_changed`
|
package/src/server/mcp/index.ts
CHANGED
|
@@ -34,10 +34,10 @@ import type { MergedCapability } from './visibility.ts';
|
|
|
34
34
|
/**
|
|
35
35
|
* The habits, in the order they are needed.
|
|
36
36
|
*
|
|
37
|
-
* Routing first because it gates every call;
|
|
38
|
-
* an agent is most tempted to improvise. Second person, and
|
|
39
|
-
* *not* to do — "ask which profile" is advice, "do not
|
|
40
|
-
* a rule.
|
|
37
|
+
* Routing first because it gates every call; the two ways a call ends badly last,
|
|
38
|
+
* because that is when an agent is most tempted to improvise. Second person, and
|
|
39
|
+
* specific about what *not* to do — "ask which profile" is advice, "do not
|
|
40
|
+
* default to the first" is a rule.
|
|
41
41
|
*
|
|
42
42
|
* **Four of these are conditional**, and that is a correctness property rather
|
|
43
43
|
* than a saving. This used to be one fixed string that told every client to
|
|
@@ -87,6 +87,23 @@ const SETUP = `**What is set up is answerable.** Before saying something cannot
|
|
|
87
87
|
that an account must be added, call \`setup_overview\` — then \`setup_provider\`
|
|
88
88
|
for the exact command. Running it is the owner's to do; inventing it is not.`;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The one about not signing as the wrong person.
|
|
92
|
+
*
|
|
93
|
+
* It carries no names, and that is deliberate rather than thrift. Inlining the
|
|
94
|
+
* declaration would put a per-profile list into a string with a fixed ceiling —
|
|
95
|
+
* so the workspace with the most identities to keep straight is exactly the one
|
|
96
|
+
* whose list would be summarised away first. A pointer costs the same for one
|
|
97
|
+
* profile as for twenty, and `identity_list` has room to say when each applies,
|
|
98
|
+
* which is the half that actually prevents the mistake.
|
|
99
|
+
*
|
|
100
|
+
* Conditional like the rest: a profile that declares nothing has no `identity`
|
|
101
|
+
* connection, so the capability is unreachable and this paragraph is unspent.
|
|
102
|
+
*/
|
|
103
|
+
const IDENTITY = `**Identity is declared, not inferred.** Where a name, address or handle of the
|
|
104
|
+
owner's is needed, call \`identity_list\`: a profile may hold several, each with a
|
|
105
|
+
note on when it applies.`;
|
|
106
|
+
|
|
90
107
|
const FILES = `**Files are named, not carried.** Where a tool takes attachments, give a path, an
|
|
91
108
|
HTTPS URL, or an attachment already on another message; the endpoint reads the
|
|
92
109
|
bytes. Never encode a file into a call — that is the thing this replaces.`;
|
|
@@ -95,12 +112,34 @@ const REFUSAL = `**A refused call is the permission system working**, not an obs
|
|
|
95
112
|
around. Report what was refused and let the owner decide whether to widen it.
|
|
96
113
|
Every call, including a refused one, is recorded.`;
|
|
97
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The one about not reaching here at all.
|
|
117
|
+
*
|
|
118
|
+
* Only for a client that authorises against this endpoint over the network —
|
|
119
|
+
* the one that cannot be handed the bundled skill, and the one whose connector
|
|
120
|
+
* decides on its own whether this endpoint is available. Observed: with the
|
|
121
|
+
* endpoint up and idle, a connector reported it unreachable without issuing a
|
|
122
|
+
* request at all, and the model read that as a fault, then re-derived an answer
|
|
123
|
+
* it had already given and re-composed an entry it had already written. Nothing
|
|
124
|
+
* here can prevent it, because nothing here is consulted — the call never
|
|
125
|
+
* arrives. Telling the model what the state means is the whole of what is left.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately *not* "the endpoint is asleep". Usually it is not, and prose
|
|
128
|
+
* asserting a cause the model cannot check is how a wrong diagnosis gets
|
|
129
|
+
* repeated with confidence.
|
|
130
|
+
*/
|
|
131
|
+
const AVAILABILITY = `**A call may simply not go through.** This endpoint is one machine its owner
|
|
132
|
+
runs, and a client can report it unreachable while it is up. That is ordinary —
|
|
133
|
+
not a fault to diagnose, and not authorization you have lost. Say the call did
|
|
134
|
+
not land, do not redo what already succeeded, and offer to retry.`;
|
|
135
|
+
|
|
98
136
|
/** Which paragraph each owner-layer provider brings, when it is reachable. */
|
|
99
137
|
const OWNER_HABITS: Record<string, string> = {
|
|
100
138
|
memory: MEMORY,
|
|
101
139
|
skills: SKILLS,
|
|
102
140
|
vault: VAULT,
|
|
103
141
|
setup: SETUP,
|
|
142
|
+
identity: IDENTITY,
|
|
104
143
|
};
|
|
105
144
|
|
|
106
145
|
/**
|
|
@@ -111,6 +150,20 @@ const OWNER_HABITS: Record<string, string> = {
|
|
|
111
150
|
* it is the prompt to ask whether the paragraph belongs in the skill instead,
|
|
112
151
|
* where it is loaded only when relevant.
|
|
113
152
|
*
|
|
153
|
+
* It was raised once, from 2000, for `AVAILABILITY` — and that question was
|
|
154
|
+
* asked and answered the other way: the client that paragraph exists for is
|
|
155
|
+
* precisely the one that holds no skills directory, so the skill is not a place
|
|
156
|
+
* it can go. Only an endpoint serving remote clients spends it.
|
|
157
|
+
*
|
|
158
|
+
* Raised a second time, to 2500, for `IDENTITY`, and the same answer for the
|
|
159
|
+
* same reason: an agent signing as the wrong person has already sent the
|
|
160
|
+
* message, and a skill loaded only when relevant is not loaded at the moment
|
|
161
|
+
* that happens. The measured worst case — twenty profiles, twenty connections
|
|
162
|
+
* each, every owner provider reachable, remote clients — is 2474, so this is
|
|
163
|
+
* the measurement plus a little, not a round number picked first. Two things
|
|
164
|
+
* hold it there: the paragraph names no identity, and it is spent only by a
|
|
165
|
+
* profile that declared one.
|
|
166
|
+
*
|
|
114
167
|
* Exported because the test asserted `2000` as a literal while the code
|
|
115
168
|
* reserved room against a second, differently-derived number — so the two could
|
|
116
169
|
* disagree, and did. There is no separate listing allowance any more: `spent`
|
|
@@ -118,7 +171,7 @@ const OWNER_HABITS: Record<string, string> = {
|
|
|
118
171
|
* exactly the final length, because `join` adds the same two characters the
|
|
119
172
|
* reduce already counted.
|
|
120
173
|
*/
|
|
121
|
-
export const MAX_INSTRUCTIONS =
|
|
174
|
+
export const MAX_INSTRUCTIONS = 2500;
|
|
122
175
|
|
|
123
176
|
/** Which of the owner-layer providers this principal can actually reach. */
|
|
124
177
|
function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
|
|
@@ -172,6 +225,9 @@ function connectionsByProfile(
|
|
|
172
225
|
export function serverInstructions(
|
|
173
226
|
profiles: readonly string[],
|
|
174
227
|
merged: ReadonlyMap<string, MergedCapability>,
|
|
228
|
+
/** Whether a client authorises against this endpoint rather than being handed
|
|
229
|
+
* a token — see `AVAILABILITY`, the only paragraph that reads it. */
|
|
230
|
+
remoteClients = false,
|
|
175
231
|
): string {
|
|
176
232
|
const reachable = connectionsByProfile(profiles, merged);
|
|
177
233
|
const owner = ownerProviders(merged);
|
|
@@ -185,6 +241,7 @@ export function serverInstructions(
|
|
|
185
241
|
...owner.map((id) => OWNER_HABITS[id]).filter((habit): habit is string => habit !== undefined),
|
|
186
242
|
FILES,
|
|
187
243
|
REFUSAL,
|
|
244
|
+
...(remoteClients ? [AVAILABILITY] : []),
|
|
188
245
|
];
|
|
189
246
|
|
|
190
247
|
if (reachable.size === 0) {
|
|
@@ -20,6 +20,20 @@ import { allowedConnections } from '#policy';
|
|
|
20
20
|
* still a leak.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A connection's reconciled state, as much of it as a reader needs.
|
|
25
|
+
*
|
|
26
|
+
* Structural rather than the store's own `ConnectionRecord`: `server` does not
|
|
27
|
+
* import `stores` (`src/architecture.test.ts`), and what a surface that reports
|
|
28
|
+
* wants from a connection is its key and whether it is working — not the
|
|
29
|
+
* timestamps and credential expiry the repository keeps behind it.
|
|
30
|
+
*/
|
|
31
|
+
export interface ConnectionState {
|
|
32
|
+
readonly provider: string;
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly status: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
/** Everything one profile contributes to the endpoint. */
|
|
24
38
|
export interface ProfileRuntime {
|
|
25
39
|
readonly config: Config;
|
|
@@ -35,6 +49,25 @@ export interface ProfileRuntime {
|
|
|
35
49
|
* how often to ask (ADR-014).
|
|
36
50
|
*/
|
|
37
51
|
refreshSkills?(): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Which target's adapters this profile was opened against.
|
|
54
|
+
*
|
|
55
|
+
* Not derivable from `config`: a target is *selected* per run, and the config
|
|
56
|
+
* only says which one is the default. Optional for the same reason the one
|
|
57
|
+
* below is — a runtime built to answer "what is visible" was never opened
|
|
58
|
+
* against anything.
|
|
59
|
+
*/
|
|
60
|
+
readonly target?: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Reconciled connection state, for a surface that reports rather than
|
|
63
|
+
* dispatches.
|
|
64
|
+
*
|
|
65
|
+
* Optional exactly as `refreshSkills` is: only a served endpoint holds the
|
|
66
|
+
* state handle this reads through, and nothing on the dispatch path asks —
|
|
67
|
+
* a capability's visibility is decided by policy, not by whether the
|
|
68
|
+
* credential behind it currently works.
|
|
69
|
+
*/
|
|
70
|
+
connections?(): Promise<readonly ConnectionState[]>;
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
export interface BuildServerOptions {
|
|
@@ -53,6 +86,15 @@ export interface BuildServerOptions {
|
|
|
53
86
|
/** Self-reported by the client. Recorded in audit; never used to authorize. */
|
|
54
87
|
readonly clientLabel?: string | undefined;
|
|
55
88
|
readonly version?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Whether this endpoint publishes an authorization surface, and therefore
|
|
91
|
+
* serves clients that arrived by URL alone.
|
|
92
|
+
*
|
|
93
|
+
* Read only by the instructions, which gain a paragraph for them. Absent over
|
|
94
|
+
* a pipe and on a loopback endpoint, where the client holds the skill and the
|
|
95
|
+
* transport cannot fail the way this describes.
|
|
96
|
+
*/
|
|
97
|
+
readonly remoteClients?: boolean | undefined;
|
|
56
98
|
}
|
|
57
99
|
|
|
58
100
|
/** One profile as the map the builder wants. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { containedKey, type BlobKey, type BlobMetadata, type BlobStore } from './index.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sending part of a store's key space somewhere else.
|
|
5
|
+
*
|
|
6
|
+
* `scopeBlobStore` narrows a store to a namespace; this one splits a store
|
|
7
|
+
* between backends. A key under a route's prefix is served by that route's
|
|
8
|
+
* store — which is rooted *at* the prefix, so the prefix is stripped on the way
|
|
9
|
+
* in and put back on the way out — and every other key falls through to the
|
|
10
|
+
* base.
|
|
11
|
+
*
|
|
12
|
+
* It exists for one shape and it is worth naming it, because the alternative
|
|
13
|
+
* was much larger. A profile's memory is not addressed by a name anything
|
|
14
|
+
* declares: core scopes the profile's blob root to `<provider>/<connection>`
|
|
15
|
+
* inside `buildProviderContext`, and `lanes link memory` reaches the same bytes
|
|
16
|
+
* by calling the same two functions. So "put memory somewhere else" is not a
|
|
17
|
+
* question the provider, the dispatcher, or the CLI can be asked — it is a
|
|
18
|
+
* property of the store all three were handed. Routing the root means none of
|
|
19
|
+
* them changes, and none of them can disagree about where an entry went.
|
|
20
|
+
*
|
|
21
|
+
* A route's store is expected to enforce its own containment: it is a
|
|
22
|
+
* `BlobStore` like any other, and the base's rules do not travel across the
|
|
23
|
+
* boundary.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface BlobRoute {
|
|
27
|
+
/**
|
|
28
|
+
* The key prefix this route claims, ending in `/`.
|
|
29
|
+
*
|
|
30
|
+
* Directory-shaped on purpose. A bare `memory` would also claim
|
|
31
|
+
* `memory-archive/x`, and a store that quietly swallows a neighbouring
|
|
32
|
+
* namespace is the kind of bug that surfaces as missing data rather than as
|
|
33
|
+
* an error.
|
|
34
|
+
*/
|
|
35
|
+
readonly prefix: string;
|
|
36
|
+
/** A store rooted at `prefix`, so it never sees the prefix itself. */
|
|
37
|
+
readonly store: BlobStore;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function routeBlobStore(base: BlobStore, routes: readonly BlobRoute[]): BlobStore {
|
|
41
|
+
if (routes.length === 0) return base;
|
|
42
|
+
|
|
43
|
+
const normalised = routes.map((route) => {
|
|
44
|
+
if (route.prefix.length === 0) {
|
|
45
|
+
throw new Error('A blob route prefix must not be empty — that is the base store.');
|
|
46
|
+
}
|
|
47
|
+
return { ...route, prefix: route.prefix.endsWith('/') ? route.prefix : `${route.prefix}/` };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which store owns this key, and what it calls it.
|
|
52
|
+
*
|
|
53
|
+
* Contained first, so `memory/../elsewhere.md` is judged by where it lands
|
|
54
|
+
* rather than by how it is spelled — routing on the raw string would let a
|
|
55
|
+
* traversal pick its own backend, which is a weaker answer than either store
|
|
56
|
+
* gives on its own.
|
|
57
|
+
*/
|
|
58
|
+
const routeFor = (key: BlobKey): { store: BlobStore; key: BlobKey } => {
|
|
59
|
+
const resolved = containedKey(key);
|
|
60
|
+
for (const route of normalised) {
|
|
61
|
+
if (resolved.startsWith(route.prefix)) {
|
|
62
|
+
return { store: route.store, key: resolved.slice(route.prefix.length) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { store: base, key };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async put(key, data, options) {
|
|
70
|
+
const to = routeFor(key);
|
|
71
|
+
return to.store.put(to.key, data, options);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async get(key) {
|
|
75
|
+
const to = routeFor(key);
|
|
76
|
+
return to.store.get(to.key);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async has(key) {
|
|
80
|
+
const to = routeFor(key);
|
|
81
|
+
return to.store.has(to.key);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async delete(key) {
|
|
85
|
+
const to = routeFor(key);
|
|
86
|
+
return to.store.delete(to.key);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Merge the listings, with routed keys removed from the base's.
|
|
91
|
+
*
|
|
92
|
+
* Three cases, and the middle one is the one that is easy to miss: a
|
|
93
|
+
* listing prefix *inside* a route belongs wholly to that route, a listing
|
|
94
|
+
* prefix *containing* a route has to include everything that route holds,
|
|
95
|
+
* and a listing that touches neither is the base's alone. Sorted at the end
|
|
96
|
+
* because callers rely on it — `skillFingerprint` sorts its own output, but
|
|
97
|
+
* `loadProfileSkills` reads the listing in order.
|
|
98
|
+
*/
|
|
99
|
+
async list(prefix) {
|
|
100
|
+
const asked = prefix ?? '';
|
|
101
|
+
|
|
102
|
+
for (const route of normalised) {
|
|
103
|
+
if (asked.startsWith(route.prefix)) {
|
|
104
|
+
const inner = await route.store.list(asked.slice(route.prefix.length));
|
|
105
|
+
return inner.map((entry) => ({ ...entry, key: `${route.prefix}${entry.key}` }));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const results: BlobMetadata[] = (await base.list(asked)).filter(
|
|
110
|
+
(entry) => !normalised.some((route) => entry.key.startsWith(route.prefix)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
for (const route of normalised) {
|
|
114
|
+
if (!route.prefix.startsWith(asked)) continue;
|
|
115
|
+
for (const entry of await route.store.list()) {
|
|
116
|
+
results.push({ ...entry, key: `${route.prefix}${entry.key}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return results.sort((a, b) => a.key.localeCompare(b.key));
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|