@lanes-sh/link 0.7.2 → 0.9.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 (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -1,3 +1,6 @@
1
+ import { migrateToContract4, type Contract4Migration } from './contract4.ts';
2
+ import { migrateToContract3, needsContract3, type Contract3Migration } from './contract3.ts';
3
+ import { readSession } from '#auth/lanes/session.ts';
1
4
  import { parseDocument } from 'yaml';
2
5
  import {
3
6
  ConfigError,
@@ -16,6 +19,7 @@ import {
16
19
  } from '#profile';
17
20
  import { ConfigDocument } from './config-edit.ts';
18
21
  import { hoist, summarise } from './migrate-plan.ts';
22
+ import { C3 } from './contract3-layout.ts';
19
23
 
20
24
  /**
21
25
  * Contract 1 → 2: the target moves out of the profile and into the workspace.
@@ -25,7 +29,7 @@ import { hoist, summarise } from './migrate-plan.ts';
25
29
  * of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
26
30
  * — and gave them nothing to keep them honest. It failed the way it was always
27
31
  * going to: a local file was rewritten, lost its cloud target and eight
28
- * connections, and `status --target cloud` reported seven where the endpoint was
32
+ * connections, and `status --workspace cloud` reported seven where the endpoint was
29
33
  * serving fifteen. `sync targets` and ADR-044's deployment index were both
30
34
  * written in response to earlier rounds of the same thing.
31
35
  *
@@ -62,7 +66,7 @@ export interface WorkspaceMigration {
62
66
  /** Whether this workspace still holds anything at contract 1. */
63
67
  export async function needsMigration(workspaceRoot: string): Promise<boolean> {
64
68
  for (const profile of await listProfiles(workspaceRoot)) {
65
- const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), `profiles/${profile}.yaml`);
69
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), C3.profile(profile));
66
70
  if (text === null) continue;
67
71
  try {
68
72
  if (isLegacyProfile(parseDocument(text).toJSON())) return true;
@@ -91,7 +95,10 @@ export async function migrateWorkspace(
91
95
  [];
92
96
 
93
97
  for (const profile of names) {
94
- const document = await ConfigDocument.open(workspaceRoot, profile);
98
+ // The contract-1 path, frozen. `open` resolves the live layout, which is
99
+ // contract 4's — so this read a profile that does not exist yet and the
100
+ // migration refused a workspace it was supposed to move.
101
+ const document = await ConfigDocument.openKey(workspaceRoot, C3.profile(profile));
95
102
  const raw = document.toJSON();
96
103
  if (!isLegacyProfile(raw)) continue;
97
104
 
@@ -122,12 +129,12 @@ export async function migrateWorkspace(
122
129
  const changes: string[] = [];
123
130
  for (const [name, entry] of Object.entries(registry)) {
124
131
  changes.push(
125
- entry.workspace !== undefined
126
- ? `targets.${name}: pointer to ${entry.workspace}`
127
- : `targets.${name}: declared in ${WORKSPACE_FILE}`,
132
+ entry.at !== undefined
133
+ ? `workspaces.${name}: pointer to ${entry.at}`
134
+ : `targets.${name}: declared in ${C3.workspace}`,
128
135
  );
129
136
  }
130
- for (const { profile } of legacy) changes.push(`profiles/${profile}.yaml: targets: removed, contract: 2`);
137
+ for (const { profile } of legacy) changes.push(`${C3.profile(profile)}: targets: removed, contract: 2`);
131
138
 
132
139
  if (!options.apply) {
133
140
  return {
@@ -147,17 +154,30 @@ export async function migrateWorkspace(
147
154
  // again.
148
155
  await writeRegistry(workspaceRoot, registry);
149
156
 
150
- for (const { document } of legacy) {
157
+ for (const { document, profile } of legacy) {
151
158
  document.removeIn(['targets']);
152
- document.setIn(['contract'], SUPPORTED_CONTRACT);
159
+ // Literally 2, not `SUPPORTED_CONTRACT`. This migration produces a
160
+ // contract-2 profile and nothing else; `migrateToContract3` takes it the
161
+ // rest of the way. Writing the current contract here would stamp a file
162
+ // that still has contract-2 shapes with the newest number, and the loader
163
+ // would then read it as a contract-3 document that is missing `grants:`.
164
+ document.setIn(['contract'], 2);
153
165
  // `instance.default_target` went with the block it selected from. It has
154
166
  // been inert since ADR-037 and there is now nothing for it to name.
155
167
  document.removeIn(['instance', 'default_target']);
156
- // Shape-only: a profile may carry an unrelated problem the full loader
157
- // refuses a connection row still spelling a renamed provider is the one
158
- // that happens and blocking the structural fix on it would strand the file
159
- // at a contract nothing reads. `doctor --fix` names and repairs that one.
160
- await document.save({ shapeOnly: true });
168
+ // Written rather than saved, because `save` validates and what this
169
+ // produces is deliberately not valid *yet*: a contract-2 profile, which the
170
+ // runtime does not read. `migrateToContract3` is the step that makes it
171
+ // loadable, and it runs immediately after so validating here would refuse
172
+ // the only shape this function is able to produce.
173
+ //
174
+ // Nothing is lost by not validating: the next step reads the file it just
175
+ // wrote, and `check` refuses anything either step leaves broken.
176
+ await writeWorkspaceFile(
177
+ workspaceFiles(workspaceRoot),
178
+ C3.profile(profile),
179
+ document.toString(),
180
+ );
161
181
  }
162
182
 
163
183
  return {
@@ -169,6 +189,98 @@ export async function migrateWorkspace(
169
189
  };
170
190
  }
171
191
 
192
+ export interface ContractMigration {
193
+ readonly workspaceRoot: string;
194
+ /** The contract 1 → 2 half, when this workspace needed one. */
195
+ readonly legacy: WorkspaceMigration | null;
196
+ /** The contract 2 → 3 half, when this workspace needed one. */
197
+ readonly contract3: Contract3Migration | null;
198
+ readonly contract4: Contract4Migration | null;
199
+ /** Every profile either half rewrote, deduplicated. */
200
+ readonly profiles: readonly string[];
201
+ /** Targets written into the registry by the contract 1 → 2 half. */
202
+ readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
203
+ /** Both halves' lines, in the order they happened. */
204
+ readonly changes: readonly string[];
205
+ /** Nothing to do: this workspace is already at `SUPPORTED_CONTRACT`. */
206
+ readonly alreadyCurrent: boolean;
207
+ }
208
+
209
+ /**
210
+ * Bring one workspace to `SUPPORTED_CONTRACT`, whatever it is on now.
211
+ *
212
+ * **This exists because "migrate a workspace" was spelled three times and only
213
+ * one of them arrived.** `update` ran the 1→2 step and then the 2→3 step;
214
+ * `deploy` and `doctor --fix` ran the first and stopped, which nothing said out
215
+ * loud — `migrateTargetWorkspace`'s own docstring claimed it brought a target
216
+ * "to the current contract" while calling only `migrateWorkspace`.
217
+ *
218
+ * What that cost is worth writing down, because it is not a refusal. A deploy
219
+ * uploaded contract-3 config over a contract-2 bucket and rolled a revision that
220
+ * read `data/state.kv` and `data/audit.log` — while the bytes sat where contract
221
+ * 2 put them, under `data/<profile>/`. The endpoint came up healthy and answered
222
+ * every call with an empty store: no memory, no tasks, no skills, and an audit
223
+ * log that verified as intact because an empty chain does. Nothing in the
224
+ * deploy, the health probe, or `doctor` had a way to notice.
225
+ *
226
+ * So there is one function now, and the contract number lives in one place. A
227
+ * fourth contract adds a step here and every caller gets it.
228
+ *
229
+ * **A dry run of a contract-1 workspace reports only the first half**, and that
230
+ * is honest rather than a gap: the 1→2 step wrote nothing, so the profiles are
231
+ * still contract 1 and the 2→3 step has nothing it recognises to describe. What
232
+ * it would do is knowable only after the step before it has run.
233
+ *
234
+ * **The signed-in subject is defaulted here rather than by each caller.** A
235
+ * migrated profile that lists nobody is an endpoint that advertises OAuth and
236
+ * then refuses its own owner, so it matters on every path — but `deployments`
237
+ * may not import `#auth` (see `MAY_IMPORT` in `src/architecture.test.ts`), and
238
+ * `deploy` is the caller that most needs it: once a target's profiles live in
239
+ * its bucket there is no upload behind it to put the members row back. A caller
240
+ * that knows better passes one; nobody signed in is a legitimate answer and
241
+ * stays default-deny on the identity axis.
242
+ */
243
+ export async function migrateToCurrentContract(
244
+ workspaceRoot: string,
245
+ options: { apply: boolean; subject?: string; target?: string } = { apply: true },
246
+ ): Promise<ContractMigration> {
247
+ const legacy = (await needsMigration(workspaceRoot))
248
+ ? await migrateWorkspace(workspaceRoot, { apply: options.apply })
249
+ : null;
250
+
251
+ const subject = options.subject ?? (await readSession().catch(() => null))?.subject;
252
+
253
+ const contract3 = await migrateToContract3(workspaceRoot, {
254
+ apply: options.apply,
255
+ ...(subject === undefined ? {} : { subject }),
256
+ });
257
+
258
+ // In sequence, not in parallel: contract 4 moves what contract 3 produced, so
259
+ // it has to run against the tree the previous step left. With `apply: false`
260
+ // it sees the unmigrated shape and reports only what it can see from here —
261
+ // which is the honest preview, and why the count is not promised.
262
+ const contract4 = await migrateToContract4(workspaceRoot, {
263
+ apply: options.apply,
264
+ ...(options.target === undefined ? {} : { target: options.target }),
265
+ });
266
+
267
+ return {
268
+ workspaceRoot,
269
+ legacy: legacy !== null && !legacy.alreadyCurrent ? legacy : null,
270
+ contract3: contract3.alreadyCurrent ? null : contract3,
271
+ contract4: contract4.alreadyCurrent ? null : contract4,
272
+ profiles: [
273
+ ...new Set([...(legacy?.profiles ?? []), ...contract3.profiles, ...contract4.profiles]),
274
+ ],
275
+ targets: legacy?.targets ?? [],
276
+ changes: [...(legacy?.changes ?? []), ...contract3.changes, ...contract4.changes],
277
+ alreadyCurrent:
278
+ (legacy === null || legacy.alreadyCurrent) &&
279
+ contract3.alreadyCurrent &&
280
+ contract4.alreadyCurrent,
281
+ };
282
+ }
283
+
172
284
  /**
173
285
  * Write the registry into the workspace file, creating it when absent.
174
286
  *
@@ -183,10 +295,10 @@ async function writeRegistry(
183
295
  registry: Record<string, WorkspaceTarget>,
184
296
  ): Promise<void> {
185
297
  const files = workspaceFiles(workspaceRoot);
186
- const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
298
+ const text = (await readWorkspaceFile(files, C3.workspace)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
187
299
  const document = parseDocument(text);
188
300
  const current = (document.toJSON() ?? {}) as {
189
- targets?: Record<string, WorkspaceTarget>;
301
+ workspaces?: Record<string, WorkspaceTarget>;
190
302
  deployments?: {
191
303
  target?: string;
192
304
  workspace?: string;
@@ -198,7 +310,7 @@ async function writeRegistry(
198
310
  // Anything already in the file wins over what was hoisted: a workspace part
199
311
  // way through this has entries that are already right, and re-deriving them
200
312
  // from a profile that still carries a stale block would undo a correction.
201
- const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.targets ?? {}) };
313
+ const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.workspaces ?? {}) };
202
314
 
203
315
  // ADR-044's index, folded into the entries it described. `primary` and
204
316
  // `last_deploy` were kept beside the declaration precisely because the
@@ -214,23 +326,28 @@ async function writeRegistry(
214
326
  };
215
327
  }
216
328
 
329
+ // `workspaces:`, not `targets:` — the registry is read back by the current
330
+ // schema even while the profiles beside it are still contract 2, because it
331
+ // is one document that both migrations share (ADR-061). The profiles are the
332
+ // half that stays at 2 until `migrateToContract3` runs.
217
333
  document.setIn(['contract'], SUPPORTED_CONTRACT);
218
334
  document.setIn(
219
- ['targets'],
335
+ ['workspaces'],
220
336
  Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
221
337
  );
338
+ document.deleteIn(['targets']);
222
339
  document.deleteIn(['deployments']);
223
340
  document.deleteIn(['default_target']);
224
341
 
225
- await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
342
+ await writeWorkspaceFile(files, C3.workspace, String(document));
226
343
  }
227
344
 
228
345
  function describe(
229
346
  registry: Record<string, WorkspaceTarget>,
230
347
  ): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
231
348
  return Object.entries(registry).map(([name, entry]) =>
232
- entry.workspace !== undefined
233
- ? { name, kind: 'pointer' as const, where: entry.workspace }
349
+ entry.at !== undefined
350
+ ? { name, kind: 'pointer' as const, where: entry.at }
234
351
  : { name, kind: 'declared' as const },
235
352
  );
236
353
  }
@@ -249,6 +366,19 @@ function describe(
249
366
  * part of what it already means (ADR-052).
250
367
  */
251
368
  export async function refuseIfUnmigrated(root: string): Promise<void> {
369
+ if (await needsContract3(root)) {
370
+ throw new ConfigError(
371
+ `${root} is a contract 2 workspace, and this version does not read one.\n\n` +
372
+ ' Under contract 2 each profile carried its own connections and one flat\n' +
373
+ ' policy. Connections belong to the workspace now, and a profile selects\n' +
374
+ ' among them with per-connection scopes (ADR-057, ADR-058).\n\n' +
375
+ ' Migrate it: lanes link update\n' +
376
+ ' Preview it: lanes link update --check\n\n' +
377
+ ' Nothing is deleted by the migration: credentials are merged and read back\n' +
378
+ ' before the old stores are left in place.',
379
+ );
380
+ }
381
+
252
382
  if (!(await needsMigration(root))) return;
253
383
 
254
384
  throw new ConfigError(
@@ -258,6 +388,6 @@ export async function refuseIfUnmigrated(root: string): Promise<void> {
258
388
  ' copies of every profile that could drift apart (ADR-052).\n\n' +
259
389
  ' Migrate it: lanes link update\n' +
260
390
  ' A deployed target is migrated by the deploy that ships the image reading it:\n' +
261
- ' lanes link deploy --target <name>',
391
+ ' lanes link deploy --workspace <name>',
262
392
  );
263
393
  }
@@ -18,7 +18,9 @@ import { connectionVariableSchema, placeholdersInConnector } from './variables.t
18
18
  *
19
19
  * - built-ins, written as typed TS modules under `#providers/` and validated
20
20
  * at import
21
- * - the profile's own manifests in `data/<profile>/providers.d/*.yaml`, validated on load
21
+ * - the workspace's own manifests in `data/providers.d/*.yaml`, validated on load
22
+ * — the workspace's, not a profile's, because a manifest defines a
23
+ * connection and connections stopped belonging to profiles (ADR-057)
22
24
  *
23
25
  * That second one is the scalability claim. A service nobody has integrated is
24
26
  * a YAML file the operator writes, not a pull request they wait on.
@@ -74,25 +76,44 @@ export const providerManifestSchema = z.object({
74
76
  export type ProviderManifest = z.infer<typeof providerManifestSchema>;
75
77
 
76
78
  /**
77
- * Provider ids reserved for the owner layer.
79
+ * The owner layer's provider ids Lanes' own surfaces.
80
+ *
81
+ * **`lanes_` on each, which is what stops them needing to be reserved.** They
82
+ * were `memory`, `tasks`, `assets`, `skills`, `vault`, `entities` — six of the
83
+ * most obvious words a vendor manifest might want, held back from every
84
+ * operator so the built-ins could have them. `buildRegistry` registers these
85
+ * before `PROVIDERS`, so a manifest claiming one threw at startup rather than
86
+ * being shadowed (ADR-051); the reservation is what made that a refusal instead
87
+ * of a collision. Prefixed, there is nothing to reserve: an operator's own
88
+ * `memory` connector is now a legal thing to declare.
89
+ *
90
+ * It is also the shape the vendor-qualified providers already use —
91
+ * `google_tasks`, `gmail_imap`, `icloud_mail` — and it reads the same way: the
92
+ * half before the underscore says whose surface this is.
78
93
  *
79
94
  * The order is read: `#server/mcp`'s instructions emit one paragraph per
80
95
  * reachable id in this sequence, so it is the order an agent meets them in.
81
- * `entities` is appended rather than inserted alphabetically so that it lands
82
- * beside `identity`: the two answer the same question about different people,
83
- * and the instructions collapse them into one paragraph when both are reachable.
96
+ * `lanes_entities` is appended rather than inserted alphabetically so that it
97
+ * lands beside `lanes_identity`: the two answer the same question about
98
+ * different people, and the instructions collapse them into one paragraph when
99
+ * both are reachable.
84
100
  */
85
101
  export const RESERVED_PROVIDER_IDS: readonly string[] = [
86
- 'memory',
87
- 'tasks',
88
- 'assets',
89
- 'skills',
90
- 'vault',
91
- 'setup',
92
- 'identity',
93
- 'entities',
102
+ 'lanes_memory',
103
+ 'lanes_tasks',
104
+ 'lanes_assets',
105
+ 'lanes_skills',
106
+ 'lanes_vault',
107
+ 'lanes_setup',
108
+ 'lanes_identity',
109
+ 'lanes_entities',
94
110
  ];
95
111
 
112
+ /** Old id to new, for the contract-4 migration and for a refusal that names it. */
113
+ export const RENAMED_OWNER_PROVIDERS: ReadonlyMap<string, string> = new Map(
114
+ RESERVED_PROVIDER_IDS.map((id) => [id.slice('lanes_'.length), id]),
115
+ );
116
+
96
117
  /**
97
118
  * Validate a manifest, with the cross-field rules the schema alone cannot
98
119
  * express.
@@ -76,7 +76,7 @@ export interface SetupNeeds {
76
76
  function storeCommand(ref: string, placeholder: string, where: Selection): string {
77
77
  return (
78
78
  `printf %s "${placeholder}" | lanes link secrets set ${ref}` +
79
- ` --profile ${where.profile} --target ${where.target}`
79
+ ` --profile ${where.profile} --workspace ${where.target}`
80
80
  );
81
81
  }
82
82
 
@@ -16,6 +16,30 @@
16
16
  const CR = 0x0d;
17
17
  const LF = 0x0a;
18
18
 
19
+ /**
20
+ * The most this will accumulate before refusing a response.
21
+ *
22
+ * A literal's length is a number the *server* writes — `{1234}` — and the
23
+ * reader's job is to wait until that many bytes have arrived. With no ceiling
24
+ * that is an allocation an upstream decides the size of, and the upstream is
25
+ * not always one this endpoint chose: a connector names its own host, so a
26
+ * connection pointed at a hostile or compromised server could announce a
27
+ * literal of any size and be believed.
28
+ *
29
+ * Sixty-four mebibytes because that is already the ceiling on the other side of
30
+ * the same journey — `MAX_UPLOAD_BYTES` in `server/attachments.ts` — and a
31
+ * message larger than the largest attachment this endpoint will accept is not
32
+ * one it can do anything useful with. Every mail host's own limit is well below
33
+ * it, so this is never what refuses a legitimate read.
34
+ *
35
+ * Applied twice, and both are needed. The announced length is refused up front,
36
+ * so an absurd number costs nothing rather than being discovered after the
37
+ * bytes arrive. The accumulated buffer is refused too, because one response may
38
+ * announce several literals and a server that never completes a response would
39
+ * otherwise grow this without ever announcing anything unreasonable.
40
+ */
41
+ export const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
42
+
19
43
  /** A parsed element of a response. */
20
44
  export type ImapToken =
21
45
  | { readonly kind: 'atom'; readonly value: string }
@@ -32,27 +56,56 @@ export type ImapToken =
32
56
  */
33
57
  export class ResponseAssembler {
34
58
  #buffer = new Uint8Array(0);
35
-
59
+ /** Bytes in use. The buffer is grown ahead of this and is not a length. */
60
+ #length = 0;
61
+
62
+ /**
63
+ * Take a chunk off the socket.
64
+ *
65
+ * Capacity doubles rather than growing to fit. It used to allocate exactly
66
+ * `held + chunk` and copy everything across on every chunk, which is
67
+ * quadratic in the size of a response — a thirty-megabyte message arriving in
68
+ * sixty-four-kilobyte pieces copied several gigabytes to assemble, and that
69
+ * was the *legitimate* case. Doubling makes each byte move a constant number
70
+ * of times.
71
+ */
36
72
  push(chunk: Uint8Array): void {
37
- const merged = new Uint8Array(this.#buffer.length + chunk.length);
38
- merged.set(this.#buffer);
39
- merged.set(chunk, this.#buffer.length);
40
- this.#buffer = merged;
73
+ const needed = this.#length + chunk.length;
74
+ if (needed > MAX_RESPONSE_BYTES) {
75
+ throw new Error(
76
+ `The server sent more than ${MAX_RESPONSE_BYTES} bytes without completing a response.`,
77
+ );
78
+ }
79
+
80
+ if (needed > this.#buffer.length) {
81
+ let capacity = Math.max(this.#buffer.length, 8192);
82
+ while (capacity < needed) capacity *= 2;
83
+
84
+ const grown = new Uint8Array(Math.min(capacity, MAX_RESPONSE_BYTES));
85
+ grown.set(this.#buffer.subarray(0, this.#length));
86
+ this.#buffer = grown;
87
+ }
88
+
89
+ this.#buffer.set(chunk, this.#length);
90
+ this.#length = needed;
41
91
  }
42
92
 
43
93
  /** The next complete response, or undefined while more bytes are needed. */
44
94
  next(): Uint8Array | undefined {
45
- const end = completeResponseEnd(this.#buffer);
95
+ const end = completeResponseEnd(this.#buffer.subarray(0, this.#length));
46
96
  if (end === undefined) return undefined;
47
97
 
48
- const response = this.#buffer.subarray(0, end);
49
- this.#buffer = this.#buffer.slice(end);
98
+ // A copy, not a view: the remainder is shifted down in place below, which
99
+ // would otherwise rewrite the bytes underneath the response just returned.
100
+ const response = this.#buffer.slice(0, end);
101
+ this.#buffer.copyWithin(0, end, this.#length);
102
+ this.#length -= end;
50
103
  return response;
51
104
  }
52
105
 
53
106
  /** Whatever has arrived but does not yet form a response. For diagnostics. */
54
107
  get pending(): number {
55
- return this.#buffer.length;
108
+ return this.#length;
56
109
  }
57
110
  }
58
111
 
@@ -67,6 +120,14 @@ function completeResponseEnd(buffer: Uint8Array): number | undefined {
67
120
  const announced = literalLength(buffer, cursor, crlf);
68
121
  if (announced === undefined) return crlf + 2;
69
122
 
123
+ // Refused on the announcement rather than after the bytes turn up: the
124
+ // number is the server's, and believing an absurd one means waiting for it.
125
+ if (announced > MAX_RESPONSE_BYTES) {
126
+ throw new Error(
127
+ `The server announced a ${announced}-byte literal, over the ${MAX_RESPONSE_BYTES}-byte limit.`,
128
+ );
129
+ }
130
+
70
131
  const afterLiteral = crlf + 2 + announced;
71
132
  if (buffer.length < afterLiteral) return undefined;
72
133
 
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
4
- import type { BlobKey, BlobMetadata, BlobStore } from '#stores/blobs';
4
+ import { containedKey, type BlobKey, type BlobMetadata, type BlobStore } from '#stores/blobs';
5
5
 
6
6
  /**
7
7
  * Filesystem blob store — the `local` target's adapter.
@@ -54,12 +54,27 @@ export function createFilesystemBlobStore(options: FilesystemBlobStoreOptions):
54
54
  * this adapter is also usable directly, and a containment check belongs at
55
55
  * the point where a path actually becomes a filesystem operation. Defence in
56
56
  * depth is cheap; a provider escaping its directory is not.
57
+ *
58
+ * **The rule is `containedKey`'s, not this file's.** It used to be a copy,
59
+ * and the copy did not agree: it tested `rel.startsWith('..')` where it meant
60
+ * "the first segment is `..`", so a key beginning with two dots and
61
+ * continuing — an ordinary name — was refused here and accepted by every
62
+ * other adapter. That is the divergence `conformance.ts` exists to prevent,
63
+ * and it was reachable from an ordinary argument, because a provider passes
64
+ * a caller's name straight through as a key.
65
+ *
66
+ * Kept as a resolve-then-join rather than deferring the whole path: the
67
+ * shared rule answers "does this land inside", and this adapter still has to
68
+ * turn the answer into a filesystem path.
57
69
  */
58
70
  const pathFor = (key: BlobKey): string => {
59
- const resolved = resolve(root, key);
71
+ const resolved = resolve(root, containedKey(key));
60
72
  const rel = relative(root, resolved);
61
73
 
62
- if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`)) {
74
+ // Unreachable once `containedKey` has answered, and kept because this is
75
+ // the line where a path becomes a filesystem operation: a future change to
76
+ // either side should fail here rather than escape.
77
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`)) {
63
78
  throw new Error(`Blob key resolves outside the store root: ${key}`);
64
79
  }
65
80
  return resolved;
@@ -121,7 +121,7 @@ export async function bindConnectionCredentials(input: {
121
121
  `could not bind ${connection.provider}.${connection.id}'s credential to ` +
122
122
  `${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
123
123
  'rotate it — which fails about an hour after the first use. ' +
124
- `Run \`lanes link deploy --target ${input.target}\` to bind it. ` +
124
+ `Run \`lanes link deploy --workspace ${input.target}\` to bind it. ` +
125
125
  `(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
126
126
  };
127
127
  }