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