@lanes-sh/link 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -25
- package/instructions/skills/lanes-link/SKILL.md +12 -12
- package/package.json +1 -1
- package/src/auth/oauth/store.ts +21 -5
- package/src/cli/accepts.ts +6 -5
- package/src/cli/brand.ts +20 -15
- package/src/cli/callback-page.ts +8 -7
- package/src/cli/commands/connect/index.ts +2 -1
- package/src/cli/commands/connect/settle.ts +12 -4
- package/src/cli/commands/connection.ts +2 -1
- package/src/cli/commands/identity.ts +4 -4
- package/src/cli/commands/knowledge/index.ts +9 -16
- package/src/cli/commands/knowledge/migrate.ts +9 -4
- package/src/cli/commands/knowledge/show.ts +14 -9
- package/src/cli/commands/operate/migrate.ts +5 -2
- package/src/cli/commands/operate/pair.ts +1 -1
- package/src/cli/commands/operate/serve.ts +1 -1
- package/src/cli/commands/owner/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +100 -13
- package/src/cli/commands/profile/remove.ts +67 -6
- package/src/cli/commands/profile.ts +33 -6
- package/src/cli/commands/secrets.ts +4 -4
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +36 -23
- package/src/cli/config-edit.ts +29 -8
- package/src/cli/config-repair-sweep.ts +183 -0
- package/src/cli/config-repair.ts +90 -141
- package/src/cli/config-templates.ts +26 -24
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +143 -209
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +34 -8
- package/src/cli/contract3.ts +141 -24
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +349 -0
- package/src/cli/identity.ts +44 -26
- package/src/cli/main.ts +6 -1
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +3 -3
- package/src/cli/publish.ts +1 -5
- package/src/cli/runtime/open.ts +5 -5
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +16 -11
- package/src/cli/runtime/vault.ts +2 -2
- package/src/cli/usage.ts +5 -1
- package/src/cli/workspace-migrate.ts +32 -11
- package/src/connectivity/manifest/provider.ts +31 -12
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/deploy.ts +5 -5
- package/src/deployments/gcp/bucket.ts +42 -6
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/target.ts +28 -7
- package/src/deployments/upload.ts +39 -30
- package/src/profile/connections.ts +13 -1
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +5 -1
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +147 -71
- package/src/profile/load.ts +53 -17
- package/src/profile/schema.ts +11 -2
- package/src/profile/testing.ts +45 -10
- package/src/profile/workspace.ts +66 -30
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +6 -6
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/registry.ts +1 -1
- package/src/server/endpoint.ts +4 -0
- package/src/server/harness.ts +1 -1
- package/src/server/mcp/instructions.ts +21 -21
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +16 -3
- package/src/server/mcp/visibility.ts +56 -4
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
package/src/cli/config-repair.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { newConnectionsTemplate } from './config-templates.ts';
|
|
2
|
-
import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
|
|
3
1
|
import { ConfigDocument } from './config-edit.ts';
|
|
4
|
-
import {
|
|
2
|
+
import { nextConnectionId } from './identity.ts';
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Giving a profile a reserved provider it is missing, without undoing a choice.
|
|
@@ -19,16 +17,16 @@ import { ok, print, style, warn } from './output.ts';
|
|
|
19
17
|
* than several that look alike.
|
|
20
18
|
*/
|
|
21
19
|
|
|
22
|
-
/**
|
|
20
|
+
/** Lanes' own provider ids, and the label each row carries. */
|
|
23
21
|
const RESERVED_SURFACES = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
lanes_memory: 'Memory',
|
|
23
|
+
lanes_tasks: 'Tasks',
|
|
24
|
+
lanes_assets: 'Assets',
|
|
25
|
+
lanes_skills: 'Skills',
|
|
26
|
+
lanes_vault: 'Vault',
|
|
27
|
+
lanes_setup: 'Setup',
|
|
28
|
+
lanes_identity: 'Identity',
|
|
29
|
+
lanes_entities: 'Entities',
|
|
32
30
|
} as const;
|
|
33
31
|
|
|
34
32
|
type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
@@ -56,13 +54,13 @@ type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
|
56
54
|
* template writes and a diff between the two reads as a diff.
|
|
57
55
|
*/
|
|
58
56
|
export const DEFAULT_SURFACES: readonly ReservedSurface[] = [
|
|
59
|
-
'
|
|
60
|
-
'
|
|
61
|
-
'
|
|
62
|
-
'
|
|
63
|
-
'
|
|
64
|
-
'
|
|
65
|
-
'
|
|
57
|
+
'lanes_memory',
|
|
58
|
+
'lanes_tasks',
|
|
59
|
+
'lanes_assets',
|
|
60
|
+
'lanes_skills',
|
|
61
|
+
'lanes_vault',
|
|
62
|
+
'lanes_setup',
|
|
63
|
+
'lanes_entities',
|
|
66
64
|
];
|
|
67
65
|
|
|
68
66
|
/**
|
|
@@ -121,18 +119,37 @@ export function ensureReservedConnection(
|
|
|
121
119
|
const rows = Array.isArray(workspace?.connections) ? workspace.connections : [];
|
|
122
120
|
const held_grants = Array.isArray(config?.grants) ? config.grants : [];
|
|
123
121
|
|
|
124
|
-
//
|
|
125
|
-
// the *first* one is taken rather than `main` specifically: an
|
|
126
|
-
// renamed theirs should not get a second one bolted on beside
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
122
|
+
// **This profile's own grant comes first.** Any instance of the provider will
|
|
123
|
+
// do, and the *first* one is taken rather than `main` specifically: an
|
|
124
|
+
// operator who renamed theirs should not get a second one bolted on beside
|
|
125
|
+
// it. That reasoning was right and the lookup implementing it was not — it
|
|
126
|
+
// read the workspace's first row, which under contract 2 was this profile's
|
|
127
|
+
// because a profile carried its own connections, and under contract 3 is
|
|
128
|
+
// whichever profile sorts first.
|
|
129
|
+
//
|
|
130
|
+
// So the repair asked whether `personal` granted `memory.main` — demo's — saw
|
|
131
|
+
// that it did not, and set about adding it. For `vault` and `skills` the
|
|
132
|
+
// schema refuses a second grant and the save failed loudly, which is how this
|
|
133
|
+
// was found. For `memory`, `tasks`, `assets`, `setup` and `entities` nothing
|
|
134
|
+
// refuses it, and the profile would quietly have been granted another
|
|
135
|
+
// profile's notes and another profile's task list — the outcome ADR-059
|
|
136
|
+
// exists to prevent, arriving from the repair rather than the migration.
|
|
137
|
+
//
|
|
138
|
+
// **Every** instance, not the first one matched. A profile may grant two —
|
|
139
|
+
// `memory.team` and `memory.personal` — and reading only the first let a deny
|
|
140
|
+
// on the other be stepped around: the repair widened the undenied row and
|
|
141
|
+
// reported the surface granted, which is the one thing the paragraph below
|
|
142
|
+
// says it must never do.
|
|
143
|
+
//
|
|
144
|
+
// `startsWith` rather than slicing at the dot, because these rows are raw
|
|
145
|
+
// unvalidated YAML: `indexOf` answers -1 for a value with no dot at all, and
|
|
146
|
+
// `'vaults'.slice(0, -1)` is `'vault'` — so a typo'd `connection: vaults`
|
|
147
|
+
// matched the vault surface and was widened while the real one stayed
|
|
148
|
+
// unreachable.
|
|
149
|
+
const mine = held_grants.filter((row) => {
|
|
150
|
+
const connection = (row as { connection?: unknown } | null)?.connection;
|
|
151
|
+
return typeof connection === 'string' && connection.startsWith(`${provider}.`);
|
|
152
|
+
}) as { connection?: string; allow?: unknown; deny?: unknown }[];
|
|
136
153
|
|
|
137
154
|
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
138
155
|
// widen nothing while announcing that an agent can now read the surface,
|
|
@@ -143,18 +160,53 @@ export function ensureReservedConnection(
|
|
|
143
160
|
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
144
161
|
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
145
162
|
// the repair untouched — which is the point of denying one capability.
|
|
146
|
-
if (patternsIn(
|
|
163
|
+
if (mine.some((row) => patternsIn(row.deny).some(covers))) return { changes: [], granted: [] };
|
|
164
|
+
|
|
165
|
+
// The instance already carrying the rule, if any. Not an early return: the
|
|
166
|
+
// workspace row can be missing while the grant is present — a profile written
|
|
167
|
+
// before the surface existed, repaired once against a `connections.yaml` that
|
|
168
|
+
// has since been hand-edited — and repairing one half only is what this
|
|
169
|
+
// function exists to prevent.
|
|
170
|
+
const allowed = mine.find((row) => patternsIn(row.allow).some(covers));
|
|
171
|
+
|
|
172
|
+
// Otherwise the first row that exists and grants nothing is the one to widen.
|
|
173
|
+
const owned = allowed ?? mine[0];
|
|
174
|
+
|
|
175
|
+
// Only when this profile grants no instance of the provider at all is the
|
|
176
|
+
// workspace consulted, which is the case this was written for: a surface that
|
|
177
|
+
// did not exist when the profile was written.
|
|
178
|
+
const declared = rows.find(
|
|
179
|
+
(row) => (row as { provider?: unknown } | null)?.provider === provider,
|
|
180
|
+
) as { id?: unknown } | undefined;
|
|
181
|
+
|
|
182
|
+
// The id an existing row already has, else the next free one. Opaque and
|
|
183
|
+
// allocated across the whole workspace, so `lan3` names exactly one row
|
|
184
|
+
// however many surfaces a repair adds in one pass.
|
|
185
|
+
const taken = rows.flatMap((row) => {
|
|
186
|
+
const id = (row as { id?: unknown } | null)?.id;
|
|
187
|
+
return typeof id === 'string' ? [id] : [];
|
|
188
|
+
});
|
|
189
|
+
const id = typeof declared?.id === 'string' ? declared.id : nextConnectionId(taken, true);
|
|
190
|
+
|
|
191
|
+
const key = owned?.connection ?? `${provider}.${id}`;
|
|
192
|
+
|
|
193
|
+
// Whether the *workspace* needs a row, which is a separate question from
|
|
194
|
+
// which one this profile grants: a profile cannot grant a connection that is
|
|
195
|
+
// not declared, so `owned` implies `declared`.
|
|
196
|
+
const existing = declared;
|
|
197
|
+
const held = owned;
|
|
147
198
|
|
|
148
199
|
const changes: string[] = [];
|
|
149
200
|
const granted: string[] = [];
|
|
150
201
|
|
|
151
202
|
if (existing === undefined) {
|
|
152
|
-
// Inline, and
|
|
153
|
-
// like `newConnectionsTemplate` writes a fresh one
|
|
154
|
-
//
|
|
203
|
+
// Inline, and the allocated id, so a repaired workspace is spelled exactly
|
|
204
|
+
// like `newConnectionsTemplate` writes a fresh one — which is what
|
|
205
|
+
// `config-edit.test.ts` asserts by checking a fresh profile needs no repair.
|
|
206
|
+
// Two spellings of one row is how a template and its repair drift apart.
|
|
155
207
|
connections.addTo(
|
|
156
208
|
['connections'],
|
|
157
|
-
{ id
|
|
209
|
+
{ id, provider, account: RESERVED_SURFACES[provider] },
|
|
158
210
|
{ inline: true },
|
|
159
211
|
);
|
|
160
212
|
changes.push(`connections.yaml += ${key}`);
|
|
@@ -166,7 +218,7 @@ export function ensureReservedConnection(
|
|
|
166
218
|
profile.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
|
|
167
219
|
changes.push(`grants += ${key}`);
|
|
168
220
|
granted.push(rule);
|
|
169
|
-
} else if (
|
|
221
|
+
} else if (allowed === undefined) {
|
|
170
222
|
// A row that exists and grants nothing is a surface that is present and
|
|
171
223
|
// silent. Widening it back is the repair; the deny check above is what stops
|
|
172
224
|
// this undoing a deliberate narrowing.
|
|
@@ -178,28 +230,7 @@ export function ensureReservedConnection(
|
|
|
178
230
|
return { changes, granted };
|
|
179
231
|
}
|
|
180
232
|
|
|
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
|
-
|
|
195
233
|
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
196
|
-
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
197
|
-
function listSurfaces(): string {
|
|
198
|
-
const names = [...DEFAULT_SURFACES];
|
|
199
|
-
const last = names.pop();
|
|
200
|
-
return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
234
|
export function repaired(repair: SurfaceRepair): boolean {
|
|
204
235
|
return repair.changes.length > 0 || repair.granted.length > 0;
|
|
205
236
|
}
|
|
@@ -286,88 +317,6 @@ export function ensureIdentityConnection(
|
|
|
286
317
|
connections: ConfigDocument,
|
|
287
318
|
profile: ConfigDocument,
|
|
288
319
|
): SurfaceRepair {
|
|
289
|
-
return ensureReservedConnection(connections, profile, '
|
|
320
|
+
return ensureReservedConnection(connections, profile, 'lanes_identity');
|
|
290
321
|
}
|
|
291
322
|
|
|
292
|
-
/**
|
|
293
|
-
* Apply that repair across a workspace, saving and reporting what changed.
|
|
294
|
-
*
|
|
295
|
-
* Here rather than in `#deployments`, where it was, because `start` needs it as
|
|
296
|
-
* much as `deploy` does — more, in fact: `start` is the one command an existing
|
|
297
|
-
* install runs without being asked to, so it is the path by which a profile
|
|
298
|
-
* written before ADR-050 gets the layer at all. Two copies of a function that
|
|
299
|
-
* widens a policy is not a thing to have.
|
|
300
|
-
*
|
|
301
|
-
* **The caller scopes it**, and for `deploy` that is exactly the set being
|
|
302
|
-
* uploaded: a profile it sends is a profile the endpoint will serve, so
|
|
303
|
-
* repairing a narrower set would leave a served profile without the surfaces.
|
|
304
|
-
* Note what a `--profile` flag does not mean — it is the flag alone, so a
|
|
305
|
-
* profile resolved from the environment leaves it undefined and that reads as
|
|
306
|
-
* the whole workspace.
|
|
307
|
-
*
|
|
308
|
-
* *Which files are profiles* comes from `listProfiles`, never from an allowlist
|
|
309
|
-
* of what is safe to copy: that would happily hand over a committed
|
|
310
|
-
* `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
|
|
311
|
-
* opens and validates what it is given — which once turned a template into a
|
|
312
|
-
* `ConfigError` aborting a deploy after provisioning had made cloud resources.
|
|
313
|
-
*
|
|
314
|
-
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
315
|
-
* a courtesy on the way past, and the caller's real work should still happen.
|
|
316
|
-
* Not silent, though — nothing else widens a policy without being asked.
|
|
317
|
-
*
|
|
318
|
-
* CLI-side by construction, like everything else in this file: a deployed
|
|
319
|
-
* revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
|
|
320
|
-
* this even if the code let it.
|
|
321
|
-
*/
|
|
322
|
-
export async function repairOwnerLayer(
|
|
323
|
-
workspaceRoot: string,
|
|
324
|
-
profiles: readonly string[] | undefined,
|
|
325
|
-
options: { report?: (line: string) => void } = {},
|
|
326
|
-
): Promise<void> {
|
|
327
|
-
// stdout by default, because every caller but one is printing a report a
|
|
328
|
-
// person reads. `update --json` passes `progress` instead: what it produces is
|
|
329
|
-
// a document, and a line of prose in front of it corrupts whatever is parsing.
|
|
330
|
-
// Routed rather than silenced — nothing else here widens a policy without
|
|
331
|
-
// saying so, and this must not be the exception.
|
|
332
|
-
const say = options.report ?? print;
|
|
333
|
-
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
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
|
-
|
|
345
|
-
for (const name of await listProfiles(workspaceRoot)) {
|
|
346
|
-
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
347
|
-
|
|
348
|
-
try {
|
|
349
|
-
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
350
|
-
const repair = ensureOwnerLayer(connections, document);
|
|
351
|
-
if (!repaired(repair)) continue;
|
|
352
|
-
|
|
353
|
-
connectionsChanged = true;
|
|
354
|
-
await document.save();
|
|
355
|
-
|
|
356
|
-
say(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
357
|
-
for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
|
|
358
|
-
// Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
|
|
359
|
-
// version still named six after a seventh had been added, so a person
|
|
360
|
-
// watching a deploy was told entities had arrived on the line above and
|
|
361
|
-
// that the layer was six things on the line below.
|
|
362
|
-
say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
|
|
363
|
-
} catch (error) {
|
|
364
|
-
say(
|
|
365
|
-
warn(
|
|
366
|
-
`could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
367
|
-
),
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (connectionsChanged) await connections.save();
|
|
373
|
-
}
|
|
@@ -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:
|
|
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: [
|
|
94
|
-
# deny: [
|
|
95
|
-
# deny: [
|
|
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:
|
|
98
|
-
- { connection:
|
|
99
|
-
- { connection:
|
|
100
|
-
- { connection:
|
|
101
|
-
- { connection:
|
|
102
|
-
- { connection:
|
|
103
|
-
- { connection:
|
|
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:
|
|
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
|
|
180
|
-
# anybody's API, so there was never anything for a
|
|
181
|
-
# (ADR-050).
|
|
182
|
-
#
|
|
183
|
-
|
|
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:
|
|
187
|
-
- { id:
|
|
188
|
-
- { id:
|
|
189
|
-
- { id:
|
|
190
|
-
- { id:
|
|
191
|
-
- { id:
|
|
192
|
-
- { id:
|
|
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
|
+
}
|