@lanes-sh/link 0.7.2 → 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/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 +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 -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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { SUPPORTED_CONTRACT } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The files a fresh workspace and a fresh profile are written from.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `config-edit.ts` because they are prose rather than machinery:
|
|
7
|
+
* that file knows how to edit YAML without disturbing what an operator wrote,
|
|
8
|
+
* and these are the comments an operator reads. Keeping both in one file put it
|
|
9
|
+
* over the size budget, and the seam was already there.
|
|
10
|
+
*
|
|
11
|
+
* **The template and the repair must write a row in one spelling.** Two
|
|
12
|
+
* spellings of one row is how they drift apart, and `config-edit.test.ts`
|
|
13
|
+
* asserts that a fresh profile and workspace need no repair, which is the check
|
|
14
|
+
* that catches it (ADR-050).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export function newProfileTemplate(profile: string, port: number, subject?: string): string {
|
|
18
|
+
return `# Lanes Link profile: ${profile}
|
|
19
|
+
#
|
|
20
|
+
# A profile is a *selection*: which of the workspace's accounts this agent may
|
|
21
|
+
# reach, what it may do with each, and who may use it. The accounts themselves
|
|
22
|
+
# live in connections.yaml beside this file, because authorising an account and
|
|
23
|
+
# deciding what may be done with it are two different acts (ADR-057).
|
|
24
|
+
#
|
|
25
|
+
# This file never contains a credential value — only "_ref" pointers into the
|
|
26
|
+
# credential store, which is the workspace's and is encrypted at rest.
|
|
27
|
+
#
|
|
28
|
+
# Edit it by hand or through the CLI; both are supported, and CLI edits preserve
|
|
29
|
+
# your comments and ordering.
|
|
30
|
+
contract: 3
|
|
31
|
+
|
|
32
|
+
instance:
|
|
33
|
+
profile: ${profile}
|
|
34
|
+
port: ${port}
|
|
35
|
+
host: 127.0.0.1
|
|
36
|
+
|
|
37
|
+
# What this profile is for, in your own words. Members see it, and so does
|
|
38
|
+
# setup_overview — "reads my mail, keeps the calendar, never sends" is what
|
|
39
|
+
# somebody needs to know before accepting it.
|
|
40
|
+
# description:
|
|
41
|
+
|
|
42
|
+
# This file says nothing about where it runs, and that is the point.
|
|
43
|
+
#
|
|
44
|
+
# A profile lives in exactly one workspace, and that workspace declares its own
|
|
45
|
+
# adapters, once, in lanes-link.yaml beside the profiles/ directory (ADR-052).
|
|
46
|
+
# Moving this profile somewhere else is copying the file there.
|
|
47
|
+
#
|
|
48
|
+
# lanes link status --profile ${profile} --workspace <name>
|
|
49
|
+
#
|
|
50
|
+
# The bearer token below is for CI. People sign in instead: a client that asks
|
|
51
|
+
# for authorization is sent to the Lanes login, and comes back as somebody
|
|
52
|
+
# (ADR-062). "lanes link token show" is for a runner with no browser.
|
|
53
|
+
auth:
|
|
54
|
+
mode: bearer
|
|
55
|
+
token_ref: profile/token
|
|
56
|
+
authorization:
|
|
57
|
+
mode: self
|
|
58
|
+
|
|
59
|
+
limits:
|
|
60
|
+
requests_per_minute: 120 # per profile
|
|
61
|
+
upstream_calls_per_minute: 60 # per connection, protects vendor quota
|
|
62
|
+
|
|
63
|
+
# One row per connection this profile may reach, and what it may do with each.
|
|
64
|
+
#
|
|
65
|
+
# A row is the grant. There is no separate list of accounts and list of rules
|
|
66
|
+
# that have to agree — naming a connection here is what makes it reachable, and
|
|
67
|
+
# the allow list is what makes any of its capabilities callable. An account the
|
|
68
|
+
# workspace holds and this file does not name is simply absent: not denied, not
|
|
69
|
+
# advertised, not there.
|
|
70
|
+
#
|
|
71
|
+
# Rules name capabilities of that row's own provider. "gmail.*" covers
|
|
72
|
+
# everything Gmail offers *for that one account*, which is what lets a second
|
|
73
|
+
# row over a second mailbox allow something different (ADR-058).
|
|
74
|
+
#
|
|
75
|
+
# The seven below hold no account, and that is why they are here already: they
|
|
76
|
+
# reach your own material rather than anybody's API, so there was never anything
|
|
77
|
+
# for a connect step to authorise (ADR-050). What each one is:
|
|
78
|
+
#
|
|
79
|
+
# memory what you want remembered between sessions
|
|
80
|
+
# tasks what you have to do, each with a status
|
|
81
|
+
# assets files you want kept, by name
|
|
82
|
+
# skills procedures you have written, handed to an agent as instructions
|
|
83
|
+
# vault passwords and API keys, released one at a time
|
|
84
|
+
# setup what is connected here, and what connecting more would take
|
|
85
|
+
# entities the people, companies and projects you deal with, and how to
|
|
86
|
+
# reach each of them — so an agent looks an address up rather
|
|
87
|
+
# than recalling one
|
|
88
|
+
#
|
|
89
|
+
# To switch one off, add it to that row's deny — deleting the row no longer
|
|
90
|
+
# works, because the next connect or deploy puts it back. The three narrowings
|
|
91
|
+
# worth knowing:
|
|
92
|
+
#
|
|
93
|
+
# deny: [memory.write] remember nothing new
|
|
94
|
+
# deny: [skills.manage.*] invoke procedures, do not write them
|
|
95
|
+
# deny: [vault.put, vault.remove]
|
|
96
|
+
grants:
|
|
97
|
+
- { connection: memory.main, allow: [memory.*], deny: [] }
|
|
98
|
+
- { connection: tasks.main, allow: [tasks.*], deny: [] }
|
|
99
|
+
- { connection: assets.main, allow: [assets.*], deny: [] }
|
|
100
|
+
- { connection: skills.main, allow: [skills.*], deny: [] }
|
|
101
|
+
- { connection: vault.main, allow: [vault.*], deny: [] }
|
|
102
|
+
- { connection: setup.main, allow: [setup.*], deny: [] }
|
|
103
|
+
- { connection: entities.main, allow: [entities.*], deny: [] }
|
|
104
|
+
|
|
105
|
+
# Who may consume this profile (ADR-060).
|
|
106
|
+
#
|
|
107
|
+
# Empty is nobody, not everybody — default deny on the identity axis. A caller
|
|
108
|
+
# proves who they are by signing in to Lanes, and reaches this profile only if
|
|
109
|
+
# their subject is listed here.
|
|
110
|
+
#
|
|
111
|
+
# "owner" may edit this list. Both roles reach exactly what the grants above
|
|
112
|
+
# allow: a role that changed what an agent could call would be a second policy
|
|
113
|
+
# system beside grants, answering a question the first one already answers.
|
|
114
|
+
members:${
|
|
115
|
+
subject
|
|
116
|
+
? `
|
|
117
|
+
- { subject: ${subject}, role: owner }`
|
|
118
|
+
: ' []'
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function newWorkspaceTemplate(): string {
|
|
124
|
+
return `# Lanes Link workspace
|
|
125
|
+
#
|
|
126
|
+
# A workspace holds the accounts you have authorised (connections.yaml) and the
|
|
127
|
+
# profiles that select among them (profiles/). One endpoint serves all of them:
|
|
128
|
+
# every call names the profile it means, with --profile.
|
|
129
|
+
#
|
|
130
|
+
# "workspaces:" below says where this one's bytes go, once, for every profile in
|
|
131
|
+
# it — a profile says nothing about where it runs, so there is one copy of it and
|
|
132
|
+
# nothing to keep in step (ADR-052).
|
|
133
|
+
#
|
|
134
|
+
# A workspace somewhere else is a pointer, and "deploy" writes one:
|
|
135
|
+
#
|
|
136
|
+
# workspaces:
|
|
137
|
+
# cloud:
|
|
138
|
+
# at: gs://your-bucket
|
|
139
|
+
# lanes_workspace: <id> # whose members may be delegated to
|
|
140
|
+
#
|
|
141
|
+
# The workspace at that address declares its own adapters, and is the only thing
|
|
142
|
+
# that does. Reading it is a network call, which is why "--workspace cloud" needs
|
|
143
|
+
# that bucket reachable.
|
|
144
|
+
#
|
|
145
|
+
# default_workspace is used when --workspace is absent, and every command that
|
|
146
|
+
# uses it prints which one it got. Commands that publish or destroy — deploy,
|
|
147
|
+
# sync, secrets push, profile remove, disconnect, token rotate — refuse it and
|
|
148
|
+
# make you type the name (ADR-061).
|
|
149
|
+
contract: 3
|
|
150
|
+
default_workspace: local
|
|
151
|
+
workspaces:
|
|
152
|
+
local:
|
|
153
|
+
credentials: { adapter: file }
|
|
154
|
+
storage: { adapter: filesystem }
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `connections.yaml` for a workspace that has just been created.
|
|
160
|
+
*
|
|
161
|
+
* The owner layer arrives here rather than in the profile, because these are
|
|
162
|
+
* connections now (ADR-059) and a second profile should select the same stores
|
|
163
|
+
* rather than get its own empty ones. `ensureOwnerLayer` keeps this in one
|
|
164
|
+
* spelling with the repair, which `config-edit.test.ts` asserts by checking that
|
|
165
|
+
* a fresh workspace needs no repair.
|
|
166
|
+
*/
|
|
167
|
+
export function newConnectionsTemplate(): string {
|
|
168
|
+
return `# Lanes Link connections
|
|
169
|
+
#
|
|
170
|
+
# Every account authorised in this workspace, in one place. A profile names the
|
|
171
|
+
# ones it may reach in its own "grants:" block — connecting an account and
|
|
172
|
+
# deciding what may be done with it are two acts, and only the second belongs to
|
|
173
|
+
# a profile (ADR-057).
|
|
174
|
+
#
|
|
175
|
+
# "account" is the identity the provider reports — an address, a workspace — so
|
|
176
|
+
# this list says whose data is reachable without having to look anything up.
|
|
177
|
+
# "label" is your own word for the same row, and only ever displayed.
|
|
178
|
+
#
|
|
179
|
+
# The seven below hold no account: they reach your own material rather than
|
|
180
|
+
# anybody's API, so there was never anything for a connect step to authorise
|
|
181
|
+
# (ADR-050). Make a second one — "lanes link connect memory --id work" — when you
|
|
182
|
+
# want two profiles to share nothing.
|
|
183
|
+
contract: 3
|
|
184
|
+
|
|
185
|
+
connections:
|
|
186
|
+
- { id: main, provider: memory, account: Memory }
|
|
187
|
+
- { id: main, provider: tasks, account: Tasks }
|
|
188
|
+
- { id: main, provider: assets, account: Assets }
|
|
189
|
+
- { id: main, provider: skills, account: Skills }
|
|
190
|
+
- { id: main, provider: vault, account: Vault }
|
|
191
|
+
- { id: main, provider: setup, account: Setup }
|
|
192
|
+
- { id: main, provider: entities, account: Entities }
|
|
193
|
+
|
|
194
|
+
# App registrations, shared by every connection of that vendor.
|
|
195
|
+
oauth_apps: {}
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { ConfigError, DATA_DIR, isRemoteWorkspace, layout } from '#profile';
|
|
2
|
+
import { createFileSecretStore } from '#secrets';
|
|
3
|
+
import type { BlobStore } from '#stores/blobs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The half of the contract-3 migration that moves bytes rather than YAML.
|
|
7
|
+
*
|
|
8
|
+
* Split from `contract3.ts` on the seam the migration already has: that file
|
|
9
|
+
* decides *what* the new shape is, and this one carries the credentials and
|
|
10
|
+
* objects into it. Both halves are ordered so a crash between any two steps
|
|
11
|
+
* leaves a workspace that still opens, and the rule that makes that true lives
|
|
12
|
+
* here — nothing is deleted until what replaced it has been read back.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Move {
|
|
16
|
+
readonly from: string;
|
|
17
|
+
readonly to: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `gmail.main` — how a connection is addressed in every file after this. */
|
|
21
|
+
function keyOf(connection: { provider: string; id: string }): string {
|
|
22
|
+
return `${connection.provider}.${connection.id}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which credential refs the merged store will hold.
|
|
27
|
+
*
|
|
28
|
+
* Read-only, and it runs before anything is written so the report an operator
|
|
29
|
+
* confirms is the real one. A ref present in two profiles' stores with two
|
|
30
|
+
* different values is the one thing this cannot resolve, and it is reported
|
|
31
|
+
* rather than merged: both are real credentials, and picking either would point
|
|
32
|
+
* a connection at the wrong account's token.
|
|
33
|
+
*
|
|
34
|
+
* **A workspace in a bucket has nothing to merge, and this says so rather than
|
|
35
|
+
* finding out.** `workspacePath` refuses a filesystem adapter against a remote
|
|
36
|
+
* root, so the only credential store such a workspace can declare is
|
|
37
|
+
* `gcp-secret-manager` — whose refs were never scoped by profile, and are
|
|
38
|
+
* therefore already what contract 3 wants. Without the guard the path below is
|
|
39
|
+
* built by string interpolation into `gs://bucket/data/<profile>/credentials.enc`
|
|
40
|
+
* and handed to `Bun.file`, where the failure is swallowed by the `catch` and
|
|
41
|
+
* reads exactly like a workspace with no credentials in it.
|
|
42
|
+
*/
|
|
43
|
+
export async function planCredentials(root: string, profiles: readonly string[]): Promise<string[]> {
|
|
44
|
+
if (isRemoteWorkspace(root)) return [];
|
|
45
|
+
|
|
46
|
+
const refs = new Set<string>();
|
|
47
|
+
|
|
48
|
+
for (const profile of profiles) {
|
|
49
|
+
const store = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
|
|
50
|
+
try {
|
|
51
|
+
for (const ref of await store.list()) refs.add(ref);
|
|
52
|
+
} catch {
|
|
53
|
+
// A store that will not open is reported by `doctor`, not here. This is a
|
|
54
|
+
// preview and must not fail on a workspace that is already broken.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...refs].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Copy every profile's credentials into the workspace store.
|
|
63
|
+
*
|
|
64
|
+
* Written and read back before the old stores are touched, which is the whole
|
|
65
|
+
* of the safety argument: a half-finished merge that has not deleted anything is
|
|
66
|
+
* recoverable by running it again, and one that deleted first is not.
|
|
67
|
+
*
|
|
68
|
+
* The old stores are left in place regardless. They are a few kilobytes, they
|
|
69
|
+
* are the only copy of anything if this went wrong, and `doctor` names them so
|
|
70
|
+
* an operator can remove them once the endpoint has served a request.
|
|
71
|
+
*
|
|
72
|
+
* Skipped entirely for a workspace in a bucket, for the reason `planCredentials`
|
|
73
|
+
* gives: its credentials are in Secret Manager under refs that were never
|
|
74
|
+
* per-profile, so there is no second store to fold in.
|
|
75
|
+
*/
|
|
76
|
+
export async function mergeCredentials(root: string, profiles: readonly string[]): Promise<void> {
|
|
77
|
+
if (isRemoteWorkspace(root)) return;
|
|
78
|
+
|
|
79
|
+
const destination = createFileSecretStore({ path: `${root}/${layout.credentials()}` });
|
|
80
|
+
|
|
81
|
+
for (const profile of profiles) {
|
|
82
|
+
const source = createFileSecretStore({ path: `${root}/${DATA_DIR}/${profile}/credentials.enc` });
|
|
83
|
+
|
|
84
|
+
let refs: string[];
|
|
85
|
+
try {
|
|
86
|
+
refs = await source.list();
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const ref of refs) {
|
|
92
|
+
const value = await source.get(ref);
|
|
93
|
+
if (value === null) continue;
|
|
94
|
+
|
|
95
|
+
const held = await destination.get(ref);
|
|
96
|
+
if (held !== null) {
|
|
97
|
+
if (held === value) continue;
|
|
98
|
+
throw new ConfigError(
|
|
99
|
+
`Two profiles hold different values for the credential "${ref}", and this migration ` +
|
|
100
|
+
`cannot choose between them.\n` +
|
|
101
|
+
` Both are real credentials for different accounts, and picking either would point a ` +
|
|
102
|
+
`connection at the wrong one.\n` +
|
|
103
|
+
` Rename one connection before migrating, so its credential ref differs.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await destination.set(ref, value);
|
|
108
|
+
if ((await destination.get(ref)) !== value) {
|
|
109
|
+
throw new ConfigError(
|
|
110
|
+
`The credential "${ref}" did not read back after being written to ` +
|
|
111
|
+
`${layout.credentials()}. Nothing has been deleted; fix the store and run this again.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where every object under `data/<profile>/` is going.
|
|
120
|
+
*
|
|
121
|
+
* Driven by `perProfile`, the per-profile map the hoist already built from old
|
|
122
|
+
* key to new — because the rename that matters is *this profile's*. The first
|
|
123
|
+
* version of this keyed a lookup by the hoisted (new) key and queried it with
|
|
124
|
+
* the old one, which made the resolution an unconditional no-op: provider and
|
|
125
|
+
* connection ids contain no dot, so anything the map returned already had the id
|
|
126
|
+
* being looked up. Two profiles holding `gmail.main` for different mailboxes
|
|
127
|
+
* both sent their blobs to `data/gmail/main/`, and the second one's landed in
|
|
128
|
+
* the first one's namespace.
|
|
129
|
+
*
|
|
130
|
+
* Anything that matches no rule is left exactly where it is: this moves what it
|
|
131
|
+
* understands and never deletes what it does not.
|
|
132
|
+
*/
|
|
133
|
+
export async function planMoves(
|
|
134
|
+
files: BlobStore,
|
|
135
|
+
profiles: readonly string[],
|
|
136
|
+
perProfile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
|
137
|
+
): Promise<Move[]> {
|
|
138
|
+
const moves: Move[] = [];
|
|
139
|
+
|
|
140
|
+
for (const profile of profiles) {
|
|
141
|
+
const mapping = perProfile.get(profile) ?? new Map<string, string>();
|
|
142
|
+
const prefix = `${DATA_DIR}/${profile}/`;
|
|
143
|
+
|
|
144
|
+
for (const blob of await files.list(prefix)) {
|
|
145
|
+
const rest = blob.key.slice(prefix.length);
|
|
146
|
+
const [head, ...tail] = rest.split('/');
|
|
147
|
+
if (head === undefined) continue;
|
|
148
|
+
|
|
149
|
+
// The credential store is merged rather than moved, and the old copy is
|
|
150
|
+
// deliberately left behind. `state.kv` and `audit.log` are per profile and
|
|
151
|
+
// become the workspace's, but their contents already carry the profile in
|
|
152
|
+
// every record, so they are concatenated by moving the objects across.
|
|
153
|
+
if (head === 'credentials.enc' || head === 'credentials.enc.key') continue;
|
|
154
|
+
|
|
155
|
+
// The instance this profile's single-instance surfaces became. Both are
|
|
156
|
+
// one store per profile in contract 2 and one per *connection* in
|
|
157
|
+
// contract 3, so two profiles' vaults are two documents — sending both to
|
|
158
|
+
// `vault('main')` orphaned the second and silently gave it the first's,
|
|
159
|
+
// which is the worst of the collisions because the wrong answer is a
|
|
160
|
+
// credential (ADR-059).
|
|
161
|
+
if (head === 'vault.enc') {
|
|
162
|
+
moves.push({ from: blob.key, to: layout.vault(instanceOf(mapping, 'vault')) });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (head === 'vault.enc.key') {
|
|
166
|
+
moves.push({ from: blob.key, to: `${layout.vault(instanceOf(mapping, 'vault'))}.key` });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (head === 'skills.d') {
|
|
170
|
+
moves.push({
|
|
171
|
+
from: blob.key,
|
|
172
|
+
to: `${layout.skills(instanceOf(mapping, 'skills'))}/${tail.join('/')}`,
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (head === 'providers.d') {
|
|
177
|
+
moves.push({ from: blob.key, to: `${layout.providers()}/${tail.join('/')}` });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (head === 'state.kv' || head === 'audit.log') {
|
|
181
|
+
moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${tail.join('/')}` });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Otherwise it is `<provider>/<connection>/...`, the namespace every
|
|
186
|
+
// provider's blobs are scoped into.
|
|
187
|
+
const connection = tail[0];
|
|
188
|
+
if (connection === undefined) continue;
|
|
189
|
+
|
|
190
|
+
// This profile's old key, through this profile's mapping.
|
|
191
|
+
const settled = mapping.get(`${head}.${connection}`);
|
|
192
|
+
const id = settled === undefined ? connection : (settled.split('.')[1] ?? connection);
|
|
193
|
+
moves.push({ from: blob.key, to: `${DATA_DIR}/${head}/${id}/${tail.slice(1).join('/')}` });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assertOneObjectPerDestination(moves);
|
|
198
|
+
return moves;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Two objects aimed at one key, caught while this is still a plan.
|
|
203
|
+
*
|
|
204
|
+
* `applyMoves` checks the destination per object as well, but that check cannot
|
|
205
|
+
* see a collision between two objects *in this run* once the moves are applied
|
|
206
|
+
* concurrently — both would look at an absent destination and both would write.
|
|
207
|
+
* Hoisting it here also puts it where this file says it belongs: everything that
|
|
208
|
+
* can fail happens before the first byte moves, so a refusal leaves the
|
|
209
|
+
* workspace exactly as it was.
|
|
210
|
+
*/
|
|
211
|
+
function assertOneObjectPerDestination(moves: readonly Move[]): void {
|
|
212
|
+
const seen = new Map<string, string>();
|
|
213
|
+
|
|
214
|
+
for (const move of moves) {
|
|
215
|
+
const first = seen.get(move.to);
|
|
216
|
+
if (first !== undefined) {
|
|
217
|
+
throw new ConfigError(
|
|
218
|
+
`Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
|
|
219
|
+
` ${first} and ${move.from}. Nothing has been written.\n` +
|
|
220
|
+
' This should be unreachable: the hoist gives every profile its own instance of ' +
|
|
221
|
+
'each owner-layer surface. Please report it with the layout of your data directory.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
seen.set(move.to, move.from);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Copy, verify, then delete. In that order, per object.
|
|
230
|
+
*
|
|
231
|
+
* A move that deleted first would lose an object on any failure, and these are
|
|
232
|
+
* the owner's notes, tasks and entities.
|
|
233
|
+
*
|
|
234
|
+
* A destination that already holds *different* bytes is a bug rather than a case
|
|
235
|
+
* to handle, now that the hoist gives every profile's owner layer its own
|
|
236
|
+
* instance: two sets of notes can no longer be aimed at one key. It used to be
|
|
237
|
+
* skipped silently, which is how work's vault came to be orphaned while `moved`
|
|
238
|
+
* reported it as moved. `assertOneObjectPerDestination` refuses that while this
|
|
239
|
+
* is still a plan, and the check here catches what a plan cannot see.
|
|
240
|
+
*
|
|
241
|
+
* **The same bytes at the destination is the interrupted move, and it finishes
|
|
242
|
+
* it.** Copy-then-delete has a window between the two, and this migration now
|
|
243
|
+
* runs against buckets — where the window is a network round trip rather than a
|
|
244
|
+
* syscall, and an interruption is something that happens rather than something
|
|
245
|
+
* to reason about. Refusing there would have meant a workspace that could not be
|
|
246
|
+
* migrated by running the migration again, which is the one recovery this file
|
|
247
|
+
* promises.
|
|
248
|
+
*/
|
|
249
|
+
/** Which instance of a single-instance surface this profile's store became. */
|
|
250
|
+
function instanceOf(mapping: ReadonlyMap<string, string>, provider: string): string {
|
|
251
|
+
for (const [from, to] of mapping) {
|
|
252
|
+
if (from.startsWith(`${provider}.`)) return to.split('.')[1] ?? 'main';
|
|
253
|
+
}
|
|
254
|
+
return 'main';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How many objects are in flight at once.
|
|
259
|
+
*
|
|
260
|
+
* Serial was fine while this only ever ran against a local disk. A deployed
|
|
261
|
+
* workspace's audit log is one object per event, so the first real bucket this
|
|
262
|
+
* migrated held 1,906 of them — three round trips each, in series, is minutes of
|
|
263
|
+
* a deploy spent with nothing on screen. The same 16 the read paths in
|
|
264
|
+
* `#providers/memory` and `#providers/tasks` settled on, and for the same
|
|
265
|
+
* reason: enough to hide the latency, not enough to look like an incident to the
|
|
266
|
+
* other end.
|
|
267
|
+
*
|
|
268
|
+
* Safe to widen only while each move stays independent, which is what
|
|
269
|
+
* `assertOneObjectPerDestination` guarantees.
|
|
270
|
+
*/
|
|
271
|
+
const MOVE_CONCURRENCY = 16;
|
|
272
|
+
|
|
273
|
+
export async function applyMoves(files: BlobStore, moves: readonly Move[]): Promise<void> {
|
|
274
|
+
const pending = moves.filter((move) => move.from !== move.to);
|
|
275
|
+
|
|
276
|
+
for (let start = 0; start < pending.length; start += MOVE_CONCURRENCY) {
|
|
277
|
+
await Promise.all(
|
|
278
|
+
pending.slice(start, start + MOVE_CONCURRENCY).map((move) => applyMove(files, move)),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** One object, moved or finished. Never deletes before the copy reads back. */
|
|
284
|
+
async function applyMove(files: BlobStore, move: Move): Promise<void> {
|
|
285
|
+
const data = await files.get(move.from);
|
|
286
|
+
if (data === null) return;
|
|
287
|
+
|
|
288
|
+
if (await files.has(move.to)) {
|
|
289
|
+
const held = await files.get(move.to);
|
|
290
|
+
|
|
291
|
+
// Raced away between the two calls, so there is nothing there after all and
|
|
292
|
+
// the ordinary path below is still the right one.
|
|
293
|
+
if (held !== null) {
|
|
294
|
+
if (!sameBytes(held, data)) {
|
|
295
|
+
throw new ConfigError(
|
|
296
|
+
`Two objects want to be at ${move.to}, and this migration cannot merge them.\n` +
|
|
297
|
+
` ${move.from} is the second, and what is already there is not a copy of it.\n` +
|
|
298
|
+
' Nothing has been deleted. This should be unreachable: the hoist gives every ' +
|
|
299
|
+
'profile its own instance of each owner-layer surface. Please report it with the ' +
|
|
300
|
+
'layout of your data directory.',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Already copied, by a run that did not get to the delete.
|
|
305
|
+
await files.delete(move.from);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
await files.put(move.to, data);
|
|
311
|
+
if ((await files.get(move.to)) === null) {
|
|
312
|
+
throw new ConfigError(
|
|
313
|
+
`${move.to} did not read back after being written. Nothing has been deleted; ` +
|
|
314
|
+
`fix the store and run this again.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await files.delete(move.from);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
322
|
+
if (left.length !== right.length) return false;
|
|
323
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
324
|
+
if (left[index] !== right[index]) return false;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|