@lanes-sh/link 0.7.1 → 0.8.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 (157) 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 +136 -61
  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 +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/brand.ts +39 -10
  22. package/src/cli/callback-page.ts +37 -104
  23. package/src/cli/commands/auth-dispatch.ts +48 -0
  24. package/src/cli/commands/auth.ts +229 -0
  25. package/src/cli/commands/connect/accounts.ts +4 -4
  26. package/src/cli/commands/connect/authorise.ts +4 -4
  27. package/src/cli/commands/connect/bind-credential.ts +2 -1
  28. package/src/cli/commands/connect/custom/index.ts +1 -1
  29. package/src/cli/commands/connect/custom/write.ts +2 -2
  30. package/src/cli/commands/connect/grant.ts +29 -14
  31. package/src/cli/commands/connect/index.ts +88 -87
  32. package/src/cli/commands/connect/options.ts +83 -0
  33. package/src/cli/commands/connect/registration.ts +50 -0
  34. package/src/cli/commands/connect/requirements.ts +1 -1
  35. package/src/cli/commands/connect/settle.ts +4 -2
  36. package/src/cli/commands/connect/target-note.ts +7 -2
  37. package/src/cli/commands/connect/unknown.ts +1 -1
  38. package/src/cli/commands/connect/variables.ts +3 -2
  39. package/src/cli/commands/connection-list.ts +116 -0
  40. package/src/cli/commands/connection.ts +182 -165
  41. package/src/cli/commands/grant.ts +140 -0
  42. package/src/cli/commands/identity.ts +21 -9
  43. package/src/cli/commands/knowledge/index.ts +46 -79
  44. package/src/cli/commands/knowledge/migrate.ts +74 -13
  45. package/src/cli/commands/knowledge/show.ts +92 -0
  46. package/src/cli/commands/knowledge.ts +2 -1
  47. package/src/cli/commands/mcp/harnesses.ts +30 -8
  48. package/src/cli/commands/mcp/onboarding.ts +86 -0
  49. package/src/cli/commands/mcp/register.ts +16 -2
  50. package/src/cli/commands/mcp.ts +1 -0
  51. package/src/cli/commands/members.ts +288 -0
  52. package/src/cli/commands/operate/attach.ts +3 -3
  53. package/src/cli/commands/operate/audit.ts +11 -7
  54. package/src/cli/commands/operate/auth.ts +28 -11
  55. package/src/cli/commands/operate/findings.ts +2 -1
  56. package/src/cli/commands/operate/inspect.ts +37 -19
  57. package/src/cli/commands/operate/migrate.ts +29 -12
  58. package/src/cli/commands/operate/outputs.ts +3 -3
  59. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  60. package/src/cli/commands/operate/pair.ts +324 -0
  61. package/src/cli/commands/operate/policy.ts +73 -22
  62. package/src/cli/commands/operate/serve.ts +52 -4
  63. package/src/cli/commands/operate/status.ts +18 -10
  64. package/src/cli/commands/operate/tools.ts +2 -2
  65. package/src/cli/commands/operate.ts +2 -0
  66. package/src/cli/commands/owner/shared.ts +13 -2
  67. package/src/cli/commands/owner/skills.ts +28 -8
  68. package/src/cli/commands/profile/removal.ts +79 -76
  69. package/src/cli/commands/profile/remove.ts +16 -1
  70. package/src/cli/commands/profile.ts +46 -10
  71. package/src/cli/commands/relabel.ts +112 -0
  72. package/src/cli/commands/secrets.ts +34 -12
  73. package/src/cli/commands/set-workspace.ts +96 -0
  74. package/src/cli/commands/setup.ts +2 -2
  75. package/src/cli/commands/sync.ts +8 -8
  76. package/src/cli/commands/target.ts +9 -7
  77. package/src/cli/commands/update.ts +58 -17
  78. package/src/cli/config-edit.ts +75 -140
  79. package/src/cli/config-migrate.ts +82 -64
  80. package/src/cli/config-repair.ts +89 -32
  81. package/src/cli/config-templates.ts +198 -0
  82. package/src/cli/contract3-data.ts +328 -0
  83. package/src/cli/contract3-shape.ts +186 -0
  84. package/src/cli/contract3.ts +282 -0
  85. package/src/cli/endpoint-url.ts +1 -1
  86. package/src/cli/lanes.ts +25 -1
  87. package/src/cli/main.ts +89 -14
  88. package/src/cli/migrate-plan.ts +12 -6
  89. package/src/cli/output.ts +34 -1
  90. package/src/cli/publish.ts +5 -2
  91. package/src/cli/runtime/open.ts +63 -98
  92. package/src/cli/runtime/registry.ts +6 -7
  93. package/src/cli/runtime/stores.ts +53 -0
  94. package/src/cli/runtime/types.ts +106 -0
  95. package/src/cli/runtime/vault.ts +19 -4
  96. package/src/cli/runtime/workspace.ts +60 -0
  97. package/src/cli/runtime.ts +2 -1
  98. package/src/cli/selection-require.ts +44 -13
  99. package/src/cli/selection.ts +127 -145
  100. package/src/cli/usage.ts +34 -18
  101. package/src/cli/workspace-migrate.ts +125 -16
  102. package/src/connectivity/manifest/provider.ts +3 -1
  103. package/src/connectivity/manifest/requirements.ts +1 -1
  104. package/src/deployments/bind.ts +1 -1
  105. package/src/deployments/deploy.ts +36 -27
  106. package/src/deployments/gcp/bucket.ts +18 -7
  107. package/src/deployments/gcp/provision.ts +7 -7
  108. package/src/deployments/prepare.ts +72 -24
  109. package/src/deployments/record.ts +1 -1
  110. package/src/deployments/report.ts +2 -2
  111. package/src/deployments/serving.ts +15 -74
  112. package/src/deployments/target.ts +15 -15
  113. package/src/deployments/upload.ts +46 -22
  114. package/src/dispatch/deps.ts +88 -0
  115. package/src/dispatch/dispatch.ts +21 -62
  116. package/src/policy/index.ts +47 -15
  117. package/src/profile/connections.ts +183 -0
  118. package/src/profile/deployments.ts +3 -3
  119. package/src/profile/index.ts +30 -5
  120. package/src/profile/layout.ts +86 -89
  121. package/src/profile/load.ts +80 -47
  122. package/src/profile/pairing.ts +32 -0
  123. package/src/profile/primitives.ts +35 -1
  124. package/src/profile/registry.ts +6 -6
  125. package/src/profile/schema.ts +172 -21
  126. package/src/profile/targets.ts +21 -9
  127. package/src/profile/testing.ts +69 -2
  128. package/src/profile/workspace.ts +58 -3
  129. package/src/providers/custom/index.ts +1 -1
  130. package/src/providers/custom/load.ts +2 -3
  131. package/src/providers/identity/provider.ts +1 -1
  132. package/src/providers/memory/provider.ts +20 -2
  133. package/src/providers/setup/plan.ts +1 -1
  134. package/src/providers/slack/index.ts +2 -2
  135. package/src/registry/policy-bridge.ts +33 -11
  136. package/src/registry/reconcile.ts +4 -4
  137. package/src/server/authorization.ts +94 -0
  138. package/src/server/edge.ts +14 -1
  139. package/src/server/endpoint.ts +85 -104
  140. package/src/server/generation.ts +10 -1
  141. package/src/server/harness.ts +71 -13
  142. package/src/server/index.ts +31 -0
  143. package/src/server/mcp/build.ts +20 -1
  144. package/src/server/mcp/client-info.ts +54 -0
  145. package/src/server/mcp/guide.ts +120 -0
  146. package/src/server/mcp/instructions.ts +1 -1
  147. package/src/server/mcp/prompts.ts +7 -3
  148. package/src/server/mcp/resources.ts +16 -8
  149. package/src/server/mcp/tools.ts +9 -3
  150. package/src/server/mcp/visibility.ts +18 -3
  151. package/src/server/oauth.ts +29 -75
  152. package/src/server/read/credential.ts +134 -0
  153. package/src/server/read/deployed.ts +56 -0
  154. package/src/server/read/listener.ts +54 -0
  155. package/src/server/read/open.ts +101 -0
  156. package/src/server/read/routes.ts +247 -0
  157. package/src/server/read/state.ts +171 -0
@@ -1,7 +1,14 @@
1
1
  import { rename, writeFile } from 'node:fs/promises';
2
2
  import { Document, parseDocument, type Node } from 'yaml';
3
3
  import {
4
+ CONNECTIONS_FILE,
4
5
  ConfigError,
6
+ WORKSPACE_FILE,
7
+ workspaceSchema,
8
+ assertConnectionsUnique,
9
+ connectionsFileSchema,
10
+ findSecrets,
11
+ formatSecretFindings,
5
12
  isRemoteWorkspace,
6
13
  readWorkspaceFile,
7
14
  validateConfig,
@@ -24,6 +31,53 @@ import {
24
31
  * refuses to run.
25
32
  */
26
33
 
34
+ /**
35
+ * Validate a document against the schema for the file it is.
36
+ *
37
+ * The key is the discriminator because it is the only thing that is always
38
+ * right: a caller could be asked to say which shape it holds, and a caller that
39
+ * said the wrong one would get the wrong check silently.
40
+ */
41
+ function validateDocument(
42
+ raw: unknown,
43
+ path: string,
44
+ key: string | undefined,
45
+ options: { shapeOnly?: boolean },
46
+ ): void {
47
+ if (key === WORKSPACE_FILE) {
48
+ const parsed = workspaceSchema.safeParse(raw);
49
+ if (!parsed.success) {
50
+ throw new ConfigError(
51
+ `${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
52
+ );
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (key === CONNECTIONS_FILE) {
58
+ const secrets = findSecrets(raw);
59
+ if (secrets.length > 0) {
60
+ throw new ConfigError(
61
+ `${path}: ${formatSecretFindings(secrets)}`,
62
+ secrets.map((finding) => finding.path),
63
+ );
64
+ }
65
+
66
+ const parsed = connectionsFileSchema.safeParse(raw);
67
+ if (!parsed.success) {
68
+ throw new ConfigError(
69
+ `${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
70
+ );
71
+ }
72
+
73
+ assertConnectionsUnique(parsed.data.connections);
74
+ return;
75
+ }
76
+
77
+ if (options.shapeOnly === true) validateConfigShape(raw, path);
78
+ else validateConfig(raw, path);
79
+ }
80
+
27
81
  export class ConfigDocument {
28
82
  readonly #document: Document;
29
83
  readonly #path: string;
@@ -47,7 +101,20 @@ export class ConfigDocument {
47
101
  * `gs://` URL produces something that addresses nothing.
48
102
  */
49
103
  static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
50
- const key = `profiles/${profile}.yaml`;
104
+ return ConfigDocument.openKey(workspaceRoot, `profiles/${profile}.yaml`);
105
+ }
106
+
107
+ /**
108
+ * Open any document the workspace holds, by key.
109
+ *
110
+ * `open` above is this with the profile path spelled out, and stays because
111
+ * it is what almost every caller wants. This exists for `connections.yaml`,
112
+ * which is a workspace document rather than a profile's (ADR-057) and needs
113
+ * the same comment-preserving edit path — a connection row carries the
114
+ * account label an operator wrote, and rewriting the file through the schema
115
+ * would drop every comment beside it.
116
+ */
117
+ static async openKey(workspaceRoot: string, key: string): Promise<ConfigDocument> {
51
118
  const shown = isRemoteWorkspace(workspaceRoot)
52
119
  ? `${workspaceRoot}/${key}`
53
120
  : `${workspaceRoot}/${key}`;
@@ -200,8 +267,13 @@ export class ConfigDocument {
200
267
 
201
268
  // Throws on any validation failure, including a credential value that has
202
269
  // crept in — so a CLI edit can never introduce one.
203
- if (options.shapeOnly === true) validateConfigShape(this.#document.toJSON(), this.#path);
204
- else validateConfig(this.#document.toJSON(), this.#path);
270
+ //
271
+ // Which schema, decided by the key rather than by the caller. This class
272
+ // edits two shapes now: a profile, and the workspace's `connections.yaml`
273
+ // (ADR-057). Validating one against the other's schema is not a stricter
274
+ // check, it is the wrong one — a connections file has no `instance:` block,
275
+ // so it would be refused for a field it is not supposed to have.
276
+ validateDocument(this.#document.toJSON(), this.#path, this.#location?.key, options);
205
277
 
206
278
  if (!this.#location) {
207
279
  throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
@@ -234,140 +306,3 @@ export class ConfigDocument {
234
306
  * Written with comments, because this is the file an operator will read first
235
307
  * and most of what it needs to say is *why*, not *what*.
236
308
  */
237
- export function newProfileTemplate(profile: string, port: number): string {
238
- return `# Lanes Link profile: ${profile}
239
- #
240
- # This file is the source of truth for what exists. It never contains a
241
- # credential value — only "_ref" pointers into the credential store, which
242
- # lives beside it and is encrypted at rest.
243
- #
244
- # Edit it by hand or through the CLI; both are supported, and CLI edits
245
- # preserve your comments and ordering.
246
- contract: 2
247
-
248
- instance:
249
- profile: ${profile}
250
- port: ${port}
251
- host: 127.0.0.1
252
-
253
- # This file says nothing about where it runs, and that is the point.
254
- #
255
- # A profile lives in exactly one target, and the target is the workspace holding
256
- # this file — which declares its own adapters, once, in lanes-link.yaml beside
257
- # the profiles/ directory (ADR-052). Moving this profile somewhere else is
258
- # copying the file there; there is no block in it to edit.
259
- #
260
- # Every command still names both, because neither is inferred (ADR-037):
261
- #
262
- # lanes link status --profile ${profile} --target <name>
263
- #
264
- # The bearer token for the endpoint this profile serves.
265
- #
266
- # "lanes link start" serves every profile in the workspace from one URL, and this
267
- # token is what opens it — so it admits every profile that "lanes link outputs"
268
- # lists, not only this one. Each call names the profile it means. Run a separate
269
- # workspace if you need a token that cannot reach them all.
270
- auth:
271
- mode: bearer
272
- token_ref: profile/token
273
-
274
- limits:
275
- requests_per_minute: 120 # per profile
276
- upstream_calls_per_minute: 60 # per connection, protects vendor quota
277
-
278
- # App registrations, shared by every connection of that vendor.
279
- oauth_apps: {}
280
-
281
- # One entry per authorised account. "account" is the identity the provider
282
- # reports — an address, a workspace — so this list says whose data is reachable
283
- # without having to look anything up.
284
- #
285
- # The seven below hold no account, and that is why they are here already: they
286
- # reach your own material rather than anybody's API, so there was never anything
287
- # for a connect step to authorise (ADR-050). What each one is:
288
- #
289
- # memory what you want remembered between sessions
290
- # tasks what you have to do, each with a status
291
- # assets files you want kept, by name
292
- # skills procedures you have written, handed to an agent as instructions
293
- # vault passwords and API keys, released one at a time
294
- # setup what is connected here, and what connecting more would take
295
- # entities the people, companies and projects you deal with, and how to
296
- # reach each of them — so an agent looks an address up rather
297
- # than recalling one
298
- #
299
- # Nothing is stored in any of them until you or an agent puts something there,
300
- # and none of them can read an account. To switch one off, deny it below —
301
- # deleting the entry no longer works, because the next connect or deploy puts it
302
- # back.
303
- connections:
304
- - { id: main, provider: memory, account: Memory }
305
- - { id: main, provider: tasks, account: Tasks }
306
- - { id: main, provider: assets, account: Assets }
307
- - { id: main, provider: skills, account: Skills }
308
- - { id: main, provider: vault, account: Vault }
309
- - { id: main, provider: setup, account: Setup }
310
- - { id: main, provider: entities, account: Entities }
311
-
312
- # Only what is listed here is reachable, and an empty policy grants nothing.
313
- #
314
- # Rules name capabilities, never accounts: "gmail.*" covers every Gmail
315
- # connection in this profile. To grant two accounts differently, run a second
316
- # profile — profiles share no database and no credential store. They do now
317
- # share an endpoint and its token, so that separation is enforced per call
318
- # rather than per URL.
319
- #
320
- # allow: ['*'] everything, which is what connect writes
321
- # allow: [notion.*, gmail.*] two providers
322
- # deny: [gmail.send_message] a deny always beats an allow
323
- #
324
- # The rules below grant each of the seven its whole namespace, writes included —
325
- # the same thing "connect memory" wrote when it was a command you had to run.
326
- # Narrowing is one line, and these are the three worth knowing:
327
- #
328
- # deny: [memory.write, memory.forget] memory becomes read-only
329
- # deny: [skills.manage.*] skills can be invoked but not authored
330
- # deny: [vault.put, vault.remove] nothing new can be stored
331
- # deny: [entities.write, entities.link, entities.forget]
332
- # entities becomes read-only
333
- #
334
- # Those lists are exhaustive on purpose: a namespace is read-only only when
335
- # every capability that changes something is named, so "deny: [memory.write]"
336
- # alone leaves "memory.forget" granted.
337
- #
338
- # A vault read is not granted by "vault.*" alone: each stored item is its own
339
- # "vault.get.<id>" capability and only appears after a restart, so a write can
340
- # never hand itself a read (ADR-012).
341
- policy:
342
- allow: [memory.*, tasks.*, assets.*, skills.*, vault.*, setup.*, entities.*]
343
- deny: []
344
- `;
345
- }
346
-
347
- export function newWorkspaceTemplate(): string {
348
- return `# Lanes Link workspace
349
- #
350
- # A workspace holds one or more profiles, and one endpoint serves all of them:
351
- # every call names the profile it means, with --profile. Profiles never share a
352
- # database or a credential store, so what one holds is invisible to another.
353
- #
354
- # This workspace IS a target. "targets:" below says where its bytes go, once,
355
- # for every profile in it — a profile says nothing about where it runs, so
356
- # there is one copy of it and nothing to keep in step (ADR-052).
357
- #
358
- # A target somewhere else is a pointer, and "deploy" writes one:
359
- #
360
- # targets:
361
- # cloud:
362
- # workspace: gs://your-bucket
363
- #
364
- # The workspace at that address declares its own adapters, and is the only
365
- # thing that does. Reading it is a network call, which is why "--target cloud"
366
- # needs that bucket reachable.
367
- contract: 2
368
- targets:
369
- local:
370
- credentials: { adapter: file }
371
- storage: { adapter: filesystem }
372
- `;
373
- }
@@ -91,10 +91,12 @@ export function pendingRenames(document: ConfigDocument): PendingRename[] {
91
91
  * connection that lost its authorisation for no reason anyone can see.
92
92
  */
93
93
  export async function migrateRenamedProviders(
94
- document: ConfigDocument,
94
+ connections: ConfigDocument,
95
+ profiles: readonly ConfigDocument[],
95
96
  credentials: SecretStore,
96
97
  options: { apply: boolean },
97
98
  ): Promise<RenameMigration> {
99
+ const document = connections;
98
100
  const rows = pendingRenames(document);
99
101
  if (rows.length === 0) return { rows, changes: [], blocked: [] };
100
102
 
@@ -116,15 +118,14 @@ export async function migrateRenamedProviders(
116
118
  }
117
119
  }
118
120
 
119
- // The policy rules second, because whether one can move depends on what is
120
- // left declaring the old id once the rows above have.
121
- for (const [from, to] of new Map(accepted.map((row) => [row.from, row.to]))) {
122
- const policy = renamePolicyRules(document, { from, to }, {
123
- stillDeclared: keepsDeclaring(document, from, accepted),
124
- apply: options.apply,
125
- });
126
- changes.push(...policy.changes);
127
- blocked.push(...policy.blocked);
121
+ // The grant rules second, because whether one can move depends on what is
122
+ // left declaring the old id once the rows above have — and because they live
123
+ // in the profiles now rather than beside the connection (ADR-057), so this is
124
+ // one pass per profile over a rename computed once.
125
+ for (const row of accepted) {
126
+ for (const profile of profiles) {
127
+ changes.push(...renameGrantRules(profile, row, { apply: options.apply }).changes);
128
+ }
128
129
  }
129
130
 
130
131
  if (!options.apply || accepted.length === 0) return { rows, changes, blocked };
@@ -137,8 +138,11 @@ export async function migrateRenamedProviders(
137
138
  }
138
139
 
139
140
  // Throws unless the result is a config that loads, which is the assertion
140
- // worth having here — the whole premise was that it did not.
141
+ // worth having here — the whole premise was that it did not. Both files, in
142
+ // the order that survives a crash between them: a grant naming a connection
143
+ // that has not been renamed yet is refused at load, so the profiles go last.
141
144
  await document.save();
145
+ if (options.apply) for (const profile of profiles) await profile.save();
142
146
 
143
147
  for (const row of accepted) await credentials.delete(`${row.from}/${row.id}`);
144
148
 
@@ -146,7 +150,7 @@ export async function migrateRenamedProviders(
146
150
  }
147
151
 
148
152
  /**
149
- * Rewrite the policy rules that named the old id, where that is unambiguous.
153
+ * Rewrite the grant rules that named the old id, where that is unambiguous.
150
154
  *
151
155
  * A rule names a provider and never an account, so `tasks.*` written for Google
152
156
  * Tasks has to follow the rename or the migrated connection is granted nothing
@@ -154,66 +158,80 @@ export async function migrateRenamedProviders(
154
158
  * consulted, so the repair would land a row that serves exactly as little as
155
159
  * the broken one did.
156
160
  *
157
- * But a profile declaring *both* a Google Tasks row and the built-in has one
158
- * rule serving two providers, and moving it would silently revoke the one that
159
- * kept its name. That profile keeps its rule and is told to add the second,
160
- * which is a sentence rather than a guess at which was meant.
161
+ * The ambiguity this used to guard against is gone, and it is worth saying why.
162
+ * A rule lived in one flat block and named a provider, so a profile holding a
163
+ * Google Tasks row *and* the built-in had one `tasks.*` serving both moving it
164
+ * would silently revoke whichever kept its name, so the migration reported
165
+ * rather than guessed. A rule lives inside the row that names one connection
166
+ * now (ADR-058), so `tasks.*` on a `google_tasks` row is not ambiguous: it is
167
+ * invalid, and `assertReferentialIntegrity` refuses it. There is nothing left to
168
+ * decide.
161
169
  *
162
- * Both lists. A `deny` written to switch Google Tasks off means it as firmly as
163
- * an allow means it on, and leaving it behind would re-enable something the
164
- * operator turned off.
170
+ * Both lists, on every row. A `deny` written to switch Google Tasks off means it
171
+ * as firmly as an allow means it on, and leaving it behind would re-enable
172
+ * something the operator turned off.
173
+ *
174
+ * The row's `connection` moves too, and that is new: a grant names the account
175
+ * rather than the provider now (ADR-058), so `tasks.main` becomes
176
+ * `google_tasks.main` or the grant resolves to nothing.
165
177
  */
166
- function renamePolicyRules(
178
+ function renameGrantRules(
167
179
  document: ConfigDocument,
168
- provider: { from: string; to: string },
169
- options: { stillDeclared: boolean; apply: boolean },
170
- ): { changes: string[]; blocked: string[] } {
180
+ moved: { from: string; to: string; id: string; key: string },
181
+ options: { apply: boolean },
182
+ ): { changes: string[] } {
171
183
  const changes: string[] = [];
172
- const blocked: string[] = [];
173
184
 
174
- for (const field of ['allow', 'deny'] as const) {
175
- const rules = document.getIn(['policy', field]) as { items?: unknown[] } | null;
176
-
177
- (rules?.items ?? []).forEach((_item, index) => {
178
- // Either spelling: a bare pattern, or `{ capability, expires_at }`. The
179
- // path to it differs; the decision does not.
180
- const bare = document.getIn(['policy', field, index]);
181
- const path =
182
- typeof bare === 'string'
183
- ? (['policy', field, index] as const)
184
- : (['policy', field, index, 'capability'] as const);
185
-
186
- const capability = typeof bare === 'string' ? bare : document.getIn(path);
187
- if (typeof capability !== 'string') return;
188
-
189
- const [named, ...rest] = capability.split('.');
190
- if (named !== provider.from) return;
191
-
192
- const moved = [provider.to, ...rest].join('.');
193
-
194
- if (options.stillDeclared) {
195
- blocked.push(
196
- `policy.${field} keeps "${capability}" — this profile still declares a ` +
197
- `"${provider.from}" connection, so add "${moved}" rather than moving it`,
198
- );
199
- return;
200
- }
201
-
202
- if (options.apply) document.setIn(path, moved);
203
- changes.push(`policy.${field}: ${capability} → ${moved}`);
204
- });
205
- }
185
+ const provider = { from: moved.from, to: moved.to };
186
+ const grants = document.getIn(['grants']) as { items?: unknown[] } | null;
187
+
188
+ (grants?.items ?? []).forEach((_row, at) => {
189
+ const connection = document.getIn(['grants', at, 'connection']);
190
+ if (typeof connection !== 'string') return;
191
+
192
+ // The *exact* connection that moved, not every grant naming the old
193
+ // provider. A workspace holding a Google Tasks row beside the built-in has
194
+ // two `tasks.` grants and only one of them is being renamed — moving both
195
+ // would point the built-in's grant at a connection that does not exist.
196
+ if (connection !== moved.key) return;
197
+
198
+ // The `connection` field always follows, and this is the one place the
199
+ // ambiguity does not reach. It names the exact row being renamed, so there
200
+ // is nothing to guess — and leaving it behind would point the grant at a
201
+ // connection that no longer exists, which `assertGrantsResolve` refuses at
202
+ // load. The rename would have made the workspace unopenable.
203
+ const renamedConnection = `${provider.to}.${moved.id}`;
204
+ if (options.apply) document.setIn(['grants', at, 'connection'], renamedConnection);
205
+ changes.push(`grants[${at}].connection: ${connection} → ${renamedConnection}`);
206
206
 
207
- return { changes, blocked };
207
+ for (const field of ['allow', 'deny'] as const) {
208
+ const rules = document.getIn(['grants', at, field]) as { items?: unknown[] } | null;
209
+
210
+ (rules?.items ?? []).forEach((_item, index) => {
211
+ // Either spelling: a bare pattern, or `{ capability, expires_at }`. The
212
+ // path to it differs; the decision does not.
213
+ const bare = document.getIn(['grants', at, field, index]);
214
+ const path =
215
+ typeof bare === 'string'
216
+ ? (['grants', at, field, index] as const)
217
+ : (['grants', at, field, index, 'capability'] as const);
218
+
219
+ const capability = typeof bare === 'string' ? bare : document.getIn(path);
220
+ if (typeof capability !== 'string') return;
221
+
222
+ const [head, ...rest] = capability.split('.');
223
+ if (head !== provider.from) return;
224
+
225
+ const renamed = [provider.to, ...rest].join('.');
226
+ if (options.apply) document.setIn(path, renamed);
227
+ changes.push(`grants[${at}].${field}: ${capability} → ${renamed}`);
228
+ });
229
+ }
230
+ });
231
+
232
+ return { changes };
208
233
  }
209
234
 
210
- /**
211
- * Whether a row naming the old provider survives the migration.
212
- *
213
- * Computed against the accepted set rather than by re-reading the document,
214
- * because the same answer has to hold on a report-only run, where nothing has
215
- * been rewritten yet.
216
- */
217
235
  function keepsDeclaring(
218
236
  document: ConfigDocument,
219
237
  provider: string,
@@ -1,4 +1,5 @@
1
- import { listProfiles } from '#profile';
1
+ import { newConnectionsTemplate } from './config-templates.ts';
2
+ import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
2
3
  import { ConfigDocument } from './config-edit.ts';
3
4
  import { ok, print, style, warn } from './output.ts';
4
5
 
@@ -100,61 +101,97 @@ export interface SurfaceRepair {
100
101
  * (ADR-023) so it could not write this even if the code let it.
101
102
  */
102
103
  export function ensureReservedConnection(
103
- document: ConfigDocument,
104
+ connections: ConfigDocument,
105
+ profile: ConfigDocument,
104
106
  provider: ReservedSurface,
107
+ options: { grants?: boolean } = {},
105
108
  ): SurfaceRepair {
109
+ // Whether the profile half is written at all. `connect` without `--profile`
110
+ // repairs the workspace's connection rows and touches no profile, because it
111
+ // was not told which one to touch (ADR-057).
112
+ const grants = options.grants ?? true;
106
113
  // Raw YAML, so nothing here has been through a schema: this runs over sibling
107
114
  // profiles that were never validated, and every field is whatever was typed.
108
- const config = document.toJSON() as {
109
- connections?: unknown;
110
- policy?: { allow?: unknown; deny?: unknown };
111
- } | null;
115
+ const workspace = connections.toJSON() as { connections?: unknown } | null;
116
+ const config = profile.toJSON() as { grants?: unknown } | null;
112
117
 
113
118
  const rule = `${provider}.*`;
114
119
  const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
115
120
 
121
+ const rows = Array.isArray(workspace?.connections) ? workspace.connections : [];
122
+ const held_grants = Array.isArray(config?.grants) ? config.grants : [];
123
+
124
+ // The row this profile would use. Any instance of the provider will do, and
125
+ // the *first* one is taken rather than `main` specifically: an operator who
126
+ // renamed theirs should not get a second one bolted on beside it.
127
+ const existing = rows.find(
128
+ (row) => (row as { provider?: unknown } | null)?.provider === provider,
129
+ ) as { id?: unknown } | undefined;
130
+ const id = typeof existing?.id === 'string' ? existing.id : 'main';
131
+ const key = `${provider}.${id}`;
132
+
133
+ const held = held_grants.find(
134
+ (row) => (row as { connection?: unknown } | null)?.connection === key,
135
+ ) as { allow?: unknown; deny?: unknown } | undefined;
136
+
116
137
  // Denied on purpose, and a deny beats an allow — so writing the rule would
117
138
  // widen nothing while announcing that an agent can now read the surface,
118
- // which would be false. For `setup`, deleting the two lines no longer removes
119
- // it either, because the next `connect` or `deploy` puts them back; a deny is
120
- // the way it stays off, so it is the one thing this must not undo. The same
121
- // holds for `identity`, where the next `identity add` is what would put them
122
- // back.
139
+ // which would be false. Deleting the row no longer removes the surface
140
+ // either, because the next `connect` or `deploy` puts it back; a deny is the
141
+ // way it stays off, so it is the one thing this must not undo.
123
142
  //
124
143
  // Only a rule covering the whole surface counts. `deny: [setup.provider]` is
125
144
  // an operator narrowing it, not switching it off, and that narrowing survives
126
145
  // the repair untouched — which is the point of denying one capability.
127
- if (patternsIn(config?.policy?.deny).some(covers)) return { changes: [], granted: [] };
146
+ if (patternsIn(held?.deny).some(covers)) return { changes: [], granted: [] };
128
147
 
129
148
  const changes: string[] = [];
130
149
  const granted: string[] = [];
131
150
 
132
- const connections = Array.isArray(config?.connections) ? config.connections : [];
133
- const declared = (row: unknown): boolean =>
134
- (row as { provider?: unknown } | null)?.provider === provider;
135
-
136
- if (!connections.some(declared)) {
137
- // Inline, and `main` for the id, so a repaired profile is spelled exactly
138
- // like `newProfileTemplate` writes a fresh one. Two spellings of one row is
139
- // how a template and its repair drift apart.
140
- document.addTo(
151
+ if (existing === undefined) {
152
+ // Inline, and `main` for the id, so a repaired workspace is spelled exactly
153
+ // like `newConnectionsTemplate` writes a fresh one. Two spellings of one row
154
+ // is how a template and its repair drift apart.
155
+ connections.addTo(
141
156
  ['connections'],
142
157
  { id: 'main', provider, account: RESERVED_SURFACES[provider] },
143
158
  { inline: true },
144
159
  );
145
- changes.push(`connections += ${provider}.main`);
160
+ changes.push(`connections.yaml += ${key}`);
146
161
  }
147
162
 
148
- // `*` already covers it. Re-stating the rule under a blanket allow would be
149
- // noise in the file and a diff the operator did not ask for.
150
- if (!patternsIn(config?.policy?.allow).some(covers)) {
151
- document.addTo(['policy', 'allow'], rule, { inline: true });
163
+ if (!grants) return { changes, granted };
164
+
165
+ if (held === undefined) {
166
+ profile.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
167
+ changes.push(`grants += ${key}`);
168
+ granted.push(rule);
169
+ } else if (!patternsIn(held.allow).some(covers)) {
170
+ // A row that exists and grants nothing is a surface that is present and
171
+ // silent. Widening it back is the repair; the deny check above is what stops
172
+ // this undoing a deliberate narrowing.
173
+ const at = held_grants.indexOf(held as never);
174
+ profile.addTo(['grants', at, 'allow'], rule, { inline: true });
152
175
  granted.push(rule);
153
176
  }
154
177
 
155
178
  return { changes, granted };
156
179
  }
157
180
 
181
+ /** The workspace's connections document, written from the template if missing. */
182
+ async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
183
+ try {
184
+ return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
185
+ } catch {
186
+ await writeWorkspaceFile(
187
+ workspaceFiles(workspaceRoot),
188
+ CONNECTIONS_FILE,
189
+ newConnectionsTemplate(),
190
+ );
191
+ return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
192
+ }
193
+ }
194
+
158
195
  /** Whether a repair did anything, without a caller adding up two lists. */
159
196
  /** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
160
197
  function listSurfaces(): string {
@@ -169,7 +206,7 @@ export function repaired(repair: SurfaceRepair): boolean {
169
206
 
170
207
  /** The repair as display lines, in the order the two halves are applied. */
171
208
  export function repairLines(repair: SurfaceRepair): string[] {
172
- return [...repair.changes, ...repair.granted.map((rule) => `policy.allow += ${rule}`)];
209
+ return [...repair.changes, ...repair.granted.map((rule) => `grants[].allow += ${rule}`)];
173
210
  }
174
211
 
175
212
  /**
@@ -219,12 +256,16 @@ function patternsIn(rules: unknown, now = Date.now()): string[] {
219
256
  * Each surface is still decided independently, so a profile that denied exactly
220
257
  * one of them keeps that decision while the rest are repaired.
221
258
  */
222
- export function ensureOwnerLayer(document: ConfigDocument): SurfaceRepair {
259
+ export function ensureOwnerLayer(
260
+ connections: ConfigDocument,
261
+ profile: ConfigDocument,
262
+ options: { grants?: boolean } = {},
263
+ ): SurfaceRepair {
223
264
  const changes: string[] = [];
224
265
  const granted: string[] = [];
225
266
 
226
267
  for (const provider of DEFAULT_SURFACES) {
227
- const repair = ensureReservedConnection(document, provider);
268
+ const repair = ensureReservedConnection(connections, profile, provider, options);
228
269
  changes.push(...repair.changes);
229
270
  granted.push(...repair.granted);
230
271
  }
@@ -241,8 +282,11 @@ export function ensureOwnerLayer(document: ConfigDocument): SurfaceRepair {
241
282
  * paragraph of the instructions budget to say so. `identity add` is the only
242
283
  * caller, so the grant arrives exactly when there is something behind it.
243
284
  */
244
- export function ensureIdentityConnection(document: ConfigDocument): SurfaceRepair {
245
- return ensureReservedConnection(document, 'identity');
285
+ export function ensureIdentityConnection(
286
+ connections: ConfigDocument,
287
+ profile: ConfigDocument,
288
+ ): SurfaceRepair {
289
+ return ensureReservedConnection(connections, profile, 'identity');
246
290
  }
247
291
 
248
292
  /**
@@ -288,14 +332,25 @@ export async function repairOwnerLayer(
288
332
  const say = options.report ?? print;
289
333
  const wanted = profiles === undefined ? undefined : new Set(profiles);
290
334
 
335
+ // One connections document for the whole sweep. The owner layer is the
336
+ // workspace's now (ADR-059), so repairing three profiles must not add three
337
+ // `memory.main` rows — opening it once and saving it once is what makes the
338
+ // second profile see what the first one created.
339
+ // Created if absent rather than refused. A contract-3 workspace always has
340
+ // one, but a hand-made or half-migrated one may not — and this is the repair,
341
+ // so the file it needs is a thing to write rather than a reason to stop.
342
+ const connections = await openOrCreateConnections(workspaceRoot);
343
+ let connectionsChanged = false;
344
+
291
345
  for (const name of await listProfiles(workspaceRoot)) {
292
346
  if (wanted !== undefined && !wanted.has(name)) continue;
293
347
 
294
348
  try {
295
349
  const document = await ConfigDocument.open(workspaceRoot, name);
296
- const repair = ensureOwnerLayer(document);
350
+ const repair = ensureOwnerLayer(connections, document);
297
351
  if (!repaired(repair)) continue;
298
352
 
353
+ connectionsChanged = true;
299
354
  await document.save();
300
355
 
301
356
  say(ok(`gave ${style.bold(name)} its own owner layer`));
@@ -313,4 +368,6 @@ export async function repairOwnerLayer(
313
368
  );
314
369
  }
315
370
  }
371
+
372
+ if (connectionsChanged) await connections.save();
316
373
  }