@lanes-sh/link 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +148 -73
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +90 -88
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +16 -6
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +33 -13
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +53 -5
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- 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/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- 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 +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- 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 +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +34 -13
- package/src/deployments/upload.ts +60 -27
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONNECTIONS_FILE,
|
|
3
|
+
connectionsFileSchema,
|
|
4
|
+
ConfigError,
|
|
5
|
+
layout,
|
|
6
|
+
readWorkspaceFile,
|
|
7
|
+
workspaceFiles,
|
|
8
|
+
} from '#profile';
|
|
9
|
+
import { parseDocument } from 'yaml';
|
|
10
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
11
|
+
import type { Renames } from './contract4-data.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The two YAML rewrites contract 4 makes, and the order between them.
|
|
15
|
+
*
|
|
16
|
+
* The grants go first and the rows second, with the stamp after both — every
|
|
17
|
+
* window that leaves is one a rerun closes, because both rewrites are
|
|
18
|
+
* idempotent: a grant or a row already naming the new ref is not in the map and
|
|
19
|
+
* is left alone. Renaming the rows first destroyed the only source the map is
|
|
20
|
+
* derived from, and the rerun then stamped a profile whose grants named a
|
|
21
|
+
* connection nothing declared.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `memory.*` becomes `lanes_memory.*`, in either spelling a rule may take.
|
|
26
|
+
*
|
|
27
|
+
* A rule is a bare pattern string or `{ capability, expires_at }`, and an
|
|
28
|
+
* expiry has to survive: dropping it would turn a lapsing grant into a
|
|
29
|
+
* permanent one, which is the direction that fails unsafely.
|
|
30
|
+
*/
|
|
31
|
+
function retitle(rules: unknown, was: string, now: string): unknown {
|
|
32
|
+
if (!Array.isArray(rules)) return rules;
|
|
33
|
+
|
|
34
|
+
const moved = (pattern: string): string =>
|
|
35
|
+
pattern === was || pattern.startsWith(`${was}.`) ? `${now}${pattern.slice(was.length)}` : pattern;
|
|
36
|
+
|
|
37
|
+
return rules.map((rule) => {
|
|
38
|
+
if (typeof rule === 'string') return moved(rule);
|
|
39
|
+
const held = rule as { capability?: unknown };
|
|
40
|
+
return typeof held.capability === 'string'
|
|
41
|
+
? { ...held, capability: moved(held.capability) }
|
|
42
|
+
: rule;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every profile's grants, onto the ids and providers the rename settled.
|
|
48
|
+
*
|
|
49
|
+
* Its own pass rather than a step inside the stamping loop, because the two
|
|
50
|
+
* have to happen either side of `renameConnections` — the grants while the rows
|
|
51
|
+
* still carry the old ids, the stamp once everything else is done.
|
|
52
|
+
*
|
|
53
|
+
* A rule may only name the provider its row grants (ADR-058), so the
|
|
54
|
+
* `allow`/`deny` patterns move with the `connection` or the profile is one the
|
|
55
|
+
* loader refuses.
|
|
56
|
+
*/
|
|
57
|
+
export async function rewriteGrants(
|
|
58
|
+
root: string,
|
|
59
|
+
profiles: readonly string[],
|
|
60
|
+
renames: Renames,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
for (const profile of profiles) {
|
|
63
|
+
const document = await ConfigDocument.openKey(root, layout.profileConfig(profile));
|
|
64
|
+
|
|
65
|
+
const held = document.toJSON() as { grants?: unknown };
|
|
66
|
+
const grants = held.grants;
|
|
67
|
+
if (!Array.isArray(grants)) continue;
|
|
68
|
+
|
|
69
|
+
// **Deduplicated, because the owner layer merges.** Every profile's own
|
|
70
|
+
// `memory` grant renames to `lanes_memory.lan1` (ADR-066), and a profile
|
|
71
|
+
// that granted two instances of one surface would emit two rows at one
|
|
72
|
+
// address — which `validateConfig` refuses, on the save below, after the
|
|
73
|
+
// registry and every byte have already moved.
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
|
|
76
|
+
document.setIn(
|
|
77
|
+
['grants'],
|
|
78
|
+
grants.flatMap((grant) => {
|
|
79
|
+
const one = grant as { connection?: unknown; allow?: unknown; deny?: unknown };
|
|
80
|
+
const ref = typeof one.connection === 'string' ? one.connection : undefined;
|
|
81
|
+
if (ref === undefined) return [grant];
|
|
82
|
+
|
|
83
|
+
const to = renames.get(ref) ?? ref;
|
|
84
|
+
if (seen.has(to)) return [];
|
|
85
|
+
seen.add(to);
|
|
86
|
+
|
|
87
|
+
const was = ref.slice(0, ref.indexOf('.'));
|
|
88
|
+
const now = to.slice(0, to.indexOf('.'));
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
...one,
|
|
93
|
+
connection: to,
|
|
94
|
+
...(was === now
|
|
95
|
+
? {}
|
|
96
|
+
: { allow: retitle(one.allow, was, now), deny: retitle(one.deny, was, now) }),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// `shapeOnly`, because the rows still name the old ids at this point and
|
|
103
|
+
// `assertGrantsResolve` would refuse a grant it cannot yet resolve. The
|
|
104
|
+
// secret-shaped-value check and the schema both still run.
|
|
105
|
+
await document.save({ shapeOnly: true, contract: 3 });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every connection row, however far through the migration this workspace is. */
|
|
110
|
+
/**
|
|
111
|
+
* Refuse a `connections.yaml` the rewrite at the end could not have saved.
|
|
112
|
+
*
|
|
113
|
+
* `readConnectionRows` reads raw and drops a row it cannot understand, which is
|
|
114
|
+
* what lets it run before the guard that would block its own fix — but it means
|
|
115
|
+
* a malformed file yields *fewer* rows rather than an error, and a rehearsal
|
|
116
|
+
* found what that costs: the rename map came out empty, every byte moved
|
|
117
|
+
* verbatim into the profiles, and the validating save at the very end refused.
|
|
118
|
+
* `data/` was then empty, so a rerun had nothing to move and left the bytes at
|
|
119
|
+
* their old ids while the rows moved on without them.
|
|
120
|
+
*
|
|
121
|
+
* So it is checked here, before the first byte moves, which is where this
|
|
122
|
+
* migration says everything that can fail belongs. Deliberately *not*
|
|
123
|
+
* `readConnections`: that one also runs `assertNoRenamedProviders`, and a
|
|
124
|
+
* workspace holding `tasks` is the workspace this migration exists to repair.
|
|
125
|
+
*/
|
|
126
|
+
export async function assertConnectionsSavable(root: string): Promise<void> {
|
|
127
|
+
const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
|
|
128
|
+
if (text === null) return;
|
|
129
|
+
|
|
130
|
+
const parsed = connectionsFileSchema.safeParse(parseDocument(text).toJSON());
|
|
131
|
+
if (parsed.success) return;
|
|
132
|
+
|
|
133
|
+
throw new ConfigError(
|
|
134
|
+
`${CONNECTIONS_FILE} is not valid, and this migration rewrites it — so it stops here,\n` +
|
|
135
|
+
' with the workspace exactly as it was. Fix these, then run it again:\n' +
|
|
136
|
+
parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function readConnectionRows(
|
|
141
|
+
root: string,
|
|
142
|
+
full = false,
|
|
143
|
+
): Promise<{ id: string; provider: string; account: string; credential_ref?: string }[]> {
|
|
144
|
+
const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
|
|
145
|
+
if (text === null) return [];
|
|
146
|
+
|
|
147
|
+
const held = parseDocument(text).toJSON() as { connections?: unknown };
|
|
148
|
+
if (!Array.isArray(held.connections)) return [];
|
|
149
|
+
|
|
150
|
+
return held.connections.flatMap((row) => {
|
|
151
|
+
const one = row as { id?: unknown; provider?: unknown; account?: unknown };
|
|
152
|
+
if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [];
|
|
153
|
+
|
|
154
|
+
// `account` matters only to `credentialRefFor`, which reads a row's own
|
|
155
|
+
// `credential_ref` off it; the id and provider are all the rest wants.
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
...(full ? (row as object) : {}),
|
|
159
|
+
id: one.id,
|
|
160
|
+
provider: one.provider,
|
|
161
|
+
account: typeof one.account === 'string' ? one.account : '',
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The ids, in `connections.yaml` and in the credential store's refs.
|
|
169
|
+
*
|
|
170
|
+
* A `credential_ref` defaults to `<provider>/<connection>` and is derived
|
|
171
|
+
* rather than written, so the rows carry no ref to update — but a connection
|
|
172
|
+
* that *declares* one, and every `oauth_apps` entry, is a string naming an id
|
|
173
|
+
* and has to move with it.
|
|
174
|
+
*/
|
|
175
|
+
export async function renameConnections(root: string, renames: Renames): Promise<void> {
|
|
176
|
+
const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
177
|
+
|
|
178
|
+
// `toJSON()`, not `getIn`: `getIn` hands back YAML nodes rather than plain
|
|
179
|
+
// JS, so an `Array.isArray` on the result is false and the rewrite silently
|
|
180
|
+
// does nothing — which is exactly what it did, and the migration reported
|
|
181
|
+
// success with every id unchanged.
|
|
182
|
+
const held = document.toJSON() as { connections?: unknown };
|
|
183
|
+
if (!Array.isArray(held.connections)) return;
|
|
184
|
+
|
|
185
|
+
// **Deduplicated, because the owner layer merges.** Three profiles' own
|
|
186
|
+
// `memory` rows all rename to `lanes_memory.lan1` (ADR-066), and keeping
|
|
187
|
+
// three of them would be three rows at one address — which
|
|
188
|
+
// `assertConnectionsUnique` refuses, on the save at the end of this. The
|
|
189
|
+
// first row wins and the rest are dropped; the *bytes* are not merged, they
|
|
190
|
+
// land under each profile's own directory.
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
const rows = held.connections.flatMap((row) => {
|
|
193
|
+
const one = row as { id?: unknown; provider?: unknown };
|
|
194
|
+
if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [row];
|
|
195
|
+
|
|
196
|
+
const to = renames.get(`${one.provider}.${one.id}`);
|
|
197
|
+
if (to === undefined) return [row];
|
|
198
|
+
if (seen.has(to)) return [];
|
|
199
|
+
seen.add(to);
|
|
200
|
+
|
|
201
|
+
// Both halves. The provider moved for the owner layer (`memory` became
|
|
202
|
+
// `lanes_memory`) and the id moved for everything unallocated, and a row
|
|
203
|
+
// that took one without the other names a connection nothing resolves.
|
|
204
|
+
const dot = to.indexOf('.');
|
|
205
|
+
return [{ ...one, provider: to.slice(0, dot), id: to.slice(dot + 1) }];
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// **Lanes' own rows last, and the accounts in file order.** Nothing reads the
|
|
209
|
+
// order, but somebody does: interleaved, `con17` landed between `lan14` and
|
|
210
|
+
// `lan15` and the file read as though the numbering had gone wrong.
|
|
211
|
+
const owned = (row: unknown): boolean =>
|
|
212
|
+
typeof (row as { provider?: unknown }).provider === 'string' &&
|
|
213
|
+
(row as { provider: string }).provider.startsWith('lanes_');
|
|
214
|
+
|
|
215
|
+
document.setIn(['connections'], [...rows.filter((r) => !owned(r)), ...rows.filter(owned)]);
|
|
216
|
+
|
|
217
|
+
// The stamp this file was missing. Nothing reads it — every contract check is
|
|
218
|
+
// on a profile — which is exactly why it went stale, and a marker that lies
|
|
219
|
+
// is worse than no marker for whoever writes the next migration.
|
|
220
|
+
document.setIn(['contract'], 4);
|
|
221
|
+
|
|
222
|
+
await document.save();
|
|
223
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
layout,
|
|
4
|
+
listProfiles,
|
|
5
|
+
readWorkspaceFile,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
writeWorkspaceFile,
|
|
8
|
+
WORKSPACE_FILE,
|
|
9
|
+
CONNECTIONS_FILE,
|
|
10
|
+
openTarget,
|
|
11
|
+
} from '#profile';
|
|
12
|
+
import { parseDocument } from 'yaml';
|
|
13
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
14
|
+
import { C3 } from './contract3-layout.ts';
|
|
15
|
+
import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
|
|
16
|
+
import { planRenames } from './contract4-rename.ts';
|
|
17
|
+
import {
|
|
18
|
+
assertConnectionsSavable,
|
|
19
|
+
readConnectionRows,
|
|
20
|
+
renameConnections,
|
|
21
|
+
rewriteGrants,
|
|
22
|
+
} from './contract4-yaml.ts';
|
|
23
|
+
import { applyMoves, assertOneObjectPerDestination } from './migrate-move.ts';
|
|
24
|
+
import {
|
|
25
|
+
applyCredentialMoves,
|
|
26
|
+
planCredentialMoves,
|
|
27
|
+
planVaultMoves,
|
|
28
|
+
} from './contract4-credentials.ts';
|
|
29
|
+
import { openSecretStoreFor } from './runtime/select.ts';
|
|
30
|
+
import { buildRegistryWithWorkspace } from './runtime/registry.ts';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Contract 3 to contract 4: a profile owns its data again.
|
|
34
|
+
*
|
|
35
|
+
* Contract 3 moved every account up to the workspace and, in the same sweep,
|
|
36
|
+
* moved the owner layer's bytes beside the *connection* — so a profile owned
|
|
37
|
+
* nothing and the shipped default had every profile granting one memory.
|
|
38
|
+
* ADR-066 reverses that half; ADR-067 collapses a profile into one directory
|
|
39
|
+
* and retires `data/`, which had stopped meaning anything once the declaration
|
|
40
|
+
* moved in beside the bytes.
|
|
41
|
+
*
|
|
42
|
+
* Four steps, ordered so a crash between any two leaves a workspace that still
|
|
43
|
+
* opens:
|
|
44
|
+
*
|
|
45
|
+
* 1. The registry is renamed. One document, cannot half-apply.
|
|
46
|
+
* 2. Bytes move — the workspace's up out of `data/`, a profile's into its
|
|
47
|
+
* directory.
|
|
48
|
+
* 3. Declarations move into the directories they now name.
|
|
49
|
+
* 4. Each profile is stamped `contract: 4`.
|
|
50
|
+
*
|
|
51
|
+
* **The stamp is last, and it is the record that this finished** rather than a
|
|
52
|
+
* step among steps. Contract 3 shipped with it written first, which left
|
|
53
|
+
* profiles claiming the new contract with every byte still at the old path and
|
|
54
|
+
* a rerun that read the stamp and found nothing to do.
|
|
55
|
+
*
|
|
56
|
+
* Every read here goes through `C3`, and every write through `layout`. A
|
|
57
|
+
* migration that asks the live layout for its *source* paths finds nothing to
|
|
58
|
+
* move the moment the next contract lands.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
export interface Contract4Migration {
|
|
62
|
+
readonly workspaceRoot: string;
|
|
63
|
+
readonly profiles: readonly string[];
|
|
64
|
+
readonly changes: readonly string[];
|
|
65
|
+
/** Copied into more than one profile, original left. The operator decides. */
|
|
66
|
+
readonly shared: DataPlan['shared'];
|
|
67
|
+
/** Granted by no profile. Left where it is. */
|
|
68
|
+
readonly orphaned: readonly string[];
|
|
69
|
+
readonly alreadyCurrent: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether this workspace still holds anything at contract 3. */
|
|
73
|
+
export async function needsContract4(workspaceRoot: string): Promise<boolean> {
|
|
74
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
75
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
76
|
+
if (raw !== null && (raw.contract ?? 0) === 3) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readProfile(
|
|
82
|
+
root: string,
|
|
83
|
+
profile: string,
|
|
84
|
+
): Promise<{ contract?: number; grants?: { connection?: unknown }[] } | null> {
|
|
85
|
+
const files = workspaceFiles(root);
|
|
86
|
+
// The contract-3 path first: a profile still to be migrated is there, and one
|
|
87
|
+
// already migrated is at the new path. Reading only one shape makes a rerun
|
|
88
|
+
// after an interruption see half a workspace.
|
|
89
|
+
const text =
|
|
90
|
+
(await readWorkspaceFile(files, C3.profile(profile))) ??
|
|
91
|
+
(await readWorkspaceFile(files, layout.profileConfig(profile)));
|
|
92
|
+
if (text === null) return null;
|
|
93
|
+
try {
|
|
94
|
+
return parseDocument(text).toJSON() as { contract?: number };
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function migrateToContract4(
|
|
101
|
+
workspaceRoot: string,
|
|
102
|
+
options: { apply: boolean; target?: string } = { apply: true },
|
|
103
|
+
): Promise<Contract4Migration> {
|
|
104
|
+
const target = options.target ?? 'local';
|
|
105
|
+
const names = await listProfiles(workspaceRoot);
|
|
106
|
+
const configs = new Map<string, { grants?: { connection?: unknown }[] }>();
|
|
107
|
+
|
|
108
|
+
for (const profile of names) {
|
|
109
|
+
const raw = await readProfile(workspaceRoot, profile);
|
|
110
|
+
if (raw !== null && (raw.contract ?? 0) === 3) configs.set(profile, raw);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (configs.size === 0) {
|
|
114
|
+
return {
|
|
115
|
+
workspaceRoot,
|
|
116
|
+
profiles: [],
|
|
117
|
+
changes: [],
|
|
118
|
+
shared: [],
|
|
119
|
+
orphaned: [],
|
|
120
|
+
alreadyCurrent: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const profiles = [...configs.keys()];
|
|
125
|
+
const files = workspaceFiles(workspaceRoot);
|
|
126
|
+
|
|
127
|
+
// Every connection the workspace holds, and the id each ends up with. Built
|
|
128
|
+
// once and read by every rewrite below — the contract-3 mover's own bug was a
|
|
129
|
+
// map keyed one way and queried the other, which made the resolution a silent
|
|
130
|
+
// no-op and sent two profiles' blobs into one namespace.
|
|
131
|
+
await assertConnectionsSavable(workspaceRoot);
|
|
132
|
+
|
|
133
|
+
const rows = await readConnectionRows(workspaceRoot);
|
|
134
|
+
const renames = planRenames(rows);
|
|
135
|
+
|
|
136
|
+
const plan = await planMoves(files, grantingProfiles(configs), profiles, renames);
|
|
137
|
+
|
|
138
|
+
// Everything that can refuse, before the first byte moves. A `keep` move is
|
|
139
|
+
// exempt: two profiles granting one store are *meant* to write one source to
|
|
140
|
+
// two destinations, which is the shape this check exists to catch elsewhere.
|
|
141
|
+
assertOneObjectPerDestination(plan.moves.filter((move) => move.keep !== true));
|
|
142
|
+
|
|
143
|
+
const changes = [...describe(plan, profiles), ...repositoryNotes(configs, renames)];
|
|
144
|
+
if (!options.apply) {
|
|
145
|
+
return {
|
|
146
|
+
workspaceRoot,
|
|
147
|
+
profiles,
|
|
148
|
+
changes,
|
|
149
|
+
shared: plan.shared,
|
|
150
|
+
orphaned: plan.orphaned,
|
|
151
|
+
alreadyCurrent: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await renameRegistry(workspaceRoot);
|
|
156
|
+
await applyMoves(files, plan.moves);
|
|
157
|
+
|
|
158
|
+
// Credentials before the rows: a ref is derived from the id, so the rows must
|
|
159
|
+
// still name the old one for `planCredentialMoves` to compute the same pair
|
|
160
|
+
// on a rerun.
|
|
161
|
+
const credentials = await moveCredentials(workspaceRoot, target, rows, renames, configs);
|
|
162
|
+
|
|
163
|
+
// **The rows are renamed after the grants and before the stamp**, and every
|
|
164
|
+
// window that leaves is one a rerun closes. `connections.yaml` is the only
|
|
165
|
+
// source `planRenames` has, so renaming it before the grants destroyed the
|
|
166
|
+
// map mid-flight: the rerun read the new rows, computed `lan1 → lan1`, found
|
|
167
|
+
// no mapping for `memory.main`, and stamped a profile whose grants named a
|
|
168
|
+
// connection nothing declared — refused at load, and `needsContract4` false,
|
|
169
|
+
// so no migration would ever run again.
|
|
170
|
+
//
|
|
171
|
+
// Both rewrites are idempotent, which is what makes the order safe rather
|
|
172
|
+
// than merely better: a grant already naming the new ref is not in the map
|
|
173
|
+
// and is left alone, and so is a row.
|
|
174
|
+
await rewriteGrants(workspaceRoot, profiles, renames);
|
|
175
|
+
await renameConnections(workspaceRoot, renames);
|
|
176
|
+
|
|
177
|
+
// Last. The stamp is the record that the migration finished.
|
|
178
|
+
for (const profile of profiles) {
|
|
179
|
+
const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
|
|
180
|
+
document.setIn(['contract'], 4);
|
|
181
|
+
await document.save();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
workspaceRoot,
|
|
186
|
+
profiles,
|
|
187
|
+
changes: [...changes, ...credentials],
|
|
188
|
+
shared: plan.shared,
|
|
189
|
+
orphaned: plan.orphaned,
|
|
190
|
+
alreadyCurrent: false,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Move each stored credential to the ref its renamed connection now derives.
|
|
196
|
+
*
|
|
197
|
+
* **Throws rather than warns**, and that is the whole of its error handling. It
|
|
198
|
+
* warned once, and the rehearsal that found it showed why it must not: the
|
|
199
|
+
* warning was printed, `renameConnections` ran anyway, and the workspace came
|
|
200
|
+
* out with rows naming `gmail.con1` while the secret sat at
|
|
201
|
+
* `gmail/wjj_andrews`. A rerun cannot repair that — the rows are renamed, so
|
|
202
|
+
* the second run computes no rename for them and the old ref is orphaned with
|
|
203
|
+
* nothing left that knows what it belonged to.
|
|
204
|
+
*
|
|
205
|
+
* Failing here leaves the rows untouched, which is the state a rerun *can*
|
|
206
|
+
* finish from. Bytes that already moved are found in place and skipped.
|
|
207
|
+
*/
|
|
208
|
+
async function moveCredentials(
|
|
209
|
+
root: string,
|
|
210
|
+
target: string,
|
|
211
|
+
rows: readonly { id: string; provider: string }[],
|
|
212
|
+
renames: Renames,
|
|
213
|
+
profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
|
|
214
|
+
): Promise<string[]> {
|
|
215
|
+
if (rows.length === 0) return [];
|
|
216
|
+
|
|
217
|
+
const [store, registry] = await Promise.all([
|
|
218
|
+
openSecretStoreFor(root, target),
|
|
219
|
+
buildRegistryWithWorkspace(root),
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
// `readConnectionRows`, not `readConnections`. The latter runs
|
|
223
|
+
// `assertNoRenamedProviders`, which refuses a `tasks` row — and a workspace
|
|
224
|
+
// holding one is exactly the workspace being migrated, so reading through the
|
|
225
|
+
// guard makes the refusal block its own fix. ADR-051's rule, met twice now: a
|
|
226
|
+
// refusal has to name a command, and the command has to be able to run.
|
|
227
|
+
// The vault's document is planned separately because `credentialRefFor`
|
|
228
|
+
// cannot name it — see `planVaultMoves`. One apply for both, so a source
|
|
229
|
+
// feeding several destinations is deleted once rather than per plan.
|
|
230
|
+
const declared = (await openTarget(root, target)).declared;
|
|
231
|
+
|
|
232
|
+
return await applyCredentialMoves(store, [
|
|
233
|
+
...planCredentialMoves(await readConnectionRows(root, true), registry, renames),
|
|
234
|
+
...(declared.vault?.adapter === 'secret'
|
|
235
|
+
? planVaultMoves(profiles, renames, declared.vault.ref)
|
|
236
|
+
: []),
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `lanes-link.yaml` becomes `workspaces.yaml`.
|
|
242
|
+
*
|
|
243
|
+
* Written then deleted rather than moved, so an interruption leaves both and
|
|
244
|
+
* `readWorkspace` — which prefers the new name — still opens the workspace.
|
|
245
|
+
* Losing this file is losing the address of every target.
|
|
246
|
+
*/
|
|
247
|
+
async function renameRegistry(root: string): Promise<void> {
|
|
248
|
+
const files = workspaceFiles(root);
|
|
249
|
+
if (await files.has(WORKSPACE_FILE)) return;
|
|
250
|
+
|
|
251
|
+
const text = await readWorkspaceFile(files, C3.workspace);
|
|
252
|
+
if (text === null) return;
|
|
253
|
+
|
|
254
|
+
await writeWorkspaceFile(files, WORKSPACE_FILE, text);
|
|
255
|
+
if ((await readWorkspaceFile(files, WORKSPACE_FILE)) === null) {
|
|
256
|
+
throw new ConfigError(
|
|
257
|
+
`${WORKSPACE_FILE} did not read back after being written. Nothing has been deleted; ` +
|
|
258
|
+
'fix the store and run this again.',
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
await files.delete(C3.workspace);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* What a `knowledge:` repository needs done by hand, named rather than left.
|
|
266
|
+
*
|
|
267
|
+
* A profile keeping its memory and entities in GitHub addresses them by the
|
|
268
|
+
* connection id — an entry reaches the repository as `memory/<id>/<entry>.md`.
|
|
269
|
+
* Contract 4 renames the id, so the provider starts reading `memory/lan1/`
|
|
270
|
+
* while the repository still holds `memory/main/`: `memory_search` returns
|
|
271
|
+
* nothing and `entities_find` finds nobody, with the data sitting intact under
|
|
272
|
+
* the old name and nothing having failed.
|
|
273
|
+
*
|
|
274
|
+
* Not repaired here on purpose. Renaming directories in somebody's repository
|
|
275
|
+
* is a network write to a thing this migration does not own, and `applyMoves`
|
|
276
|
+
* cannot reach a GitHub store at all — so the honest move is to say exactly
|
|
277
|
+
* what to rename, which is one `git mv` per area. `removalPlan` warns about the
|
|
278
|
+
* same class for the same reason.
|
|
279
|
+
*/
|
|
280
|
+
function repositoryNotes(
|
|
281
|
+
configs: ReadonlyMap<string, { knowledge?: unknown; grants?: { connection?: unknown }[] }>,
|
|
282
|
+
renames: Renames,
|
|
283
|
+
): string[] {
|
|
284
|
+
const notes: string[] = [];
|
|
285
|
+
|
|
286
|
+
for (const [profile, config] of configs) {
|
|
287
|
+
const repo = (config.knowledge as { repo?: unknown } | undefined)?.repo;
|
|
288
|
+
if (typeof repo !== 'string') continue;
|
|
289
|
+
|
|
290
|
+
for (const surface of ['memory', 'entities'] as const) {
|
|
291
|
+
const granted = (config.grants ?? [])
|
|
292
|
+
.map((grant) => grant.connection)
|
|
293
|
+
.find((ref): ref is string => typeof ref === 'string' && ref.startsWith(`${surface}.`));
|
|
294
|
+
if (granted === undefined) continue;
|
|
295
|
+
|
|
296
|
+
const to = renames.get(granted);
|
|
297
|
+
if (to === undefined) continue;
|
|
298
|
+
|
|
299
|
+
const was = granted.slice(granted.indexOf('.') + 1);
|
|
300
|
+
const now = to.slice(to.indexOf('.') + 1);
|
|
301
|
+
if (was === now) continue;
|
|
302
|
+
|
|
303
|
+
notes.push(
|
|
304
|
+
`${repo}: rename ${surface}/${was}/ to ${surface}/${now}/ — "${profile}" reads it by the ` +
|
|
305
|
+
'connection id, and nothing here can write to your repository',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return notes;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function describe(plan: DataPlan, profiles: readonly string[]): string[] {
|
|
314
|
+
const changes = [`${C3.workspace} → ${WORKSPACE_FILE}`];
|
|
315
|
+
|
|
316
|
+
for (const profile of profiles) {
|
|
317
|
+
changes.push(`${C3.profile(profile)} → ${layout.profileConfig(profile)}: contract 4`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const moved = plan.moves.filter((move) => move.keep !== true).length;
|
|
321
|
+
if (moved > 0) changes.push(`${moved} object(s) moved out of data/`);
|
|
322
|
+
|
|
323
|
+
for (const { key, profiles: owners } of plan.shared) {
|
|
324
|
+
changes.push(`${key}: copied into ${owners.join(' and ')} — the original is left for you`);
|
|
325
|
+
}
|
|
326
|
+
for (const key of plan.orphaned) {
|
|
327
|
+
changes.push(`${key}: no profile grants it, so it stays where it is`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Said as what it is. Contract 3 merged these and deliberately did not delete
|
|
331
|
+
// them, so a workspace that came through it still holds a decryptable
|
|
332
|
+
// credential document per profile — and reporting that as an ungranted store
|
|
333
|
+
// reads as tidy-up rather than as a credential left on disk.
|
|
334
|
+
for (const key of plan.leftover) {
|
|
335
|
+
changes.push(
|
|
336
|
+
`${key}: a credential store contract 3 merged and left behind — its contents are in ` +
|
|
337
|
+
`${layout.credentials()} now, so delete it`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return changes;
|
|
342
|
+
}
|
package/src/cli/endpoint-url.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Config, DeployConfig, TargetConfig } from '#profile';
|
|
|
9
9
|
*
|
|
10
10
|
* Shared because getting it wrong is silent in the worst way. `outputs` asked
|
|
11
11
|
* the driver; `mcp add` built the local URL unconditionally, so
|
|
12
|
-
* `lanes link mcp add --
|
|
12
|
+
* `lanes link mcp add --workspace cloud` registered `http://127.0.0.1:7337/mcp`
|
|
13
13
|
* with the agent — a registration that looks successful, names the right
|
|
14
14
|
* server, and points at a port with nothing behind it. One of the two had the
|
|
15
15
|
* answer and the other could not see it, which is what a copied line does
|
package/src/cli/identity.ts
CHANGED
|
@@ -88,10 +88,10 @@ export async function resolveAccount(
|
|
|
88
88
|
if (!primary || !identity.qualifier) return primary;
|
|
89
89
|
|
|
90
90
|
// `alice (Acme)` rather than `alice`. The bracketed half is what makes
|
|
91
|
-
// two workspaces two accounts instead of one overwritten twice
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
91
|
+
// two workspaces two accounts instead of one overwritten twice: the
|
|
92
|
+
// reconnect match below is on the account, so without it the second
|
|
93
|
+
// connect repairs the first row rather than adding one. It used to reach
|
|
94
|
+
// the id as well, back when the id was slugified from this.
|
|
95
95
|
const qualifier = pluck(body, identity.qualifier);
|
|
96
96
|
return qualifier ? `${primary} (${qualifier})` : primary;
|
|
97
97
|
}
|
|
@@ -116,29 +116,47 @@ export async function resolveAccount(
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* `lan` for a surface built into Lanes, `con` for somebody's account.
|
|
120
120
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
121
|
+
* The prefix is the only thing an id says, and it says the one thing that is
|
|
122
|
+
* true forever: whether there is a vendor behind this row. Everything else a
|
|
123
|
+
* reader wants — whose mailbox, what the operator calls it — is `account` and
|
|
124
|
+
* `label`, one field each, both changeable without moving a reference.
|
|
125
125
|
*/
|
|
126
|
-
export
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
126
|
+
export const OWNER_ID_PREFIX = 'lan';
|
|
127
|
+
export const ACCOUNT_ID_PREFIX = 'con';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The next free connection id.
|
|
131
|
+
*
|
|
132
|
+
* **Opaque, where this used to derive the id from the account.**
|
|
133
|
+
* `ada.lovelace@example.com` became `ada_lovelace`, on the reasoning that the
|
|
134
|
+
* local part tells accounts apart in practice. It does not: the same name at
|
|
135
|
+
* two domains produced `ada_lovelace` and `ada_lovelace2`, and an id that half
|
|
136
|
+
* describes its account is worse than one that does not, because it invites
|
|
137
|
+
* being trusted. What made that concrete is that the id is the whole of the
|
|
138
|
+
* `connection` enum a model chooses from.
|
|
139
|
+
*
|
|
140
|
+
* So the id is a key and nothing else. `account` carries the identity the
|
|
141
|
+
* provider reports and `label` the operator's own word, which means a `relabel`
|
|
142
|
+
* never moves a `credential_ref`, a blob path, or a grant.
|
|
143
|
+
*
|
|
144
|
+
* **A leading letter, not a bare number.** `id: 001` parses as the integer `1`
|
|
145
|
+
* in YAML, so `gmail.001` in a grant would match nothing and every id would need
|
|
146
|
+
* quoting forever. `con1` is a string unconditionally.
|
|
147
|
+
*
|
|
148
|
+
* Numbers are never reused: the highest taken plus one, so an id that appears
|
|
149
|
+
* in an audit log years later still means the row it meant then.
|
|
150
|
+
*/
|
|
151
|
+
export function nextConnectionId(taken: readonly string[], owner: boolean): string {
|
|
152
|
+
const prefix = owner ? OWNER_ID_PREFIX : ACCOUNT_ID_PREFIX;
|
|
153
|
+
const pattern = new RegExp(`^${prefix}([0-9]+)$`);
|
|
154
|
+
|
|
155
|
+
let highest = 0;
|
|
156
|
+
for (const id of taken) {
|
|
157
|
+
const match = pattern.exec(id);
|
|
158
|
+
if (match) highest = Math.max(highest, Number(match[1]));
|
|
143
159
|
}
|
|
160
|
+
|
|
161
|
+
return `${prefix}${highest + 1}`;
|
|
144
162
|
}
|