@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.
Files changed (156) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -1,9 +1,11 @@
1
+ import { readSession } from '#auth/lanes/session.ts';
2
+ import { ConfigError } from '#profile';
1
3
  import { startEndpoint } from '#server/endpoint.ts';
2
4
  import { streamLogger } from '#server/logging.ts';
3
5
  import { repairOwnerLayer } from '../../config-repair.ts';
4
6
  import { announce, ok, print, style, warn } from '../../output.ts';
5
7
  import { staleNudge } from '../../release.ts';
6
- import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
8
+ import { primaryProfile, resolveProfile, type GlobalFlags } from '../../runtime.ts';
7
9
 
8
10
  /** `lanes link start` — reconcile, then serve every profile on one endpoint. */
9
11
 
@@ -14,9 +16,24 @@ export async function start(
14
16
  only?: boolean | undefined;
15
17
  },
16
18
  ): Promise<void> {
17
- const { resolution } = await resolveProfile(flags);
19
+ // One endpoint serves every profile in the workspace, so the workspace is the
20
+ // subject and a profile is not required. What `--profile` picks is the
21
+ // *primary*: whose token opens the endpoint and whose port it binds (ADR-009).
22
+ // `--only` is the flag that narrows what is served, and it has nothing to
23
+ // narrow unless a profile was named.
24
+ if (flags.only === true && flags.profile === undefined) {
25
+ throw new ConfigError(
26
+ '--only serves one profile, so it needs to be told which.\n' +
27
+ ' Add --profile <name>, or drop --only to serve every profile in the workspace.',
28
+ );
29
+ }
30
+
31
+ const resolved = { ...flags, profile: await primaryProfile(flags) };
32
+ const { resolution } = await resolveProfile(resolved);
18
33
  announce(resolution);
19
34
 
35
+ await requireSignIn();
36
+
20
37
  // Before the bootstrap, and only here.
21
38
  //
22
39
  // This is the one command an existing install runs without being told to, so
@@ -41,7 +58,7 @@ export async function start(
41
58
  // entrypoint. What stays here is what a terminal wants: the plan, printed as
42
59
  // it is applied, and the endpoint at the end.
43
60
  const endpoint = await startEndpoint({
44
- flags,
61
+ flags: resolved,
45
62
  port: flags.port,
46
63
  only: flags.only,
47
64
  mintToken: true,
@@ -56,7 +73,7 @@ export async function start(
56
73
  print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
57
74
  },
58
75
  tokenMinted({ target }) {
59
- print(warn(`minted a token — run: lanes link outputs --show --target ${target}`));
76
+ print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
60
77
  },
61
78
  },
62
79
  });
@@ -81,3 +98,34 @@ export async function start(
81
98
 
82
99
  await new Promise(() => {});
83
100
  }
101
+
102
+ /**
103
+ * Refuse to serve without a Lanes session.
104
+ *
105
+ * A local endpoint requires this as much as a deployed one does, which is the
106
+ * uncomfortable half of ADR-060 and worth stating plainly: **a self-hostable
107
+ * tool now needs an account to start.** The alternative was worse. A profile
108
+ * declares who may consume it, and there is no way to check that against
109
+ * anybody if the endpoint has no idea who is asking — so "local is exempt"
110
+ * means local profiles cannot express delegation at all, and the model would be
111
+ * two models.
112
+ *
113
+ * What is *not* required is the network per request. A machine offline for a
114
+ * day keeps serving; the session is needed to sign in and to refresh, and
115
+ * `lanes auth status` says how long that has left to run.
116
+ *
117
+ * The CI token is the exception and stays one (ADR-009): a headless runner has
118
+ * no browser, presents `llk_…`, and reaches the whole workspace.
119
+ */
120
+ async function requireSignIn(): Promise<void> {
121
+ if ((await readSession()) !== null) return;
122
+
123
+ throw new ConfigError(
124
+ 'Not signed in, so there is nobody for this endpoint to serve.\n' +
125
+ ' Run: lanes auth login\n' +
126
+ '\n' +
127
+ ' A profile reaches you only where its members list your subject, which is why\n' +
128
+ ' this is required for a local endpoint too. See:\n' +
129
+ ' lanes link profile members add --me --profile <name>',
130
+ );
131
+ }
@@ -1,3 +1,4 @@
1
+ import { wasDefaulted } from '../../selection-require.ts';
1
2
  import {
2
3
  isPointer,
3
4
  listProfiles,
@@ -11,7 +12,7 @@ import {
11
12
  import { oneProfile, visibleCapabilities } from '#server/mcp';
12
13
  import { toPolicyDocument } from '#registry';
13
14
  import { announce, emit, heading, print, style, table } from '../../output.ts';
14
- import { openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
15
+ import { grantedConnections, openRuntime, ownerPrincipal, type GlobalFlags } from '../../runtime.ts';
15
16
  import { deploymentIdentity } from '../../endpoint-url.ts';
16
17
 
17
18
  /** `lanes link status` — connections, what is reachable through them, and where. */
@@ -29,7 +30,7 @@ export interface StatusFlags extends GlobalFlags {
29
30
  *
30
31
  * Which is why a deployed target prints its *identity* rather than its address.
31
32
  * This used to print `http://<host>:<port>/mcp` unconditionally, so
32
- * `status --target cloud` named a loopback port with nothing behind it — the
33
+ * `status --workspace cloud` named a loopback port with nothing behind it — the
33
34
  * bug `endpoint-url.ts` records having already fixed in `mcp add`. Reaching for
34
35
  * `endpointUrl` here would fix the lie by surrendering the property above: it
35
36
  * shells out to `gcloud`, costs seconds, needs the CLI installed and
@@ -48,7 +49,7 @@ export async function status(flags: StatusFlags): Promise<void> {
48
49
  const records = await runtime.state.connections.list();
49
50
  const byKey = new Map(records.map((record) => [`${record.provider}.${record.id}`, record]));
50
51
 
51
- const connections = runtime.config.connections.map((connection) => {
52
+ const connections = grantedConnections(runtime).map((connection) => {
52
53
  const key = `${connection.provider}.${connection.id}`;
53
54
  return {
54
55
  key,
@@ -142,7 +143,7 @@ export async function status(flags: StatusFlags): Promise<void> {
142
143
  print(
143
144
  style.dim(
144
145
  ' the address is the platform\'s to assign — run: ' +
145
- `lanes link outputs --target ${runtime.target}`,
146
+ `lanes link outputs --workspace ${runtime.target}`,
146
147
  ),
147
148
  );
148
149
  }
@@ -197,12 +198,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
197
198
  unreachable = error instanceof Error ? error.message : String(error);
198
199
  }
199
200
 
200
- const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.workspace : localRoot);
201
+ const root = resolved?.workspaceRoot ?? (isPointer(entry) ? entry.at : localRoot);
201
202
  const workspace = resolved ? await loadWorkspaceProfiles(root) : undefined;
202
203
 
203
204
  const profiles = (workspace?.loaded ?? []).map((loaded) => ({
204
205
  name: loaded.profile,
205
- connections: loaded.config.connections.length,
206
+ grants: loaded.config.grants.length,
206
207
  }));
207
208
 
208
209
  const deployment = deploymentIdentity(resolved?.declared.deploy);
@@ -220,7 +221,14 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
220
221
  unreadable: workspace?.unreadable ?? [],
221
222
  },
222
223
  () => {
223
- print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
224
+ // The same shape `announce` prints, including whether the workspace was
225
+ // typed or defaulted — a command that reported it differently would make
226
+ // ADR-061's echo a thing an operator has to learn twice.
227
+ print(
228
+ style.dim(
229
+ `workspace ${style.bold(target)}${wasDefaulted(target) ? ' (default)' : ''} ${root}`,
230
+ ),
231
+ );
224
232
 
225
233
  if (unreachable !== undefined) {
226
234
  heading('Unreachable');
@@ -234,12 +242,12 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
234
242
  heading('Profiles');
235
243
  if (profiles.length === 0) {
236
244
  print(style.dim(' None yet.'));
237
- print(style.dim(` Create one with: lanes link profile add <name> --target ${target}`));
245
+ print(style.dim(` Create one with: lanes link profile add <name> --workspace ${target}`));
238
246
  } else {
239
247
  table(
240
248
  profiles.map((profile) => [
241
249
  ` ${style.bold(profile.name)}`,
242
- style.dim(`${profile.connections} connection(s)`),
250
+ style.dim(`${profile.grants} grant(s)`),
243
251
  ]),
244
252
  );
245
253
  }
@@ -257,7 +265,7 @@ async function workspaceStatus(flags: StatusFlags): Promise<void> {
257
265
  print(
258
266
  style.dim(
259
267
  " the address is the platform's to assign — run: " +
260
- `lanes link outputs --target ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
268
+ `lanes link outputs --workspace ${target} --profile ${profiles[0]?.name ?? '<name>'}`,
261
269
  ),
262
270
  );
263
271
  },
@@ -50,7 +50,7 @@ export async function tools(flags: ToolsFlags): Promise<void> {
50
50
  {
51
51
  url,
52
52
  target: runtime.target,
53
- // Both, because `--target cloud` reaching loopback is indistinguishable
53
+ // Both, because `--workspace cloud` reaching loopback is indistinguishable
54
54
  // from success without them.
55
55
  deployed: deployed !== null,
56
56
  answering: mine,
@@ -297,7 +297,7 @@ export function parse(text: string): Record<string, unknown> {
297
297
  * `capabilityIdForToolName` answers it exactly against a set of known ids, and
298
298
  * falls back to that first-underscore split when it recognises none — which is
299
299
  * reachable here, because the registry is the *invoking profile's* while the
300
- * endpoint may serve several, and under `--target cloud` may run an image this
300
+ * endpoint may serve several, and under `--workspace cloud` may run an image this
301
301
  * checkout does not have. So the fallback is detected rather than trusted: a
302
302
  * name that resolves to nothing known is grouped as unattributed, because a
303
303
  * guessed heading with a confident count is worse than an honest "these did not
@@ -11,6 +11,7 @@
11
11
  * outputs.ts what an agent harness needs, and proving the short form works
12
12
  * desktop.ts opening the Lanes app, on the page that drives this CLI
13
13
  * serve.ts start
14
+ * pair.ts pair — the dashboard's read surface (ADR-063)
14
15
  * audit.ts audit tail and verify, and the Markdown rendering of tail
15
16
  * token.ts token show, token rotate
16
17
  * policy.ts policy list/allow/deny, config show
@@ -25,6 +26,7 @@ export { status } from './operate/status.ts';
25
26
  export { outputs, type OutputsFlags } from './operate/outputs.ts';
26
27
  export { tools, type ToolsFlags } from './operate/tools.ts';
27
28
  export { start } from './operate/serve.ts';
29
+ export { pair, PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, type PairFlags } from './operate/pair.ts';
28
30
  export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
29
31
  export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
30
32
  export { attachFile } from './operate/attach.ts';
@@ -73,7 +73,15 @@ export async function withRuntime(
73
73
  * the candidates rather than picking.
74
74
  */
75
75
  export function ownerConnection(config: Config, provider: string, flags: OwnerFlags): string {
76
- const candidates = config.connections.filter((connection) => connection.provider === provider);
76
+ // Derived from the grants, not from the workspace's connections. The question
77
+ // is which store *this profile* may reach, and a workspace holding
78
+ // `memory.acme` that this profile does not grant is not a candidate — offering
79
+ // it would let the CLI write somewhere the endpoint would refuse to read
80
+ // (ADR-057, ADR-058).
81
+ const prefix = `${provider}.`;
82
+ const candidates = config.grants
83
+ .filter((grant) => grant.connection.startsWith(prefix))
84
+ .map((grant) => ({ id: grant.connection.slice(prefix.length) }));
77
85
 
78
86
  if (flags.connection) {
79
87
  const match = candidates.find((connection) => connection.id === flags.connection);
@@ -90,7 +98,10 @@ export function ownerConnection(config: Config, provider: string, flags: OwnerFl
90
98
 
91
99
  if (candidates.length === 0) {
92
100
  throw new ConfigError(
93
- `This profile has no ${provider} connection. Add one with: lanes link connect ${provider}`,
101
+ `This profile grants no ${provider} connection.\n` +
102
+ ` Connect one to the workspace, then grant it:\n` +
103
+ ` lanes link connect ${provider}\n` +
104
+ ` lanes link grant ${provider}.<id> --profile ${config.instance.profile}`,
94
105
  );
95
106
  }
96
107
  if (candidates.length > 1) {
@@ -1,4 +1,5 @@
1
- import { ConfigError } from '#profile';
1
+ import { ConfigError, type Config } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
2
3
  import {
3
4
  loadProfileSkills,
4
5
  readSkill,
@@ -10,9 +11,28 @@ import { agreed, readStdin, required, withRuntime, type OwnerFlags } from './sha
10
11
 
11
12
  /** `lanes link skills` — the reusable procedures agents can invoke. */
12
13
 
14
+ /**
15
+ * The store, or a refusal naming why there is none.
16
+ *
17
+ * A profile grants at most one `skills` connection and may grant none
18
+ * (ADR-059), so `store(runtime)` is genuinely absent rather than empty. The two
19
+ * are worth telling apart: "you have no skills" invites writing one, and
20
+ * "this profile is not granted skills" is a config fact the operator has to fix
21
+ * before writing one would do anything.
22
+ */
23
+ function store(runtime: { skills: BlobStore | undefined; config: Config }): BlobStore {
24
+ if (runtime.skills) return runtime.skills;
25
+ throw new ConfigError(
26
+ `Profile "${runtime.config.instance.profile}" grants no skills connection.\n` +
27
+ ` Grant one, then try again:\n` +
28
+ ` lanes link grant skills.main --profile ${runtime.config.instance.profile}`,
29
+ );
30
+ }
31
+
32
+
13
33
  export async function skillsList(flags: OwnerFlags): Promise<void> {
14
34
  await withRuntime(flags, async (runtime) => {
15
- const skills = await loadProfileSkills(runtime.skills);
35
+ const skills = await loadProfileSkills(store(runtime));
16
36
 
17
37
  heading(`Skills (${skills.length})`);
18
38
  if (skills.length === 0) {
@@ -43,10 +63,10 @@ export async function skillsShow(name: string | undefined, flags: OwnerFlags): P
43
63
  const skillName = required(name, 'lanes link skills show <name>');
44
64
 
45
65
  await withRuntime(flags, async (runtime) => {
46
- const skill = await readSkill(runtime.skills, skillName);
66
+ const skill = await readSkill(store(runtime), skillName);
47
67
  if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
48
68
 
49
- const bytes = await runtime.skills.get(skill.path);
69
+ const bytes = await store(runtime).get(skill.path);
50
70
  print('');
51
71
  print(bytes ? new TextDecoder().decode(bytes) : skill.body);
52
72
  });
@@ -63,8 +83,8 @@ export async function skillsAdd(name: string | undefined, flags: OwnerFlags): Pr
63
83
  );
64
84
 
65
85
  await withRuntime(flags, async (runtime) => {
66
- const existing = await readSkill(runtime.skills, skillName);
67
- const skill = await writeSkill(runtime.skills, skillName, text);
86
+ const existing = await readSkill(store(runtime), skillName);
87
+ const skill = await writeSkill(store(runtime), skillName, text);
68
88
 
69
89
  print(ok(`${existing ? 'replaced' : 'added'} skill ${style.bold(skill.name)}`));
70
90
  print(
@@ -79,14 +99,14 @@ export async function skillsRemove(name: string | undefined, flags: OwnerFlags):
79
99
  const skillName = required(name, 'lanes link skills remove <name>');
80
100
 
81
101
  await withRuntime(flags, async (runtime) => {
82
- const skill = await readSkill(runtime.skills, skillName);
102
+ const skill = await readSkill(store(runtime), skillName);
83
103
  if (!skill) throw new ConfigError(`No skill "${skillName}" in this workspace.`);
84
104
 
85
105
  print(` ${style.bold(skill.name)} ${skill.description}`);
86
106
  print(style.dim(` ${skill.path}`));
87
107
  if (!(await agreed(flags, 'Remove this skill?'))) return;
88
108
 
89
- await removeSkill(runtime.skills, skillName);
109
+ await removeSkill(store(runtime), skillName);
90
110
  print(ok(`removed skill ${style.bold(skillName)}`));
91
111
  });
92
112
  }
@@ -1,4 +1,4 @@
1
- import { layout, profilePath, workspacePath, type Config, type TargetConfig } from '#profile';
1
+ import { layout, profilePath, vaultRef, workspacePath, type Config, type TargetConfig } from '#profile';
2
2
  import type { SecretStore } from '#secrets';
3
3
  import type { BlobStore } from '#stores/blobs';
4
4
  import { credentialRefFor, ownClientRefsFor, type ProviderRegistry } from '#registry';
@@ -32,37 +32,69 @@ import { print, style, warn } from '../../output.ts';
32
32
  */
33
33
  export function declaredRefs(
34
34
  config: Config,
35
- registry: ProviderRegistry,
36
35
  declared: TargetConfig,
36
+ /**
37
+ * What the profiles that are staying declare.
38
+ *
39
+ * Nothing in here is deleted, however plainly the profile being removed also
40
+ * declares it. The credential store is one file per *workspace* since
41
+ * contract 3, and every profile takes the template default
42
+ * `token_ref: profile/token` — so removing one profile deleted the endpoint
43
+ * token the others are served by, and the deployed revision then refused every
44
+ * request with "No profile token in this target's credential store". The vault
45
+ * ref is read off the target and is identical for every profile there, which
46
+ * made the sibling's sealed items unrecoverable in the same command.
47
+ */
48
+ survivors: readonly Config[] = [],
37
49
  ): string[] {
38
50
  const refs = new Set<string>([config.auth.token_ref]);
39
51
 
40
52
  // Read off the *target*, not the profile. `vaultTargetSchema` sits inside
41
- // `targetSchema`, so two targets may seal the same profile's items in
42
- // different places; taking it from the profile would attach one target's
43
- // vault to another target's removal.
53
+ // `targetSchema`, so two targets may seal the same items in different places;
54
+ // taking it from the profile would attach one target's vault to another
55
+ // target's removal.
56
+ //
57
+ // **Through `vaultRef`, because the name carries the connection.** This said
58
+ // `vault/document`, the contract-2 constant, while `openVault` seals under
59
+ // `vault/<connection>` (ADR-059) — so removing a profile queued a ref nothing
60
+ // had ever written and left the real document behind. Under-deletion rather
61
+ // than over, since the survivor set was wrong the same way and they cancelled,
62
+ // but what stayed behind is sealed credential material belonging to a profile
63
+ // the operator asked to be gone. Per connection also makes the survivor check
64
+ // mean something: two profiles granting different vaults no longer look like
65
+ // one document to it.
44
66
  if (declared.vault?.adapter === 'secret') {
45
- refs.add(declared.vault.ref ?? 'vault/document');
67
+ refs.add(vaultRef(declared, config));
46
68
  }
47
69
 
48
- for (const connection of config.connections) {
49
- const manifest = registry.manifest(connection.provider);
50
-
51
- // `credentialRefFor` rather than `credentialRefForConnection`: it is the
52
- // single authority on where a connection's credential lives, and it exists
53
- // because two callers once derived the answer differently and disagreed.
54
- // Asking the lower-level one here would open that gap again from a third
55
- // side and it would miss a `local` provider, whose only ref is the one
56
- // the connection declares itself.
57
- const ref = credentialRefFor(connection, manifest);
58
- if (ref) refs.add(ref);
59
-
60
- if (manifest) {
61
- for (const own of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(own);
62
- }
70
+ // **No connection credentials.** They belong to the workspace now (ADR-057),
71
+ // and every one of them may be granted by a profile that is staying. Removing
72
+ // a profile therefore removes no account and no credential — `lanes link
73
+ // disconnect` is the command that does that, and it is the one that knows how
74
+ // to check whether anybody else still needs the credential first.
75
+ //
76
+ // This is the sharpest edge in the whole decoupling, so `renderPlan` says it
77
+ // out loud rather than leaving an operator to infer it from a short list:
78
+ // "remove the work profile" used to mean "revoke what work could reach", and
79
+ // it does not any more.
80
+ if (config.auth.authorization?.mode === 'oidc') {
81
+ refs.add(config.auth.authorization.client_id_ref);
63
82
  }
64
83
 
65
- return [...refs];
84
+ // Shared with a profile that is staying, so not ours to delete. Computed the
85
+ // same way for the survivors as for this one, because "what does a profile
86
+ // declare" has to have one answer.
87
+ const kept = new Set(
88
+ survivors.flatMap((other) => [
89
+ other.auth.token_ref,
90
+ ...(declared.vault?.adapter === 'secret' ? [vaultRef(declared, other)] : []),
91
+ ...(other.auth.authorization?.mode === 'oidc'
92
+ ? [other.auth.authorization.client_id_ref]
93
+ : []),
94
+ ]),
95
+ );
96
+
97
+ return [...refs].filter((ref) => !kept.has(ref));
66
98
  }
67
99
 
68
100
  export interface RemovalItem {
@@ -96,6 +128,14 @@ export interface PlanOptions {
96
128
  readonly openSecrets: (target: string) => Promise<SecretStore>;
97
129
  readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
98
130
  readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
131
+ /**
132
+ * The profiles that are staying.
133
+ *
134
+ * So a credential both this one and a survivor declares is left alone — see
135
+ * `declaredRefs`. Defaulted to empty, which is the single-profile workspace
136
+ * and the shape every existing caller had.
137
+ */
138
+ readonly survivors?: readonly Config[] | undefined;
99
139
  }
100
140
 
101
141
  const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
@@ -144,15 +184,16 @@ export async function removalPlan(
144
184
  );
145
185
  }
146
186
 
147
- // Secrets first, and this ordering is load-bearing rather than tidy:
148
- // `layout.credentials(p)` is `data/<p>/credentials.enc`, *inside* the blob
149
- // root `data/<p>`. For the file adapter the credential store is itself a
150
- // blob, so blobs-first would delete the store the secret deletions read
151
- // through and turn every one of them into a failure.
187
+ // Secrets, and there is nothing left to order them against. This used to
188
+ // run before a blob sweep because `layout.credentials(p)` was
189
+ // `data/<p>/credentials.enc`, *inside* the blob root `data/<p>`, so
190
+ // blobs-first deleted the store the secret deletions read through. Both the
191
+ // sweep and the per-profile root are gone (ADR-057, ADR-059) — see the note
192
+ // below — and the credential store is the workspace's now.
152
193
  try {
153
194
  const secrets = await options.openSecrets(name);
154
195
  const present = await secrets.list();
155
- const mine = new Set(declaredRefs(config, registry, declared));
196
+ const mine = new Set(declaredRefs(config, declared, options.survivors ?? []));
156
197
 
157
198
  for (const ref of present) if (mine.has(ref)) items.push({ target: name, kind: 'secret', id: ref });
158
199
 
@@ -164,54 +205,16 @@ export async function removalPlan(
164
205
  );
165
206
  }
166
207
 
167
- try {
168
- const blobs = await options.openBlobs(name);
169
- for (const blob of await blobs.list()) items.push({ target: name, kind: 'blob', id: blob.key });
170
- } catch (cause) {
171
- warnings.push(
172
- `Target "${name}": its storage could not be opened (${reason(cause)}), so nothing in it will be removed.`,
173
- );
174
- }
175
-
176
- // The blob store's root is the profile's own directory, and an adapter must
177
- // never delete the root it was configured with — so emptying it leaves the
178
- // directory. `layout.ts` frames that directory as the unit ("rm -r
179
- // data/work is exactly remove the work profile's data"), and one left
180
- // behind is silently reused by a later `profile add` of the same name.
181
- if (declared.storage.adapter === 'filesystem') {
182
- const blobRoot = declared.storage.path ?? layout.blobs(profile);
183
- items.push({
184
- target: name,
185
- kind: 'file',
186
- id: blobRoot,
187
- note: 'the profile directory, once emptied',
188
- });
189
-
190
- // Skills and manifests sit at fixed areas under `data/<profile>/`, the
191
- // same way state and the log do — an explicit area, so a profile that
192
- // declares its own `storage.path` moves its provider blobs and leaves
193
- // these where `layout` says they are. The sweep above walks the blob root
194
- // and would then walk straight past them, so they are named. Ordinary
195
- // case: both are already inside `blobRoot` and the sweep has them, which
196
- // is why this only fires when the two have been pointed apart.
197
- //
198
- // Resolved before comparing, because they are not compared as paths
199
- // otherwise: `newProfileTemplate` writes `./data/<profile>` and
200
- // `layout.blobs` returns `data/<profile>`, which is one directory spelled
201
- // two ways — the same leading `./` that `profile/layout.ts` has a
202
- // paragraph about. Comparing the strings names every default profile's
203
- // skills twice in the preview an operator confirms.
204
- if (workspacePath(root, blobRoot) !== workspacePath(root, layout.blobs(profile))) {
205
- for (const area of [layout.skills(profile), layout.providers(profile)]) {
206
- items.push({
207
- target: name,
208
- kind: 'file',
209
- id: area,
210
- note: 'outside the declared storage path, so not swept with it',
211
- });
212
- }
213
- }
214
- }
208
+ // **No blob sweep, and no profile directory.** Both existed because a
209
+ // profile owned `data/<profile>/`, and `rm -r` on it was exactly "what could
210
+ // this profile reach". It owns nothing now (ADR-057, ADR-059): the blob root
211
+ // is the workspace's, and the stores under it belong to connections other
212
+ // profiles may grant. Listing it here would queue every byte in the
213
+ // workspace for deletion because one profile is going.
214
+ //
215
+ // What replaces it is `lanes link disconnect`, which takes one connection,
216
+ // its grants, and its credential — after checking whether anything else
217
+ // still needs them. That check is the whole reason this cannot happen here.
215
218
 
216
219
  // A deployed revision reads its config from the bucket rather than the
217
220
  // image (ADR-023), so that copy is the profile too — and it is outside the
@@ -13,6 +13,7 @@ import {
13
13
  import { parseDocument } from 'yaml';
14
14
  import { rm } from 'node:fs/promises';
15
15
  import { terminalPrompter, type Prompter } from '../../prompt.ts';
16
+ import { recordConfigChange } from '../../audit-change.ts';
16
17
  import { announceProfile, emit, fail, ok, print, style } from '../../output.ts';
17
18
  import {
18
19
  buildRegistryWithWorkspace,
@@ -21,6 +22,7 @@ import {
21
22
  resolveProfileOnly,
22
23
  type GlobalFlags,
23
24
  } from '../../runtime.ts';
25
+ import { loadWorkspaceProfiles } from '#profile';
24
26
  import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
25
27
 
26
28
  /**
@@ -255,7 +257,7 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
255
257
  announceProfile(selection);
256
258
 
257
259
  const root = selection.workspaceRoot;
258
- const registry = await buildRegistryWithWorkspace(root, name);
260
+ const registry = await buildRegistryWithWorkspace(root);
259
261
  const files = workspaceFiles(root);
260
262
  const { declared } = await openTarget(root, target);
261
263
 
@@ -265,6 +267,11 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
265
267
  openSecrets: (target) => openSecretStoreFor(config, root, target),
266
268
  openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
267
269
  readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
270
+ // What the workspace keeps. The credential store is one file for all of
271
+ // them now, so anything a survivor declares is not this removal's to delete.
272
+ survivors: (await loadWorkspaceProfiles(root)).loaded
273
+ .filter((one) => one.profile !== name)
274
+ .map((one) => one.config),
268
275
  });
269
276
 
270
277
  renderPlan(plan);
@@ -286,6 +293,14 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
286
293
  retry: retryCommand,
287
294
  });
288
295
 
296
+ // Before the render, and against the config loaded above — the profile's file
297
+ // is gone by now, so a helper that re-read it would find nothing.
298
+ await recordConfigChange(config, root, target, {
299
+ capability: 'config.profile.remove',
300
+ scope: name,
301
+ arguments: { connections: config.grants.map((grant) => grant.connection) },
302
+ });
303
+
289
304
  return emit(flags.json, outcome, () => renderOutcome(outcome));
290
305
  }
291
306