@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.
Files changed (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. 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 it survives a reload that
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.
@@ -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 store = options.authorization ? new OAuthStore(state.kv) : null;
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
 
@@ -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 allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, isLoopback(host));
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 server = Bun.serve({
320
- hostname: host,
321
- port,
322
- fetch: (request: Request) => handler.fetch(request),
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}`,
@@ -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`
@@ -28,6 +28,7 @@ export {
28
28
  visibleCapabilities,
29
29
  visibleToolCount,
30
30
  type BuildServerOptions,
31
+ type ConnectionState,
31
32
  type MergedCapability,
32
33
  type ProfileRuntime,
33
34
  } from './visibility.ts';
@@ -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; refusals last because that is when
38
- * an agent is most tempted to improvise. Second person, and specific about what
39
- * *not* to do — "ask which profile" is advice, "do not default to the first" is
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 = 2000;
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
+ }