@lanes-sh/link 0.8.0 → 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 (87) hide show
  1. package/instructions/skills/lanes-link/SKILL.md +12 -12
  2. package/package.json +1 -1
  3. package/src/auth/oauth/store.ts +21 -5
  4. package/src/cli/accepts.ts +6 -5
  5. package/src/cli/commands/connect/index.ts +2 -1
  6. package/src/cli/commands/connect/settle.ts +12 -4
  7. package/src/cli/commands/connection.ts +2 -1
  8. package/src/cli/commands/identity.ts +4 -4
  9. package/src/cli/commands/knowledge/index.ts +9 -16
  10. package/src/cli/commands/knowledge/migrate.ts +9 -4
  11. package/src/cli/commands/knowledge/show.ts +14 -9
  12. package/src/cli/commands/operate/migrate.ts +5 -2
  13. package/src/cli/commands/operate/pair.ts +1 -1
  14. package/src/cli/commands/operate/serve.ts +1 -1
  15. package/src/cli/commands/owner/assets.ts +2 -2
  16. package/src/cli/commands/owner/entities.ts +2 -2
  17. package/src/cli/commands/owner/memory.ts +2 -2
  18. package/src/cli/commands/owner/tasks.ts +2 -2
  19. package/src/cli/commands/owner/vault.ts +3 -3
  20. package/src/cli/commands/profile/disposition.ts +236 -0
  21. package/src/cli/commands/profile/removal.ts +100 -13
  22. package/src/cli/commands/profile/remove.ts +67 -6
  23. package/src/cli/commands/profile.ts +33 -6
  24. package/src/cli/commands/secrets.ts +4 -4
  25. package/src/cli/commands/update-migration.ts +54 -0
  26. package/src/cli/commands/update.ts +36 -23
  27. package/src/cli/config-edit.ts +29 -8
  28. package/src/cli/config-repair-sweep.ts +119 -0
  29. package/src/cli/config-repair.ts +90 -141
  30. package/src/cli/config-templates.ts +26 -24
  31. package/src/cli/contract3-credentials.ts +294 -0
  32. package/src/cli/contract3-data.ts +143 -209
  33. package/src/cli/contract3-layout.ts +46 -0
  34. package/src/cli/contract3-shape.ts +34 -8
  35. package/src/cli/contract3.ts +141 -24
  36. package/src/cli/contract4-credentials.ts +207 -0
  37. package/src/cli/contract4-data.ts +399 -0
  38. package/src/cli/contract4-rename.ts +73 -0
  39. package/src/cli/contract4-yaml.ts +223 -0
  40. package/src/cli/contract4.ts +342 -0
  41. package/src/cli/identity.ts +44 -26
  42. package/src/cli/main.ts +6 -1
  43. package/src/cli/migrate-move.ts +166 -0
  44. package/src/cli/migrate-plan.ts +3 -3
  45. package/src/cli/publish.ts +1 -5
  46. package/src/cli/runtime/open.ts +5 -5
  47. package/src/cli/runtime/select.ts +2 -11
  48. package/src/cli/runtime/stores.ts +16 -11
  49. package/src/cli/runtime/vault.ts +2 -2
  50. package/src/cli/usage.ts +5 -1
  51. package/src/cli/workspace-migrate.ts +32 -11
  52. package/src/connectivity/manifest/provider.ts +31 -12
  53. package/src/connectivity/transports/imap/parser.ts +70 -9
  54. package/src/deployments/adapters/filesystem.ts +18 -3
  55. package/src/deployments/deploy.ts +5 -5
  56. package/src/deployments/gcp/bucket.ts +42 -6
  57. package/src/deployments/knowledge.ts +9 -4
  58. package/src/deployments/target.ts +28 -7
  59. package/src/deployments/upload.ts +39 -30
  60. package/src/profile/connections.ts +13 -1
  61. package/src/profile/deployments.ts +86 -8
  62. package/src/profile/index.ts +5 -1
  63. package/src/profile/knowledge.ts +18 -5
  64. package/src/profile/layout.ts +147 -71
  65. package/src/profile/load.ts +53 -17
  66. package/src/profile/schema.ts +11 -2
  67. package/src/profile/testing.ts +45 -10
  68. package/src/profile/workspace.ts +66 -30
  69. package/src/providers/assets/provider.ts +6 -6
  70. package/src/providers/entities/provider.ts +6 -6
  71. package/src/providers/entities/writes.ts +1 -1
  72. package/src/providers/identity/provider.ts +1 -1
  73. package/src/providers/memory/provider.ts +6 -6
  74. package/src/providers/setup/provider.ts +3 -3
  75. package/src/providers/skills/provider.ts +2 -2
  76. package/src/providers/tasks/provider.ts +6 -6
  77. package/src/providers/vault/provider.ts +1 -1
  78. package/src/registry/registry.ts +1 -1
  79. package/src/server/endpoint.ts +4 -0
  80. package/src/server/harness.ts +1 -1
  81. package/src/server/mcp/instructions.ts +21 -21
  82. package/src/server/mcp/routing.ts +3 -3
  83. package/src/server/mcp/tools.ts +16 -3
  84. package/src/server/mcp/visibility.ts +56 -4
  85. package/src/stores/blobs/conformance.ts +19 -0
  86. package/src/stores/state/index.ts +76 -10
  87. package/src/stores/state/testing.ts +5 -1
@@ -27,7 +27,7 @@ export function newProfileTemplate(profile: string, port: number, subject?: stri
27
27
  #
28
28
  # Edit it by hand or through the CLI; both are supported, and CLI edits preserve
29
29
  # your comments and ordering.
30
- contract: 3
30
+ contract: 4
31
31
 
32
32
  instance:
33
33
  profile: ${profile}
@@ -90,17 +90,17 @@ limits:
90
90
  # works, because the next connect or deploy puts it back. The three narrowings
91
91
  # worth knowing:
92
92
  #
93
- # deny: [memory.write] remember nothing new
94
- # deny: [skills.manage.*] invoke procedures, do not write them
95
- # deny: [vault.put, vault.remove]
93
+ # deny: [lanes_memory.write] remember nothing new
94
+ # deny: [lanes_skills.manage.*] invoke procedures, do not write them
95
+ # deny: [lanes_vault.put, lanes_vault.remove]
96
96
  grants:
97
- - { connection: memory.main, allow: [memory.*], deny: [] }
98
- - { connection: tasks.main, allow: [tasks.*], deny: [] }
99
- - { connection: assets.main, allow: [assets.*], deny: [] }
100
- - { connection: skills.main, allow: [skills.*], deny: [] }
101
- - { connection: vault.main, allow: [vault.*], deny: [] }
102
- - { connection: setup.main, allow: [setup.*], deny: [] }
103
- - { connection: entities.main, allow: [entities.*], deny: [] }
97
+ - { connection: lanes_memory.lan1, allow: [lanes_memory.*], deny: [] }
98
+ - { connection: lanes_tasks.lan2, allow: [lanes_tasks.*], deny: [] }
99
+ - { connection: lanes_assets.lan3, allow: [lanes_assets.*], deny: [] }
100
+ - { connection: lanes_skills.lan4, allow: [lanes_skills.*], deny: [] }
101
+ - { connection: lanes_vault.lan5, allow: [lanes_vault.*], deny: [] }
102
+ - { connection: lanes_setup.lan6, allow: [lanes_setup.*], deny: [] }
103
+ - { connection: lanes_entities.lan7, allow: [lanes_entities.*], deny: [] }
104
104
 
105
105
  # Who may consume this profile (ADR-060).
106
106
  #
@@ -146,7 +146,7 @@ export function newWorkspaceTemplate(): string {
146
146
  # uses it prints which one it got. Commands that publish or destroy — deploy,
147
147
  # sync, secrets push, profile remove, disconnect, token rotate — refuse it and
148
148
  # make you type the name (ADR-061).
149
- contract: 3
149
+ contract: 4
150
150
  default_workspace: local
151
151
  workspaces:
152
152
  local:
@@ -176,20 +176,22 @@ export function newConnectionsTemplate(): string {
176
176
  # this list says whose data is reachable without having to look anything up.
177
177
  # "label" is your own word for the same row, and only ever displayed.
178
178
  #
179
- # The seven below hold no account: they reach your own material rather than
180
- # anybody's API, so there was never anything for a connect step to authorise
181
- # (ADR-050). Make a second one "lanes link connect memory --id work" — when you
182
- # want two profiles to share nothing.
183
- contract: 3
179
+ # The "lanes_" rows are Lanes' own surfaces and hold no account: they reach your
180
+ # own material rather than anybody's API, so there was never anything for a
181
+ # connect step to authorise (ADR-050). Every profile grants the same ones and
182
+ # still keeps its own bytes — what you write through one profile is absent in
183
+ # another (ADR-066). A second instance is for holding two of something in one
184
+ # profile: "lanes link connect lanes_memory --id lan9".
185
+ contract: 4
184
186
 
185
187
  connections:
186
- - { id: main, provider: memory, account: Memory }
187
- - { id: main, provider: tasks, account: Tasks }
188
- - { id: main, provider: assets, account: Assets }
189
- - { id: main, provider: skills, account: Skills }
190
- - { id: main, provider: vault, account: Vault }
191
- - { id: main, provider: setup, account: Setup }
192
- - { id: main, provider: entities, account: Entities }
188
+ - { id: lan1, provider: lanes_memory, account: Memory }
189
+ - { id: lan2, provider: lanes_tasks, account: Tasks }
190
+ - { id: lan3, provider: lanes_assets, account: Assets }
191
+ - { id: lan4, provider: lanes_skills, account: Skills }
192
+ - { id: lan5, provider: lanes_vault, account: Vault }
193
+ - { id: lan6, provider: lanes_setup, account: Setup }
194
+ - { id: lan7, provider: lanes_entities, account: Entities }
193
195
 
194
196
  # App registrations, shared by every connection of that vendor.
195
197
  oauth_apps: {}
@@ -0,0 +1,294 @@
1
+ import { ConfigError, LEGACY_DATA_DIR, isRemoteWorkspace } from '#profile';
2
+ import { C3 } from './contract3-layout.ts';
3
+ import { createFileSecretStore } from '#secrets';
4
+
5
+ /**
6
+ * The credential half of the contract-3 migration.
7
+ *
8
+ * Split from `contract3-data.ts` when that file outgrew the budget, on the seam
9
+ * the migration already had: objects move, credentials merge, and the two obey
10
+ * different rules. A moved object has one home and the move is reversible by
11
+ * moving it back. A merged credential has to survive two profiles claiming one
12
+ * ref, and the wrong resolution points a live connection at somebody else's
13
+ * account — so nothing here picks between two values, ever.
14
+ *
15
+ * The whole file is ordered around one promise: every refusal happens before
16
+ * the first write.
17
+ */
18
+
19
+ /**
20
+ * What this migration needs to know about one profile's credentials.
21
+ *
22
+ * Assembled by `contract3.ts` from the hoist, because the two facts below are
23
+ * decisions that file already made and this one must not make differently.
24
+ */
25
+ export interface CredentialPlan {
26
+ readonly profile: string;
27
+ /** Every connection-derived credential ref, and where it is going. */
28
+ readonly renames: ReadonlyMap<string, RefTarget>;
29
+ /** This profile's endpoint token ref, which is deliberately not migrated. */
30
+ readonly tokenRef: string;
31
+ }
32
+
33
+ /** What the merged credential store will hold, and what was left out of it. */
34
+ export interface CredentialMerge {
35
+ /** Refs the merged store will hold, sorted. */
36
+ readonly refs: readonly string[];
37
+ /** Endpoint token refs left behind rather than merged, sorted. */
38
+ readonly tokens: readonly string[];
39
+ }
40
+
41
+ /** Where a credential ref is going, and which connection it belongs to after. */
42
+ export interface RefTarget {
43
+ readonly to: string;
44
+ /** The settled `provider.id` this ref's connection became. */
45
+ readonly connection: string;
46
+ }
47
+
48
+ /**
49
+ * The credential ref of every connection the hoist touched, renamed or not.
50
+ *
51
+ * `credentialRefForConnection` derives `${app ?? provider}/${connectionId}` for
52
+ * every auth kind that has a per-connection credential, and `hoistConnections`
53
+ * renames the connection with `{ ...connection, id }` — which leaves the derived
54
+ * ref pointing at the old id. So a profile whose `github.main` became
55
+ * `github.main_2` still claimed `github/main`, and two profiles claiming one ref
56
+ * with two different tokens is what aborted the migration.
57
+ *
58
+ * **Unrenamed connections are in here too**, which is not redundancy: the
59
+ * `connection` half is what tells `readMerged` that two refs landing on one
60
+ * target belong to the same connection and are therefore a merge rather than a
61
+ * clash. Without it a single profile declaring `gmail.main` and `gmail.archive`
62
+ * for one mailbox — legal under contract 2, and the reason tokens are
63
+ * per-connection at all, since the scopes differ — was hoisted into one row and
64
+ * then refused with "Two profiles hold different values", naming one profile
65
+ * twice and prescribing a rename that cannot help.
66
+ *
67
+ * Only the `<provider>/<id>` spelling is derived here. A provider declaring
68
+ * `auth.app` stores under `<app>/<id>` and a row carrying an explicit
69
+ * `credential_ref` stores wherever it says — neither is reconstructable without
70
+ * the manifest, which this migration does not load. Those refs are carried
71
+ * across untouched, and a genuine clash between two of them is refused by name.
72
+ */
73
+ export function connectionRefs(mapping: ReadonlyMap<string, string>): Map<string, RefTarget> {
74
+ const refs = new Map<string, RefTarget>();
75
+
76
+ for (const [from, to] of mapping) {
77
+ const before = from.indexOf('.');
78
+ const after = to.indexOf('.');
79
+ if (before < 0 || after < 0) continue;
80
+ refs.set(`${from.slice(0, before)}/${from.slice(before + 1)}`, {
81
+ to: `${to.slice(0, after)}/${to.slice(after + 1)}`,
82
+ connection: to,
83
+ });
84
+ }
85
+
86
+ return refs;
87
+ }
88
+
89
+ /**
90
+ * Every credential the merged store will hold, addressed as contract 3 will
91
+ * address it.
92
+ *
93
+ * One walk, shared by the preview and the apply, because the two disagreeing is
94
+ * the defect this replaces: `planCredentials` collected the union of ref *names*
95
+ * and never compared values, so a clash it could not see aborted
96
+ * `mergeCredentials` — at which point `rewriteRegistry` and `writeConnections`
97
+ * had already run, and the workspace was half-migrated behind a message saying
98
+ * nothing had been written.
99
+ *
100
+ * Read-only. Everything that can refuse, refuses here.
101
+ *
102
+ * **A workspace in a bucket has nothing to merge, and this says so rather than
103
+ * finding out.** `workspacePath` refuses a filesystem adapter against a remote
104
+ * root, so the only credential store such a workspace can declare is
105
+ * `gcp-secret-manager` — whose refs were never scoped by profile, and are
106
+ * therefore already what contract 3 wants. Without the guard the path below is
107
+ * built by string interpolation into `gs://bucket/data/<profile>/credentials.enc`
108
+ * and handed to `Bun.file`, where the failure is swallowed by the `catch` and
109
+ * reads exactly like a workspace with no credentials in it.
110
+ */
111
+ async function readMerged(
112
+ root: string,
113
+ plans: readonly CredentialPlan[],
114
+ ): Promise<{ merged: Map<string, string>; tokens: Set<string> }> {
115
+ const merged = new Map<string, string>();
116
+ const held = new Map<string, { profile: string; connection?: string }>();
117
+ // One entry per endpoint-token ref, holding what each profile had under it.
118
+ // Decided after the walk, because whether it can be carried across depends on
119
+ // whether the profiles agree — which is not known until they have all been read.
120
+ const endpoint = new Map<string, Map<string, string>>();
121
+ const tokens = new Set<string>();
122
+
123
+ if (isRemoteWorkspace(root)) return { merged, tokens };
124
+
125
+ for (const plan of plans) {
126
+ const store = createFileSecretStore({
127
+ path: `${root}/${LEGACY_DATA_DIR}/${plan.profile}/credentials.enc`,
128
+ });
129
+
130
+ let refs: string[];
131
+ try {
132
+ refs = await store.list();
133
+ } catch {
134
+ // A store that will not open is reported by `doctor`, not here. This runs
135
+ // as a preview too, and must not fail on a workspace that is already
136
+ // broken.
137
+ continue;
138
+ }
139
+
140
+ for (const ref of refs) {
141
+ // The endpoint's own bearer token, which every profile keeps under the
142
+ // same ref because `authSchema` defaults `token_ref` to `profile/token`.
143
+ // Under contract 2 that was unambiguous — one store per profile. Under
144
+ // contract 3 there is one store, so three profiles' tokens are three
145
+ // values for one key, and there is no merge that means anything.
146
+ //
147
+ // Left behind rather than picked between. It is minted locally rather
148
+ // than granted by anybody, `ensureProfileToken` writes a fresh one the
149
+ // first time a command asks, and the old stores are not deleted — so the
150
+ // cost is re-registering a client, and no account has to be authorised
151
+ // again.
152
+ let value: string | null;
153
+ try {
154
+ value = await store.get(ref);
155
+ } catch {
156
+ continue;
157
+ }
158
+ if (value === null) continue;
159
+
160
+ // The endpoint's own bearer token, held under one ref by every profile
161
+ // because `authSchema` defaults `token_ref` to `profile/token`. Set aside
162
+ // rather than merged here; see below.
163
+ if (ref === plan.tokenRef) {
164
+ const seen = endpoint.get(ref) ?? new Map<string, string>();
165
+ seen.set(plan.profile, value);
166
+ endpoint.set(ref, seen);
167
+ continue;
168
+ }
169
+
170
+ const mapped = plan.renames.get(ref);
171
+ const target = mapped?.to ?? ref;
172
+ const first = merged.get(target);
173
+
174
+ if (first !== undefined) {
175
+ if (first === value) continue;
176
+
177
+ // Two refs on one target whose connections are the same connection.
178
+ // That is the hoist merging two rows for one account — legal under
179
+ // contract 2, where `gmail.main` and `gmail.archive` could hold the
180
+ // same mailbox at different scopes — and there is one connection now,
181
+ // so one token. The first row is the one that survived the hoist, so
182
+ // its credential is the one that belongs to it.
183
+ const owner = held.get(target);
184
+ if (mapped !== undefined && owner?.connection === mapped.connection) continue;
185
+
186
+ throw new ConfigError(
187
+ `Two credentials want to be at "${target}", and this migration cannot choose ` +
188
+ `between them.\n` +
189
+ ` ${owner?.profile ?? 'another profile'} and ${plan.profile} both hold one. Both ` +
190
+ `are real credentials, and picking either would point a connection at the wrong ` +
191
+ `account.\n` +
192
+ ` Nothing has been written. Give one of them its own credential_ref before ` +
193
+ `migrating.`,
194
+ );
195
+ }
196
+
197
+ merged.set(target, value);
198
+ held.set(target, {
199
+ profile: plan.profile,
200
+ ...(mapped === undefined ? {} : { connection: mapped.connection }),
201
+ });
202
+ }
203
+ }
204
+
205
+ // **A token is only left behind when the profiles disagree about it.**
206
+ //
207
+ // Under contract 2 each profile had its own store, so one ref meant one value
208
+ // per profile and contract 3's single store cannot hold three of them. But a
209
+ // workspace with one profile — or three that were registered from the same
210
+ // token — has nothing to choose between, and dropping it there was gratuitous:
211
+ // every client registered against that endpoint started getting 401s after a
212
+ // routine `update`, for a conflict that did not exist.
213
+ //
214
+ // Where they do disagree it is still left behind rather than picked between.
215
+ // It is minted locally rather than granted by anybody, `ensureProfileToken`
216
+ // writes a fresh one the first time a command asks, and the old stores are not
217
+ // deleted — so the cost is re-registering a client, and no account has to be
218
+ // authorised again.
219
+ for (const [ref, byProfile] of endpoint) {
220
+ const values = new Set(byProfile.values());
221
+ const agreed = values.size === 1 ? [...values][0] : undefined;
222
+
223
+ if (agreed === undefined || merged.has(ref)) {
224
+ tokens.add(ref);
225
+ continue;
226
+ }
227
+
228
+ merged.set(ref, agreed);
229
+ held.set(ref, { profile: [...byProfile.keys()].join(', ') });
230
+ }
231
+
232
+ return { merged, tokens };
233
+ }
234
+
235
+ /**
236
+ * What the merge will do, computed before anything is written.
237
+ *
238
+ * The report an operator confirms is therefore the real one, and a refusal
239
+ * leaves the workspace exactly as it was.
240
+ */
241
+ export async function planCredentials(
242
+ root: string,
243
+ plans: readonly CredentialPlan[],
244
+ ): Promise<CredentialMerge> {
245
+ const { merged, tokens } = await readMerged(root, plans);
246
+ return { refs: [...merged.keys()].sort(), tokens: [...tokens].sort() };
247
+ }
248
+
249
+ /**
250
+ * Copy every profile's credentials into the workspace store.
251
+ *
252
+ * Written and read back before the old stores are touched, which is the whole
253
+ * of the safety argument: a half-finished merge that has not deleted anything is
254
+ * recoverable by running it again, and one that deleted first is not.
255
+ *
256
+ * The old stores are left in place regardless. They are a few kilobytes, they
257
+ * are the only copy of anything if this went wrong, and `doctor` names them so
258
+ * an operator can remove them once the endpoint has served a request.
259
+ *
260
+ * Skipped entirely for a workspace in a bucket, for the reason `readMerged`
261
+ * gives: its credentials are in Secret Manager under refs that were never
262
+ * per-profile, so there is no second store to fold in.
263
+ */
264
+ export async function mergeCredentials(
265
+ root: string,
266
+ plans: readonly CredentialPlan[],
267
+ ): Promise<void> {
268
+ if (isRemoteWorkspace(root)) return;
269
+
270
+ const { merged } = await readMerged(root, plans);
271
+ const destination = createFileSecretStore({ path: `${root}/${C3.credentials()}` });
272
+
273
+ for (const [ref, value] of merged) {
274
+ const already = await destination.get(ref);
275
+ if (already !== null) {
276
+ // Already copied, by a run that did not get to the end. Anything else is
277
+ // a store that disagrees with the profiles it was built from, which is
278
+ // not something to overwrite silently.
279
+ if (already === value) continue;
280
+ throw new ConfigError(
281
+ `${C3.credentials()} already holds a different value for "${ref}" than the profile ` +
282
+ `stores do. Nothing has been deleted; resolve it and run this again.`,
283
+ );
284
+ }
285
+
286
+ await destination.set(ref, value);
287
+ if ((await destination.get(ref)) !== value) {
288
+ throw new ConfigError(
289
+ `The credential "${ref}" did not read back after being written to ` +
290
+ `${C3.credentials()}. Nothing has been deleted; fix the store and run this again.`,
291
+ );
292
+ }
293
+ }
294
+ }