@lanes-sh/link 0.7.1 → 0.8.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 +136 -61
- 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 +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/brand.ts +39 -10
- package/src/cli/callback-page.ts +37 -104
- 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 +88 -87
- 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 +4 -2
- 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 +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -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 +29 -12
- 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 +52 -4
- 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/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- 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.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -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 +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- 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 +15 -15
- package/src/deployments/upload.ts +46 -22
- 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 +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- 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 +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -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 +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -75
- 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/cli/config-edit.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { Document, parseDocument, type Node } from 'yaml';
|
|
3
3
|
import {
|
|
4
|
+
CONNECTIONS_FILE,
|
|
4
5
|
ConfigError,
|
|
6
|
+
WORKSPACE_FILE,
|
|
7
|
+
workspaceSchema,
|
|
8
|
+
assertConnectionsUnique,
|
|
9
|
+
connectionsFileSchema,
|
|
10
|
+
findSecrets,
|
|
11
|
+
formatSecretFindings,
|
|
5
12
|
isRemoteWorkspace,
|
|
6
13
|
readWorkspaceFile,
|
|
7
14
|
validateConfig,
|
|
@@ -24,6 +31,53 @@ import {
|
|
|
24
31
|
* refuses to run.
|
|
25
32
|
*/
|
|
26
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Validate a document against the schema for the file it is.
|
|
36
|
+
*
|
|
37
|
+
* The key is the discriminator because it is the only thing that is always
|
|
38
|
+
* right: a caller could be asked to say which shape it holds, and a caller that
|
|
39
|
+
* said the wrong one would get the wrong check silently.
|
|
40
|
+
*/
|
|
41
|
+
function validateDocument(
|
|
42
|
+
raw: unknown,
|
|
43
|
+
path: string,
|
|
44
|
+
key: string | undefined,
|
|
45
|
+
options: { shapeOnly?: boolean },
|
|
46
|
+
): void {
|
|
47
|
+
if (key === WORKSPACE_FILE) {
|
|
48
|
+
const parsed = workspaceSchema.safeParse(raw);
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
throw new ConfigError(
|
|
51
|
+
`${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (key === CONNECTIONS_FILE) {
|
|
58
|
+
const secrets = findSecrets(raw);
|
|
59
|
+
if (secrets.length > 0) {
|
|
60
|
+
throw new ConfigError(
|
|
61
|
+
`${path}: ${formatSecretFindings(secrets)}`,
|
|
62
|
+
secrets.map((finding) => finding.path),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parsed = connectionsFileSchema.safeParse(raw);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
throw new ConfigError(
|
|
69
|
+
`${path}:\n${parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n')}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assertConnectionsUnique(parsed.data.connections);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options.shapeOnly === true) validateConfigShape(raw, path);
|
|
78
|
+
else validateConfig(raw, path);
|
|
79
|
+
}
|
|
80
|
+
|
|
27
81
|
export class ConfigDocument {
|
|
28
82
|
readonly #document: Document;
|
|
29
83
|
readonly #path: string;
|
|
@@ -47,7 +101,20 @@ export class ConfigDocument {
|
|
|
47
101
|
* `gs://` URL produces something that addresses nothing.
|
|
48
102
|
*/
|
|
49
103
|
static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
|
|
50
|
-
|
|
104
|
+
return ConfigDocument.openKey(workspaceRoot, `profiles/${profile}.yaml`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Open any document the workspace holds, by key.
|
|
109
|
+
*
|
|
110
|
+
* `open` above is this with the profile path spelled out, and stays because
|
|
111
|
+
* it is what almost every caller wants. This exists for `connections.yaml`,
|
|
112
|
+
* which is a workspace document rather than a profile's (ADR-057) and needs
|
|
113
|
+
* the same comment-preserving edit path — a connection row carries the
|
|
114
|
+
* account label an operator wrote, and rewriting the file through the schema
|
|
115
|
+
* would drop every comment beside it.
|
|
116
|
+
*/
|
|
117
|
+
static async openKey(workspaceRoot: string, key: string): Promise<ConfigDocument> {
|
|
51
118
|
const shown = isRemoteWorkspace(workspaceRoot)
|
|
52
119
|
? `${workspaceRoot}/${key}`
|
|
53
120
|
: `${workspaceRoot}/${key}`;
|
|
@@ -200,8 +267,13 @@ export class ConfigDocument {
|
|
|
200
267
|
|
|
201
268
|
// Throws on any validation failure, including a credential value that has
|
|
202
269
|
// crept in — so a CLI edit can never introduce one.
|
|
203
|
-
|
|
204
|
-
|
|
270
|
+
//
|
|
271
|
+
// Which schema, decided by the key rather than by the caller. This class
|
|
272
|
+
// edits two shapes now: a profile, and the workspace's `connections.yaml`
|
|
273
|
+
// (ADR-057). Validating one against the other's schema is not a stricter
|
|
274
|
+
// check, it is the wrong one — a connections file has no `instance:` block,
|
|
275
|
+
// so it would be refused for a field it is not supposed to have.
|
|
276
|
+
validateDocument(this.#document.toJSON(), this.#path, this.#location?.key, options);
|
|
205
277
|
|
|
206
278
|
if (!this.#location) {
|
|
207
279
|
throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
|
|
@@ -234,140 +306,3 @@ export class ConfigDocument {
|
|
|
234
306
|
* Written with comments, because this is the file an operator will read first
|
|
235
307
|
* and most of what it needs to say is *why*, not *what*.
|
|
236
308
|
*/
|
|
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,
|
package/src/cli/config-repair.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { newConnectionsTemplate } from './config-templates.ts';
|
|
2
|
+
import { CONNECTIONS_FILE, listProfiles, workspaceFiles, writeWorkspaceFile } from '#profile';
|
|
2
3
|
import { ConfigDocument } from './config-edit.ts';
|
|
3
4
|
import { ok, print, style, warn } from './output.ts';
|
|
4
5
|
|
|
@@ -100,61 +101,97 @@ export interface SurfaceRepair {
|
|
|
100
101
|
* (ADR-023) so it could not write this even if the code let it.
|
|
101
102
|
*/
|
|
102
103
|
export function ensureReservedConnection(
|
|
103
|
-
|
|
104
|
+
connections: ConfigDocument,
|
|
105
|
+
profile: ConfigDocument,
|
|
104
106
|
provider: ReservedSurface,
|
|
107
|
+
options: { grants?: boolean } = {},
|
|
105
108
|
): SurfaceRepair {
|
|
109
|
+
// Whether the profile half is written at all. `connect` without `--profile`
|
|
110
|
+
// repairs the workspace's connection rows and touches no profile, because it
|
|
111
|
+
// was not told which one to touch (ADR-057).
|
|
112
|
+
const grants = options.grants ?? true;
|
|
106
113
|
// Raw YAML, so nothing here has been through a schema: this runs over sibling
|
|
107
114
|
// profiles that were never validated, and every field is whatever was typed.
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
policy?: { allow?: unknown; deny?: unknown };
|
|
111
|
-
} | null;
|
|
115
|
+
const workspace = connections.toJSON() as { connections?: unknown } | null;
|
|
116
|
+
const config = profile.toJSON() as { grants?: unknown } | null;
|
|
112
117
|
|
|
113
118
|
const rule = `${provider}.*`;
|
|
114
119
|
const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
|
|
115
120
|
|
|
121
|
+
const rows = Array.isArray(workspace?.connections) ? workspace.connections : [];
|
|
122
|
+
const held_grants = Array.isArray(config?.grants) ? config.grants : [];
|
|
123
|
+
|
|
124
|
+
// The row this profile would use. Any instance of the provider will do, and
|
|
125
|
+
// the *first* one is taken rather than `main` specifically: an operator who
|
|
126
|
+
// renamed theirs should not get a second one bolted on beside it.
|
|
127
|
+
const existing = rows.find(
|
|
128
|
+
(row) => (row as { provider?: unknown } | null)?.provider === provider,
|
|
129
|
+
) as { id?: unknown } | undefined;
|
|
130
|
+
const id = typeof existing?.id === 'string' ? existing.id : 'main';
|
|
131
|
+
const key = `${provider}.${id}`;
|
|
132
|
+
|
|
133
|
+
const held = held_grants.find(
|
|
134
|
+
(row) => (row as { connection?: unknown } | null)?.connection === key,
|
|
135
|
+
) as { allow?: unknown; deny?: unknown } | undefined;
|
|
136
|
+
|
|
116
137
|
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
117
138
|
// widen nothing while announcing that an agent can now read the surface,
|
|
118
|
-
// which would be false.
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// holds for `identity`, where the next `identity add` is what would put them
|
|
122
|
-
// back.
|
|
139
|
+
// which would be false. Deleting the row no longer removes the surface
|
|
140
|
+
// either, because the next `connect` or `deploy` puts it back; a deny is the
|
|
141
|
+
// way it stays off, so it is the one thing this must not undo.
|
|
123
142
|
//
|
|
124
143
|
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
125
144
|
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
126
145
|
// the repair untouched — which is the point of denying one capability.
|
|
127
|
-
if (patternsIn(
|
|
146
|
+
if (patternsIn(held?.deny).some(covers)) return { changes: [], granted: [] };
|
|
128
147
|
|
|
129
148
|
const changes: string[] = [];
|
|
130
149
|
const granted: string[] = [];
|
|
131
150
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// Inline, and `main` for the id, so a repaired profile is spelled exactly
|
|
138
|
-
// like `newProfileTemplate` writes a fresh one. Two spellings of one row is
|
|
139
|
-
// how a template and its repair drift apart.
|
|
140
|
-
document.addTo(
|
|
151
|
+
if (existing === undefined) {
|
|
152
|
+
// Inline, and `main` for the id, so a repaired workspace is spelled exactly
|
|
153
|
+
// like `newConnectionsTemplate` writes a fresh one. Two spellings of one row
|
|
154
|
+
// is how a template and its repair drift apart.
|
|
155
|
+
connections.addTo(
|
|
141
156
|
['connections'],
|
|
142
157
|
{ id: 'main', provider, account: RESERVED_SURFACES[provider] },
|
|
143
158
|
{ inline: true },
|
|
144
159
|
);
|
|
145
|
-
changes.push(`connections += ${
|
|
160
|
+
changes.push(`connections.yaml += ${key}`);
|
|
146
161
|
}
|
|
147
162
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
163
|
+
if (!grants) return { changes, granted };
|
|
164
|
+
|
|
165
|
+
if (held === undefined) {
|
|
166
|
+
profile.addTo(['grants'], { connection: key, allow: [rule], deny: [] }, { inline: true });
|
|
167
|
+
changes.push(`grants += ${key}`);
|
|
168
|
+
granted.push(rule);
|
|
169
|
+
} else if (!patternsIn(held.allow).some(covers)) {
|
|
170
|
+
// A row that exists and grants nothing is a surface that is present and
|
|
171
|
+
// silent. Widening it back is the repair; the deny check above is what stops
|
|
172
|
+
// this undoing a deliberate narrowing.
|
|
173
|
+
const at = held_grants.indexOf(held as never);
|
|
174
|
+
profile.addTo(['grants', at, 'allow'], rule, { inline: true });
|
|
152
175
|
granted.push(rule);
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
return { changes, granted };
|
|
156
179
|
}
|
|
157
180
|
|
|
181
|
+
/** The workspace's connections document, written from the template if missing. */
|
|
182
|
+
async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
183
|
+
try {
|
|
184
|
+
return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
185
|
+
} catch {
|
|
186
|
+
await writeWorkspaceFile(
|
|
187
|
+
workspaceFiles(workspaceRoot),
|
|
188
|
+
CONNECTIONS_FILE,
|
|
189
|
+
newConnectionsTemplate(),
|
|
190
|
+
);
|
|
191
|
+
return ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
158
195
|
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
159
196
|
/** `memory, tasks, assets, skills, vault, setup and entities`, in repair order. */
|
|
160
197
|
function listSurfaces(): string {
|
|
@@ -169,7 +206,7 @@ export function repaired(repair: SurfaceRepair): boolean {
|
|
|
169
206
|
|
|
170
207
|
/** The repair as display lines, in the order the two halves are applied. */
|
|
171
208
|
export function repairLines(repair: SurfaceRepair): string[] {
|
|
172
|
-
return [...repair.changes, ...repair.granted.map((rule) => `
|
|
209
|
+
return [...repair.changes, ...repair.granted.map((rule) => `grants[].allow += ${rule}`)];
|
|
173
210
|
}
|
|
174
211
|
|
|
175
212
|
/**
|
|
@@ -219,12 +256,16 @@ function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
|
219
256
|
* Each surface is still decided independently, so a profile that denied exactly
|
|
220
257
|
* one of them keeps that decision while the rest are repaired.
|
|
221
258
|
*/
|
|
222
|
-
export function ensureOwnerLayer(
|
|
259
|
+
export function ensureOwnerLayer(
|
|
260
|
+
connections: ConfigDocument,
|
|
261
|
+
profile: ConfigDocument,
|
|
262
|
+
options: { grants?: boolean } = {},
|
|
263
|
+
): SurfaceRepair {
|
|
223
264
|
const changes: string[] = [];
|
|
224
265
|
const granted: string[] = [];
|
|
225
266
|
|
|
226
267
|
for (const provider of DEFAULT_SURFACES) {
|
|
227
|
-
const repair = ensureReservedConnection(
|
|
268
|
+
const repair = ensureReservedConnection(connections, profile, provider, options);
|
|
228
269
|
changes.push(...repair.changes);
|
|
229
270
|
granted.push(...repair.granted);
|
|
230
271
|
}
|
|
@@ -241,8 +282,11 @@ export function ensureOwnerLayer(document: ConfigDocument): SurfaceRepair {
|
|
|
241
282
|
* paragraph of the instructions budget to say so. `identity add` is the only
|
|
242
283
|
* caller, so the grant arrives exactly when there is something behind it.
|
|
243
284
|
*/
|
|
244
|
-
export function ensureIdentityConnection(
|
|
245
|
-
|
|
285
|
+
export function ensureIdentityConnection(
|
|
286
|
+
connections: ConfigDocument,
|
|
287
|
+
profile: ConfigDocument,
|
|
288
|
+
): SurfaceRepair {
|
|
289
|
+
return ensureReservedConnection(connections, profile, 'identity');
|
|
246
290
|
}
|
|
247
291
|
|
|
248
292
|
/**
|
|
@@ -288,14 +332,25 @@ export async function repairOwnerLayer(
|
|
|
288
332
|
const say = options.report ?? print;
|
|
289
333
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
290
334
|
|
|
335
|
+
// One connections document for the whole sweep. The owner layer is the
|
|
336
|
+
// workspace's now (ADR-059), so repairing three profiles must not add three
|
|
337
|
+
// `memory.main` rows — opening it once and saving it once is what makes the
|
|
338
|
+
// second profile see what the first one created.
|
|
339
|
+
// Created if absent rather than refused. A contract-3 workspace always has
|
|
340
|
+
// one, but a hand-made or half-migrated one may not — and this is the repair,
|
|
341
|
+
// so the file it needs is a thing to write rather than a reason to stop.
|
|
342
|
+
const connections = await openOrCreateConnections(workspaceRoot);
|
|
343
|
+
let connectionsChanged = false;
|
|
344
|
+
|
|
291
345
|
for (const name of await listProfiles(workspaceRoot)) {
|
|
292
346
|
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
293
347
|
|
|
294
348
|
try {
|
|
295
349
|
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
296
|
-
const repair = ensureOwnerLayer(document);
|
|
350
|
+
const repair = ensureOwnerLayer(connections, document);
|
|
297
351
|
if (!repaired(repair)) continue;
|
|
298
352
|
|
|
353
|
+
connectionsChanged = true;
|
|
299
354
|
await document.save();
|
|
300
355
|
|
|
301
356
|
say(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
@@ -313,4 +368,6 @@ export async function repairOwnerLayer(
|
|
|
313
368
|
);
|
|
314
369
|
}
|
|
315
370
|
}
|
|
371
|
+
|
|
372
|
+
if (connectionsChanged) await connections.save();
|
|
316
373
|
}
|