@lanes-sh/link 0.2.2 → 0.3.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.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -3,6 +3,13 @@ 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,6 +56,15 @@ 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
 
54
70
  type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
@@ -110,6 +126,7 @@ export interface RequestHandler {
110
126
  export function createRequestHandler(options: ServerOptions): RequestHandler {
111
127
  let probedAt = 0;
112
128
  const failedAuth = failedAuthLimiter();
129
+ const sessions = dashboardSessions();
113
130
 
114
131
  /**
115
132
  * Re-read the config because a call named a tool we do not serve.
@@ -169,6 +186,19 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
169
186
  });
170
187
  }
171
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
+
172
202
  if (
173
203
  url.pathname !== MCP_PATH &&
174
204
  url.pathname !== ATTACHMENTS_PATH &&
@@ -335,17 +365,26 @@ export function serve(options: ServeOptions): RunningServer {
335
365
  const host = options.host ?? primary.config.instance.host;
336
366
  const port = options.port ?? primary.config.instance.port;
337
367
 
338
- 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-039.
375
+ const cors: CorsPolicy | undefined = loopback
376
+ ? undefined
377
+ : { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
339
378
  const handler = createRequestHandler({
340
379
  ...options,
380
+ dashboard: servesDashboard(options.dashboard, loopback),
341
381
  ...(allowedHostnames ? { allowedHostnames } : {}),
342
382
  });
343
383
 
344
- const server = Bun.serve({
345
- hostname: host,
346
- port,
347
- fetch: (request: Request) => handler.fetch(request),
348
- });
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 });
349
388
 
350
389
  return {
351
390
  url: `http://${host}:${port}${MCP_PATH}`,
@@ -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';
@@ -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.`;
@@ -122,6 +139,7 @@ const OWNER_HABITS: Record<string, string> = {
122
139
  skills: SKILLS,
123
140
  vault: VAULT,
124
141
  setup: SETUP,
142
+ identity: IDENTITY,
125
143
  };
126
144
 
127
145
  /**
@@ -137,6 +155,15 @@ const OWNER_HABITS: Record<string, string> = {
137
155
  * precisely the one that holds no skills directory, so the skill is not a place
138
156
  * it can go. Only an endpoint serving remote clients spends it.
139
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
+ *
140
167
  * Exported because the test asserted `2000` as a literal while the code
141
168
  * reserved room against a second, differently-derived number — so the two could
142
169
  * disagree, and did. There is no separate listing allowance any more: `spent`
@@ -144,7 +171,7 @@ const OWNER_HABITS: Record<string, string> = {
144
171
  * exactly the final length, because `join` adds the same two characters the
145
172
  * reduce already counted.
146
173
  */
147
- export const MAX_INSTRUCTIONS = 2300;
174
+ export const MAX_INSTRUCTIONS = 2500;
148
175
 
149
176
  /** Which of the owner-layer providers this principal can actually reach. */
150
177
  function ownerProviders(merged: ReadonlyMap<string, MergedCapability>): string[] {
@@ -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 {
@@ -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
+ }