@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
package/src/cli/config-edit.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { rename, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { Document, parseDocument, type Node } from 'yaml';
|
|
2
|
+
import { Document, parseDocument, YAMLSeq, type Node } from 'yaml';
|
|
3
3
|
import {
|
|
4
|
+
CONNECTIONS_FILE,
|
|
4
5
|
ConfigError,
|
|
6
|
+
WORKSPACE_FILE,
|
|
7
|
+
LEGACY_WORKSPACE_FILE,
|
|
8
|
+
workspaceSchema,
|
|
9
|
+
assertConnectionsUnique,
|
|
10
|
+
connectionsFileSchema,
|
|
11
|
+
findSecrets,
|
|
12
|
+
formatSecretFindings,
|
|
5
13
|
isRemoteWorkspace,
|
|
6
14
|
readWorkspaceFile,
|
|
7
15
|
validateConfig,
|
|
8
16
|
validateConfigShape,
|
|
9
17
|
workspaceFiles,
|
|
10
18
|
writeWorkspaceFile,
|
|
19
|
+
layout,
|
|
11
20
|
} from '#profile';
|
|
12
21
|
|
|
13
22
|
/**
|
|
@@ -24,6 +33,59 @@ import {
|
|
|
24
33
|
* refuses to run.
|
|
25
34
|
*/
|
|
26
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Validate a document against the schema for the file it is.
|
|
38
|
+
*
|
|
39
|
+
* The key is the discriminator because it is the only thing that is always
|
|
40
|
+
* right: a caller could be asked to say which shape it holds, and a caller that
|
|
41
|
+
* said the wrong one would get the wrong check silently.
|
|
42
|
+
*/
|
|
43
|
+
function validateDocument(
|
|
44
|
+
raw: unknown,
|
|
45
|
+
path: string,
|
|
46
|
+
key: string | undefined,
|
|
47
|
+
options: { shapeOnly?: boolean; contract?: number },
|
|
48
|
+
): void {
|
|
49
|
+
// Either name. The contract-3 migration rewrites the registry under the name
|
|
50
|
+
// it still has, and a document checked against the wrong schema fails with
|
|
51
|
+
// "instance: expected object" — which reads as a corrupt profile rather than
|
|
52
|
+
// as a registry being validated as one.
|
|
53
|
+
if (key === WORKSPACE_FILE || key === LEGACY_WORKSPACE_FILE) {
|
|
54
|
+
const parsed = workspaceSchema.safeParse(raw);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
throw new ConfigError(
|
|
57
|
+
`${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (key === CONNECTIONS_FILE) {
|
|
64
|
+
const secrets = findSecrets(raw);
|
|
65
|
+
if (secrets.length > 0) {
|
|
66
|
+
throw new ConfigError(
|
|
67
|
+
`${path}: ${formatSecretFindings(secrets)}`,
|
|
68
|
+
secrets.map((finding) => finding.path),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = connectionsFileSchema.safeParse(raw);
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
throw new ConfigError(
|
|
75
|
+
`${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
assertConnectionsUnique(parsed.data.connections);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `contract` is the one a migration is *producing*, and it is only ever
|
|
84
|
+
// passed by one: every other caller writes the newest.
|
|
85
|
+
if (options.shapeOnly === true) validateConfigShape(raw, path, options.contract);
|
|
86
|
+
else validateConfig(raw, path, options.contract);
|
|
87
|
+
}
|
|
88
|
+
|
|
27
89
|
export class ConfigDocument {
|
|
28
90
|
readonly #document: Document;
|
|
29
91
|
readonly #path: string;
|
|
@@ -47,7 +109,20 @@ export class ConfigDocument {
|
|
|
47
109
|
* `gs://` URL produces something that addresses nothing.
|
|
48
110
|
*/
|
|
49
111
|
static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
|
|
50
|
-
|
|
112
|
+
return ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Open any document the workspace holds, by key.
|
|
117
|
+
*
|
|
118
|
+
* `open` above is this with the profile path spelled out, and stays because
|
|
119
|
+
* it is what almost every caller wants. This exists for `connections.yaml`,
|
|
120
|
+
* which is a workspace document rather than a profile's (ADR-057) and needs
|
|
121
|
+
* the same comment-preserving edit path — a connection row carries the
|
|
122
|
+
* account label an operator wrote, and rewriting the file through the schema
|
|
123
|
+
* would drop every comment beside it.
|
|
124
|
+
*/
|
|
125
|
+
static async openKey(workspaceRoot: string, key: string): Promise<ConfigDocument> {
|
|
51
126
|
const shown = isRemoteWorkspace(workspaceRoot)
|
|
52
127
|
? `${workspaceRoot}/${key}`
|
|
53
128
|
: `${workspaceRoot}/${key}`;
|
|
@@ -132,7 +207,20 @@ export class ConfigDocument {
|
|
|
132
207
|
// difference decided whether a command explained itself or crashed.
|
|
133
208
|
const existing = this.#document.getIn(path as (string | number)[]);
|
|
134
209
|
if (existing === undefined || existing === null) {
|
|
135
|
-
|
|
210
|
+
// A `YAMLSeq` rather than the plain `[node]` this used to set. The array
|
|
211
|
+
// is not a collection the document API will traverse — the same hazard
|
|
212
|
+
// `setIn` above documents — so the *first* append landed and the second
|
|
213
|
+
// found a value with no `.add` and threw `existing.add is not a
|
|
214
|
+
// function`. `#expand` below reads `.items` and was silently a no-op for
|
|
215
|
+
// the same reason, leaving the sequence in flow style.
|
|
216
|
+
//
|
|
217
|
+
// Latent until contract 3: every path this was called with
|
|
218
|
+
// (`connections`, `policy.allow`) already existed, so the branch ran at
|
|
219
|
+
// most once per document. `grants:` is genuinely absent on a profile
|
|
220
|
+
// being repaired, which is what made the second append reachable.
|
|
221
|
+
const created = new YAMLSeq();
|
|
222
|
+
created.add(node);
|
|
223
|
+
this.#document.setIn(path as (string | number)[], created);
|
|
136
224
|
} else {
|
|
137
225
|
(existing as { add(item: unknown): void }).add(node);
|
|
138
226
|
}
|
|
@@ -195,13 +283,18 @@ export class ConfigDocument {
|
|
|
195
283
|
* The half it keeps is the half that matters for a write: the schema, and the
|
|
196
284
|
* scan that stops a credential value being written into config.
|
|
197
285
|
*/
|
|
198
|
-
async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
|
|
286
|
+
async save(options: { shapeOnly?: boolean; contract?: number } = {}): Promise<void> {
|
|
199
287
|
const rendered = this.toString();
|
|
200
288
|
|
|
201
289
|
// Throws on any validation failure, including a credential value that has
|
|
202
290
|
// crept in — so a CLI edit can never introduce one.
|
|
203
|
-
|
|
204
|
-
|
|
291
|
+
//
|
|
292
|
+
// Which schema, decided by the key rather than by the caller. This class
|
|
293
|
+
// edits two shapes now: a profile, and the workspace's `connections.yaml`
|
|
294
|
+
// (ADR-057). Validating one against the other's schema is not a stricter
|
|
295
|
+
// check, it is the wrong one — a connections file has no `instance:` block,
|
|
296
|
+
// so it would be refused for a field it is not supposed to have.
|
|
297
|
+
validateDocument(this.#document.toJSON(), this.#path, this.#location?.key, options);
|
|
205
298
|
|
|
206
299
|
if (!this.#location) {
|
|
207
300
|
throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
|
|
@@ -234,140 +327,3 @@ export class ConfigDocument {
|
|
|
234
327
|
* Written with comments, because this is the file an operator will read first
|
|
235
328
|
* and most of what it needs to say is *why*, not *what*.
|
|
236
329
|
*/
|
|
237
|
-
export function newProfileTemplate(profile: string, port: number): string {
|
|
238
|
-
return `# Lanes Link profile: ${profile}
|
|
239
|
-
#
|
|
240
|
-
# This file is the source of truth for what exists. It never contains a
|
|
241
|
-
# credential value — only "_ref" pointers into the credential store, which
|
|
242
|
-
# lives beside it and is encrypted at rest.
|
|
243
|
-
#
|
|
244
|
-
# Edit it by hand or through the CLI; both are supported, and CLI edits
|
|
245
|
-
# preserve your comments and ordering.
|
|
246
|
-
contract: 2
|
|
247
|
-
|
|
248
|
-
instance:
|
|
249
|
-
profile: ${profile}
|
|
250
|
-
port: ${port}
|
|
251
|
-
host: 127.0.0.1
|
|
252
|
-
|
|
253
|
-
# This file says nothing about where it runs, and that is the point.
|
|
254
|
-
#
|
|
255
|
-
# A profile lives in exactly one target, and the target is the workspace holding
|
|
256
|
-
# this file — which declares its own adapters, once, in lanes-link.yaml beside
|
|
257
|
-
# the profiles/ directory (ADR-052). Moving this profile somewhere else is
|
|
258
|
-
# copying the file there; there is no block in it to edit.
|
|
259
|
-
#
|
|
260
|
-
# Every command still names both, because neither is inferred (ADR-037):
|
|
261
|
-
#
|
|
262
|
-
# lanes link status --profile ${profile} --target <name>
|
|
263
|
-
#
|
|
264
|
-
# The bearer token for the endpoint this profile serves.
|
|
265
|
-
#
|
|
266
|
-
# "lanes link start" serves every profile in the workspace from one URL, and this
|
|
267
|
-
# token is what opens it — so it admits every profile that "lanes link outputs"
|
|
268
|
-
# lists, not only this one. Each call names the profile it means. Run a separate
|
|
269
|
-
# workspace if you need a token that cannot reach them all.
|
|
270
|
-
auth:
|
|
271
|
-
mode: bearer
|
|
272
|
-
token_ref: profile/token
|
|
273
|
-
|
|
274
|
-
limits:
|
|
275
|
-
requests_per_minute: 120 # per profile
|
|
276
|
-
upstream_calls_per_minute: 60 # per connection, protects vendor quota
|
|
277
|
-
|
|
278
|
-
# App registrations, shared by every connection of that vendor.
|
|
279
|
-
oauth_apps: {}
|
|
280
|
-
|
|
281
|
-
# One entry per authorised account. "account" is the identity the provider
|
|
282
|
-
# reports — an address, a workspace — so this list says whose data is reachable
|
|
283
|
-
# without having to look anything up.
|
|
284
|
-
#
|
|
285
|
-
# The seven below hold no account, and that is why they are here already: they
|
|
286
|
-
# reach your own material rather than anybody's API, so there was never anything
|
|
287
|
-
# for a connect step to authorise (ADR-050). What each one is:
|
|
288
|
-
#
|
|
289
|
-
# memory what you want remembered between sessions
|
|
290
|
-
# tasks what you have to do, each with a status
|
|
291
|
-
# assets files you want kept, by name
|
|
292
|
-
# skills procedures you have written, handed to an agent as instructions
|
|
293
|
-
# vault passwords and API keys, released one at a time
|
|
294
|
-
# setup what is connected here, and what connecting more would take
|
|
295
|
-
# entities the people, companies and projects you deal with, and how to
|
|
296
|
-
# reach each of them — so an agent looks an address up rather
|
|
297
|
-
# than recalling one
|
|
298
|
-
#
|
|
299
|
-
# Nothing is stored in any of them until you or an agent puts something there,
|
|
300
|
-
# and none of them can read an account. To switch one off, deny it below —
|
|
301
|
-
# deleting the entry no longer works, because the next connect or deploy puts it
|
|
302
|
-
# back.
|
|
303
|
-
connections:
|
|
304
|
-
- { id: main, provider: memory, account: Memory }
|
|
305
|
-
- { id: main, provider: tasks, account: Tasks }
|
|
306
|
-
- { id: main, provider: assets, account: Assets }
|
|
307
|
-
- { id: main, provider: skills, account: Skills }
|
|
308
|
-
- { id: main, provider: vault, account: Vault }
|
|
309
|
-
- { id: main, provider: setup, account: Setup }
|
|
310
|
-
- { id: main, provider: entities, account: Entities }
|
|
311
|
-
|
|
312
|
-
# Only what is listed here is reachable, and an empty policy grants nothing.
|
|
313
|
-
#
|
|
314
|
-
# Rules name capabilities, never accounts: "gmail.*" covers every Gmail
|
|
315
|
-
# connection in this profile. To grant two accounts differently, run a second
|
|
316
|
-
# profile — profiles share no database and no credential store. They do now
|
|
317
|
-
# share an endpoint and its token, so that separation is enforced per call
|
|
318
|
-
# rather than per URL.
|
|
319
|
-
#
|
|
320
|
-
# allow: ['*'] everything, which is what connect writes
|
|
321
|
-
# allow: [notion.*, gmail.*] two providers
|
|
322
|
-
# deny: [gmail.send_message] a deny always beats an allow
|
|
323
|
-
#
|
|
324
|
-
# The rules below grant each of the seven its whole namespace, writes included —
|
|
325
|
-
# the same thing "connect memory" wrote when it was a command you had to run.
|
|
326
|
-
# Narrowing is one line, and these are the three worth knowing:
|
|
327
|
-
#
|
|
328
|
-
# deny: [memory.write, memory.forget] memory becomes read-only
|
|
329
|
-
# deny: [skills.manage.*] skills can be invoked but not authored
|
|
330
|
-
# deny: [vault.put, vault.remove] nothing new can be stored
|
|
331
|
-
# deny: [entities.write, entities.link, entities.forget]
|
|
332
|
-
# entities becomes read-only
|
|
333
|
-
#
|
|
334
|
-
# Those lists are exhaustive on purpose: a namespace is read-only only when
|
|
335
|
-
# every capability that changes something is named, so "deny: [memory.write]"
|
|
336
|
-
# alone leaves "memory.forget" granted.
|
|
337
|
-
#
|
|
338
|
-
# A vault read is not granted by "vault.*" alone: each stored item is its own
|
|
339
|
-
# "vault.get.<id>" capability and only appears after a restart, so a write can
|
|
340
|
-
# never hand itself a read (ADR-012).
|
|
341
|
-
policy:
|
|
342
|
-
allow: [memory.*, tasks.*, assets.*, skills.*, vault.*, setup.*, entities.*]
|
|
343
|
-
deny: []
|
|
344
|
-
`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export function newWorkspaceTemplate(): string {
|
|
348
|
-
return `# Lanes Link workspace
|
|
349
|
-
#
|
|
350
|
-
# A workspace holds one or more profiles, and one endpoint serves all of them:
|
|
351
|
-
# every call names the profile it means, with --profile. Profiles never share a
|
|
352
|
-
# database or a credential store, so what one holds is invisible to another.
|
|
353
|
-
#
|
|
354
|
-
# This workspace IS a target. "targets:" below says where its bytes go, once,
|
|
355
|
-
# for every profile in it — a profile says nothing about where it runs, so
|
|
356
|
-
# there is one copy of it and nothing to keep in step (ADR-052).
|
|
357
|
-
#
|
|
358
|
-
# A target somewhere else is a pointer, and "deploy" writes one:
|
|
359
|
-
#
|
|
360
|
-
# targets:
|
|
361
|
-
# cloud:
|
|
362
|
-
# workspace: gs://your-bucket
|
|
363
|
-
#
|
|
364
|
-
# The workspace at that address declares its own adapters, and is the only
|
|
365
|
-
# thing that does. Reading it is a network call, which is why "--target cloud"
|
|
366
|
-
# needs that bucket reachable.
|
|
367
|
-
contract: 2
|
|
368
|
-
targets:
|
|
369
|
-
local:
|
|
370
|
-
credentials: { adapter: file }
|
|
371
|
-
storage: { adapter: filesystem }
|
|
372
|
-
`;
|
|
373
|
-
}
|
|
@@ -91,10 +91,12 @@ export function pendingRenames(document: ConfigDocument): PendingRename[] {
|
|
|
91
91
|
* connection that lost its authorisation for no reason anyone can see.
|
|
92
92
|
*/
|
|
93
93
|
export async function migrateRenamedProviders(
|
|
94
|
-
|
|
94
|
+
connections: ConfigDocument,
|
|
95
|
+
profiles: readonly ConfigDocument[],
|
|
95
96
|
credentials: SecretStore,
|
|
96
97
|
options: { apply: boolean },
|
|
97
98
|
): Promise<RenameMigration> {
|
|
99
|
+
const document = connections;
|
|
98
100
|
const rows = pendingRenames(document);
|
|
99
101
|
if (rows.length === 0) return { rows, changes: [], blocked: [] };
|
|
100
102
|
|
|
@@ -116,15 +118,14 @@ export async function migrateRenamedProviders(
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
// The
|
|
120
|
-
// left declaring the old id once the rows above have
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
blocked.push(...policy.blocked);
|
|
121
|
+
// The grant rules second, because whether one can move depends on what is
|
|
122
|
+
// left declaring the old id once the rows above have — and because they live
|
|
123
|
+
// in the profiles now rather than beside the connection (ADR-057), so this is
|
|
124
|
+
// one pass per profile over a rename computed once.
|
|
125
|
+
for (const row of accepted) {
|
|
126
|
+
for (const profile of profiles) {
|
|
127
|
+
changes.push(...renameGrantRules(profile, row, { apply: options.apply }).changes);
|
|
128
|
+
}
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
if (!options.apply || accepted.length === 0) return { rows, changes, blocked };
|
|
@@ -137,8 +138,11 @@ export async function migrateRenamedProviders(
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
// Throws unless the result is a config that loads, which is the assertion
|
|
140
|
-
// worth having here — the whole premise was that it did not.
|
|
141
|
+
// worth having here — the whole premise was that it did not. Both files, in
|
|
142
|
+
// the order that survives a crash between them: a grant naming a connection
|
|
143
|
+
// that has not been renamed yet is refused at load, so the profiles go last.
|
|
141
144
|
await document.save();
|
|
145
|
+
if (options.apply) for (const profile of profiles) await profile.save();
|
|
142
146
|
|
|
143
147
|
for (const row of accepted) await credentials.delete(`${row.from}/${row.id}`);
|
|
144
148
|
|
|
@@ -146,7 +150,7 @@ export async function migrateRenamedProviders(
|
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
/**
|
|
149
|
-
* Rewrite the
|
|
153
|
+
* Rewrite the grant rules that named the old id, where that is unambiguous.
|
|
150
154
|
*
|
|
151
155
|
* A rule names a provider and never an account, so `tasks.*` written for Google
|
|
152
156
|
* Tasks has to follow the rename or the migrated connection is granted nothing
|
|
@@ -154,66 +158,80 @@ export async function migrateRenamedProviders(
|
|
|
154
158
|
* consulted, so the repair would land a row that serves exactly as little as
|
|
155
159
|
* the broken one did.
|
|
156
160
|
*
|
|
157
|
-
*
|
|
158
|
-
* rule
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
+
* The ambiguity this used to guard against is gone, and it is worth saying why.
|
|
162
|
+
* A rule lived in one flat block and named a provider, so a profile holding a
|
|
163
|
+
* Google Tasks row *and* the built-in had one `tasks.*` serving both — moving it
|
|
164
|
+
* would silently revoke whichever kept its name, so the migration reported
|
|
165
|
+
* rather than guessed. A rule lives inside the row that names one connection
|
|
166
|
+
* now (ADR-058), so `tasks.*` on a `google_tasks` row is not ambiguous: it is
|
|
167
|
+
* invalid, and `assertReferentialIntegrity` refuses it. There is nothing left to
|
|
168
|
+
* decide.
|
|
161
169
|
*
|
|
162
|
-
* Both lists. A `deny` written to switch Google Tasks off means it
|
|
163
|
-
* an allow means it on, and leaving it behind would re-enable
|
|
164
|
-
* operator turned off.
|
|
170
|
+
* Both lists, on every row. A `deny` written to switch Google Tasks off means it
|
|
171
|
+
* as firmly as an allow means it on, and leaving it behind would re-enable
|
|
172
|
+
* something the operator turned off.
|
|
173
|
+
*
|
|
174
|
+
* The row's `connection` moves too, and that is new: a grant names the account
|
|
175
|
+
* rather than the provider now (ADR-058), so `tasks.main` becomes
|
|
176
|
+
* `google_tasks.main` or the grant resolves to nothing.
|
|
165
177
|
*/
|
|
166
|
-
function
|
|
178
|
+
function renameGrantRules(
|
|
167
179
|
document: ConfigDocument,
|
|
168
|
-
|
|
169
|
-
options: {
|
|
170
|
-
): { changes: string[]
|
|
180
|
+
moved: { from: string; to: string; id: string; key: string },
|
|
181
|
+
options: { apply: boolean },
|
|
182
|
+
): { changes: string[] } {
|
|
171
183
|
const changes: string[] = [];
|
|
172
|
-
const blocked: string[] = [];
|
|
173
184
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
blocked.push(
|
|
196
|
-
`policy.${field} keeps "${capability}" — this profile still declares a ` +
|
|
197
|
-
`"${provider.from}" connection, so add "${moved}" rather than moving it`,
|
|
198
|
-
);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (options.apply) document.setIn(path, moved);
|
|
203
|
-
changes.push(`policy.${field}: ${capability} → ${moved}`);
|
|
204
|
-
});
|
|
205
|
-
}
|
|
185
|
+
const provider = { from: moved.from, to: moved.to };
|
|
186
|
+
const grants = document.getIn(['grants']) as { items?: unknown[] } | null;
|
|
187
|
+
|
|
188
|
+
(grants?.items ?? []).forEach((_row, at) => {
|
|
189
|
+
const connection = document.getIn(['grants', at, 'connection']);
|
|
190
|
+
if (typeof connection !== 'string') return;
|
|
191
|
+
|
|
192
|
+
// The *exact* connection that moved, not every grant naming the old
|
|
193
|
+
// provider. A workspace holding a Google Tasks row beside the built-in has
|
|
194
|
+
// two `tasks.` grants and only one of them is being renamed — moving both
|
|
195
|
+
// would point the built-in's grant at a connection that does not exist.
|
|
196
|
+
if (connection !== moved.key) return;
|
|
197
|
+
|
|
198
|
+
// The `connection` field always follows, and this is the one place the
|
|
199
|
+
// ambiguity does not reach. It names the exact row being renamed, so there
|
|
200
|
+
// is nothing to guess — and leaving it behind would point the grant at a
|
|
201
|
+
// connection that no longer exists, which `assertGrantsResolve` refuses at
|
|
202
|
+
// load. The rename would have made the workspace unopenable.
|
|
203
|
+
const renamedConnection = `${provider.to}.${moved.id}`;
|
|
204
|
+
if (options.apply) document.setIn(['grants', at, 'connection'], renamedConnection);
|
|
205
|
+
changes.push(`grants[${at}].connection: ${connection} → ${renamedConnection}`);
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
for (const field of ['allow', 'deny'] as const) {
|
|
208
|
+
const rules = document.getIn(['grants', at, field]) as { items?: unknown[] } | null;
|
|
209
|
+
|
|
210
|
+
(rules?.items ?? []).forEach((_item, index) => {
|
|
211
|
+
// Either spelling: a bare pattern, or `{ capability, expires_at }`. The
|
|
212
|
+
// path to it differs; the decision does not.
|
|
213
|
+
const bare = document.getIn(['grants', at, field, index]);
|
|
214
|
+
const path =
|
|
215
|
+
typeof bare === 'string'
|
|
216
|
+
? (['grants', at, field, index] as const)
|
|
217
|
+
: (['grants', at, field, index, 'capability'] as const);
|
|
218
|
+
|
|
219
|
+
const capability = typeof bare === 'string' ? bare : document.getIn(path);
|
|
220
|
+
if (typeof capability !== 'string') return;
|
|
221
|
+
|
|
222
|
+
const [head, ...rest] = capability.split('.');
|
|
223
|
+
if (head !== provider.from) return;
|
|
224
|
+
|
|
225
|
+
const renamed = [provider.to, ...rest].join('.');
|
|
226
|
+
if (options.apply) document.setIn(path, renamed);
|
|
227
|
+
changes.push(`grants[${at}].${field}: ${capability} → ${renamed}`);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { changes };
|
|
208
233
|
}
|
|
209
234
|
|
|
210
|
-
/**
|
|
211
|
-
* Whether a row naming the old provider survives the migration.
|
|
212
|
-
*
|
|
213
|
-
* Computed against the accepted set rather than by re-reading the document,
|
|
214
|
-
* because the same answer has to hold on a report-only run, where nothing has
|
|
215
|
-
* been rewritten yet.
|
|
216
|
-
*/
|
|
217
235
|
function keepsDeclaring(
|
|
218
236
|
document: ConfigDocument,
|
|
219
237
|
provider: string,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
|
|
2
|
+
import { newConnectionsTemplate } from './config-templates.ts';
|
|
3
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
4
|
+
import { ok, print, style, warn } from './output.ts';
|
|
5
|
+
import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './config-repair.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Applying the owner-layer repair across a whole workspace.
|
|
9
|
+
*
|
|
10
|
+
* Split from `config-repair.ts` when that file outgrew the budget, on the seam
|
|
11
|
+
* it already had: that file decides what one profile is missing, and this one
|
|
12
|
+
* walks the workspace applying it. The decision is a pure function of two
|
|
13
|
+
* documents and is tested as one; the sweep is all filesystem, ordering and
|
|
14
|
+
* what to say when a profile will not open.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
18
|
+
function listSurfaces(): string {
|
|
19
|
+
const names = [...DEFAULT_SURFACES];
|
|
20
|
+
const last = names.pop();
|
|
21
|
+
return names.length === 0 ? String(last) : `${names.join(', ')} and ${last}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The workspace's connections document, written from the template if missing. */
|
|
25
|
+
async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
26
|
+
try {
|
|
27
|
+
return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
28
|
+
} catch {
|
|
29
|
+
await writeWorkspaceFile(
|
|
30
|
+
workspaceFiles(workspaceRoot),
|
|
31
|
+
CONNECTIONS_FILE,
|
|
32
|
+
newConnectionsTemplate(),
|
|
33
|
+
);
|
|
34
|
+
return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply that repair across a workspace, saving and reporting what changed.
|
|
40
|
+
*
|
|
41
|
+
* Here rather than in `#deployments`, where it was, because `start` needs it as
|
|
42
|
+
* much as `deploy` does — more, in fact: `start` is the one command an existing
|
|
43
|
+
* install runs without being asked to, so it is the path by which a profile
|
|
44
|
+
* written before ADR-050 gets the layer at all. Two copies of a function that
|
|
45
|
+
* widens a policy is not a thing to have.
|
|
46
|
+
*
|
|
47
|
+
* **The caller scopes it**, and for `deploy` that is exactly the set being
|
|
48
|
+
* uploaded: a profile it sends is a profile the endpoint will serve, so
|
|
49
|
+
* repairing a narrower set would leave a served profile without the surfaces.
|
|
50
|
+
* Note what a `--profile` flag does not mean — it is the flag alone, so a
|
|
51
|
+
* profile resolved from the environment leaves it undefined and that reads as
|
|
52
|
+
* the whole workspace.
|
|
53
|
+
*
|
|
54
|
+
* *Which files are profiles* comes from `listProfiles`, never from an allowlist
|
|
55
|
+
* of what is safe to copy: that would happily hand over a committed
|
|
56
|
+
* `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
|
|
57
|
+
* opens and validates what it is given — which once turned a template into a
|
|
58
|
+
* `ConfigError` aborting a deploy after provisioning had made cloud resources.
|
|
59
|
+
*
|
|
60
|
+
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
61
|
+
* a courtesy on the way past, and the caller's real work should still happen.
|
|
62
|
+
* Not silent, though — nothing else widens a policy without being asked.
|
|
63
|
+
*
|
|
64
|
+
* CLI-side by construction, like everything else in this file: a deployed
|
|
65
|
+
* revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
|
|
66
|
+
* this even if the code let it.
|
|
67
|
+
*/
|
|
68
|
+
export async function repairOwnerLayer(
|
|
69
|
+
workspaceRoot: string,
|
|
70
|
+
profiles: readonly string[] | undefined,
|
|
71
|
+
options: { report?: (line: string) => void } = {},
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
// stdout by default, because every caller but one is printing a report a
|
|
74
|
+
// person reads. `update --json` passes `progress` instead: what it produces is
|
|
75
|
+
// a document, and a line of prose in front of it corrupts whatever is parsing.
|
|
76
|
+
// Routed rather than silenced — nothing else here widens a policy without
|
|
77
|
+
// saying so, and this must not be the exception.
|
|
78
|
+
const say = options.report ?? print;
|
|
79
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
80
|
+
|
|
81
|
+
// One connections document for the whole sweep. The owner layer is the
|
|
82
|
+
// workspace's now (ADR-059), so repairing three profiles must not add three
|
|
83
|
+
// `memory.main` rows — opening it once and saving it once is what makes the
|
|
84
|
+
// second profile see what the first one created.
|
|
85
|
+
// Created if absent rather than refused. A contract-3 workspace always has
|
|
86
|
+
// one, but a hand-made or half-migrated one may not — and this is the repair,
|
|
87
|
+
// so the file it needs is a thing to write rather than a reason to stop.
|
|
88
|
+
const connections = await openOrCreateConnections(workspaceRoot);
|
|
89
|
+
let connectionsChanged = false;
|
|
90
|
+
|
|
91
|
+
for (const name of await listProfiles(workspaceRoot)) {
|
|
92
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
96
|
+
const repair = ensureOwnerLayer(connections, document);
|
|
97
|
+
if (!repaired(repair)) continue;
|
|
98
|
+
|
|
99
|
+
connectionsChanged = true;
|
|
100
|
+
await document.save();
|
|
101
|
+
|
|
102
|
+
say(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
103
|
+
for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
|
|
104
|
+
// Built from `DEFAULT_SURFACES` rather than typed out. The typed-out
|
|
105
|
+
// version still named six after a seventh had been added, so a person
|
|
106
|
+
// watching a deploy was told entities had arrived on the line above and
|
|
107
|
+
// that the layer was six things on the line below.
|
|
108
|
+
say(` ${style.dim(`${listSurfaces()} — your own material, no account behind any of them`)}`);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
say(
|
|
111
|
+
warn(
|
|
112
|
+
`could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (connectionsChanged) await connections.save();
|
|
119
|
+
}
|