@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
package/src/cli/main.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { connect } from './commands/connect/index.ts';
2
2
  import { connectCustom } from './commands/connect/custom/index.ts';
3
- import { disconnect, relabel } from './commands/connection.ts';
3
+ import { disconnect } from './commands/connection.ts';
4
+ import { grantConnectionTo, revokeConnectionFrom } from './commands/grant.ts';
5
+ import { connectionList } from './commands/connection-list.ts';
6
+ import { membersAdd, membersList, membersRemove } from './commands/members.ts';
7
+ import { relabel } from './commands/relabel.ts';
4
8
  import {
5
9
  attachFile,
6
10
  auditTail,
@@ -11,6 +15,7 @@ import {
11
15
  desktop,
12
16
  doctor,
13
17
  outputs,
18
+ pair,
14
19
  plan,
15
20
  policyList,
16
21
  policyRule,
@@ -26,13 +31,23 @@ import { syncTargets } from './commands/sync.ts';
26
31
  import { targetList, targetShow, targetUse } from './commands/target.ts';
27
32
  import { identityAdd, identityList, identityRemove } from './commands/identity.ts';
28
33
  import { setupPlan } from './commands/setup.ts';
29
- import { mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
34
+ import { installInstructions, mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
30
35
  import { deploy } from '#deployments/deploy.ts';
31
36
  import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
32
37
  import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
33
38
  import { dispatchOwner } from './dispatch-owner.ts';
34
39
  import { update } from './commands/update.ts';
35
- import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
40
+ import {
41
+ all,
42
+ customFlags,
43
+ globalFlags,
44
+ knowledgeFlags,
45
+ ownerFlags,
46
+ parseArgv,
47
+ text,
48
+ } from './argv.ts';
49
+ import type { GlobalFlags } from './runtime.ts';
50
+ import type { OwnerFlags } from './commands/owner/shared.ts';
36
51
  import { assertKnownFlags } from './selection.ts';
37
52
  import { requireSelection } from './selection-require.ts';
38
53
  import { PROGRAM, USAGE } from './usage.ts';
@@ -57,8 +72,12 @@ export async function run(argv: readonly string[]): Promise<void> {
57
72
  const { command, flags } = parseArgv(argv);
58
73
  const [first, second, ...rest] = command;
59
74
 
60
- const global = globalFlags(flags);
61
- const owner = ownerFlags(flags, argv);
75
+ // Both are read *after* `requireSelection`, which is what resolves
76
+ // `default_workspace` onto `flags` (ADR-061). Capturing them before it would
77
+ // hand every command an undefined workspace on the path the default exists to
78
+ // serve — the flag would be resolved and nothing would be looking.
79
+ let global: GlobalFlags;
80
+ let owner: OwnerFlags;
62
81
 
63
82
  const show = flags['show'] === true;
64
83
  const raw = flags['raw'] === true;
@@ -77,6 +96,9 @@ export async function run(argv: readonly string[]): Promise<void> {
77
96
  assertKnownFlags(first, second, flags);
78
97
  await requireSelection(first, second, flags);
79
98
 
99
+ global = globalFlags(flags);
100
+ owner = ownerFlags(flags, argv);
101
+
80
102
  switch (first) {
81
103
  case 'connect':
82
104
  // A nested switch rather than an `if`, so `selection.test.ts` sees
@@ -117,6 +139,20 @@ export async function run(argv: readonly string[]): Promise<void> {
117
139
  });
118
140
  }
119
141
 
142
+ // The command connecting no longer implies. A connection belongs to the
143
+ // workspace (ADR-057), so which profiles may reach it is a separate say.
144
+ case 'connection':
145
+ if (second !== 'list' && second !== undefined) {
146
+ throw new Error(`Unknown: ${PROGRAM} connection ${second}`);
147
+ }
148
+ return connectionList({ ...global, json });
149
+
150
+ case 'grant':
151
+ return grantConnectionTo(second, { ...global, json });
152
+
153
+ case 'revoke':
154
+ return revokeConnectionFrom(second, { ...global, json });
155
+
120
156
  case 'setup':
121
157
  if (second !== 'plan' && second !== undefined) {
122
158
  throw new Error(`Unknown: ${PROGRAM} setup ${second}`);
@@ -127,27 +163,46 @@ export async function run(argv: readonly string[]): Promise<void> {
127
163
  switch (second) {
128
164
  case 'add':
129
165
  if (!rest[0]) {
130
- throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name>`);
166
+ throw new Error(`Usage: ${PROGRAM} profile add <name> --workspace <name>`);
131
167
  }
132
168
  return profileAdd(rest[0], {
133
169
  // One target, not a list. A profile declared every target it could
134
170
  // run on under contract 1, which is why this read the repeated flag
135
171
  // out of argv; it lives in exactly one now (ADR-052), so a second
136
172
  // --target would be naming a second place to put the same file.
137
- targets: [text(flags, 'target')!],
173
+ targets: [text(flags, 'workspace')!],
138
174
  nonInteractive: flags['non-interactive'] === true,
139
175
  json,
140
176
  });
141
177
  case 'list':
142
178
  case undefined:
143
- return profileList(text(flags, 'target')!, { json });
179
+ return profileList(text(flags, 'workspace')!, { json });
144
180
  case 'default':
145
181
  if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile default <name>`);
146
182
  return profileDefault(rest[0]);
183
+ case 'members': {
184
+ const [action, subject] = rest;
185
+ switch (action) {
186
+ case 'add':
187
+ return membersAdd(subject, {
188
+ ...global,
189
+ json,
190
+ me: flags['me'] === true,
191
+ role: text(flags, 'role'),
192
+ });
193
+ case 'remove':
194
+ return membersRemove(subject, { ...global, json });
195
+ case 'list':
196
+ case undefined:
197
+ return membersList({ ...global, json });
198
+ default:
199
+ throw new Error(`Unknown: ${PROGRAM} profile members ${action}`);
200
+ }
201
+ }
147
202
  case 'remove':
148
203
  if (!rest[0]) {
149
204
  throw new Error(
150
- `Usage: ${PROGRAM} profile remove <name> [--target t] [--dry-run] [--yes]`,
205
+ `Usage: ${PROGRAM} profile remove <name> [--workspace <name>] [--dry-run] [--yes]`,
151
206
  );
152
207
  }
153
208
  return profileRemove(rest[0], {
@@ -160,18 +215,22 @@ export async function run(argv: readonly string[]): Promise<void> {
160
215
  throw new Error(`Unknown: ${PROGRAM} profile ${second}`);
161
216
  }
162
217
 
218
+ // `target` was the old word for the same thing (ADR-061). Kept as its own
219
+ // case rather than aliased, so `selection.test.ts` sees both spellings and
220
+ // neither can default quietly.
163
221
  case 'target':
222
+ case 'workspace':
164
223
  switch (second) {
165
224
  case 'list':
166
225
  case undefined:
167
226
  return targetList({ ...global, json, urls: flags['urls'] === true });
168
227
  case 'use':
169
- if (!rest[0]) throw new Error(`Usage: ${PROGRAM} target use <name>`);
228
+ if (!rest[0]) throw new Error(`Usage: ${PROGRAM} workspace use <name>`);
170
229
  return targetUse(rest[0]);
171
230
  case 'show':
172
231
  return targetShow(rest[0], { ...global, json });
173
232
  default:
174
- throw new Error(`Unknown: ${PROGRAM} target ${second}`);
233
+ throw new Error(`Unknown: ${PROGRAM} workspace ${second}`);
175
234
  }
176
235
 
177
236
  case 'identity': {
@@ -206,11 +265,15 @@ export async function run(argv: readonly string[]): Promise<void> {
206
265
  const [capability] = rest;
207
266
  if (!capability) {
208
267
  throw new Error(
209
- `Usage: ${PROGRAM} policy ${second} <capability>\n` +
210
- ` e.g. ${PROGRAM} policy ${second} gmail.send_message, or gmail.* for the whole provider`,
268
+ `Usage: ${PROGRAM} policy ${second} <capability> --connection <provider>.<id>\n` +
269
+ ` e.g. ${PROGRAM} policy ${second} gmail.send_message --connection gmail.personal,\n` +
270
+ ` or gmail.* for everything that connection's provider offers`,
211
271
  );
212
272
  }
213
- return policyRule(second, capability, global);
273
+ return policyRule(second, capability, {
274
+ ...global,
275
+ ...(typeof flags.connection === 'string' ? { connection: flags.connection } : {}),
276
+ });
214
277
  }
215
278
  default:
216
279
  throw new Error(`Unknown: ${PROGRAM} policy ${second}`);
@@ -227,6 +290,14 @@ export async function run(argv: readonly string[]): Promise<void> {
227
290
  throw new Error(`Unknown: ${PROGRAM} token ${second}`);
228
291
  }
229
292
 
293
+ case 'pair':
294
+ return pair({
295
+ ...global,
296
+ print: flags['print'] === true,
297
+ rotate: flags['rotate'] === true,
298
+ yes: flags['yes'] === true,
299
+ });
300
+
230
301
  case 'audit':
231
302
  if (second === 'verify') return auditVerify(global);
232
303
  if (second !== 'tail' && second !== undefined) {
@@ -340,7 +411,10 @@ export async function run(argv: readonly string[]): Promise<void> {
340
411
  dryRun: flags['dry-run'] === true,
341
412
  force: flags['force'] === true,
342
413
  noSkill: flags['no-skill'] === true,
414
+ headless: flags['headless'] === true,
343
415
  });
416
+ case 'install-instructions':
417
+ return installInstructions({ ...(text(flags, 'client') ? { client: text(flags, 'client') } : {}) });
344
418
  case 'stdio':
345
419
  return mcpStdio({ ...global, ...(flags['only'] === true ? { only: true } : {}) });
346
420
  case 'list':
@@ -398,6 +472,7 @@ export async function run(argv: readonly string[]): Promise<void> {
398
472
  switch (second) {
399
473
  case undefined:
400
474
  case 'targets':
475
+ case 'workspaces':
401
476
  return syncTargets({
402
477
  ...global,
403
478
  dryRun: flags['dry-run'] === true,
@@ -1,4 +1,5 @@
1
- import { ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
1
+ import {
2
+ DATA_DIR, ConfigError, layout, isRemoteWorkspace, type LegacyTarget, type WorkspaceTarget } from '#profile';
2
3
  import { deployedWorkspace } from '#deployments/upload.ts';
3
4
 
4
5
  /**
@@ -88,7 +89,7 @@ export function toEntry(
88
89
  // produces `gs://b` pointing at `gs://b`. That is a loop, and `openTarget`
89
90
  // refuses it — which made `deploy` unable to run against the bucket it had
90
91
  // just migrated, on the one command the refusal names as the fix.
91
- if (remote && remote !== workspaceRoot) return { workspace: remote };
92
+ if (remote && remote !== workspaceRoot) return { at: remote };
92
93
 
93
94
  // **A filesystem target is dropped from a remote workspace.**
94
95
  //
@@ -105,8 +106,13 @@ export function toEntry(
105
106
  const storagePath = declared.storage.path;
106
107
  const credentialsPath = declared.credentials.path;
107
108
 
108
- const defaultStorage = `./${layout.blobs(profile)}`;
109
- const defaultCredentials = `./${layout.credentials(profile)}`;
109
+ // The **contract-1** defaults, spelled out rather than taken from `layout`.
110
+ // This function reads a contract-1 profile, where every path was per profile,
111
+ // and `layout` describes where things live *now* — workspace-level since
112
+ // ADR-057. Asking it would compare a contract-1 path against a contract-3
113
+ // default and refuse every profile that had written the ordinary one.
114
+ const defaultStorage = `./${DATA_DIR}/${profile}`;
115
+ const defaultCredentials = `./${DATA_DIR}/${profile}/credentials.enc`;
110
116
 
111
117
  refuseCustomPath(name, profile, workspaceRoot, 'storage.path', storagePath, defaultStorage);
112
118
  refuseCustomPath(
@@ -149,12 +155,12 @@ function refuseCustomPath(
149
155
  ` A target is declared once for the whole workspace now (ADR-052), so it cannot hold a\n` +
150
156
  ` different path per profile. The default it would get is ${expected}.\n\n` +
151
157
  ` Move the data there and delete the line, or keep this profile in a workspace of its\n` +
152
- ` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --target ${target}`,
158
+ ` own: LANES_LINK_HOME=${workspaceRoot}/<somewhere> lanes link profile add ${profile} --workspace ${target}`,
153
159
  );
154
160
  }
155
161
 
156
162
  export function summarise(entry: WorkspaceTarget): string {
157
- if (entry.workspace !== undefined) return `points at ${entry.workspace}`;
163
+ if (entry.at !== undefined) return `points at ${entry.at}`;
158
164
  const parts = [entry.credentials?.adapter, entry.storage?.adapter].filter(Boolean);
159
165
  return `${parts.join(' + ')}${entry.deploy ? `, deploys ${entry.deploy.service}` : ''}`;
160
166
  }
package/src/cli/output.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { wasDefaulted } from './selection-require.ts';
1
2
  import type { Resolution } from '#profile';
2
3
 
3
4
  /**
@@ -126,16 +127,48 @@ export function announceProfile(selection: {
126
127
  print(style.dim(`profile ${style.bold(selection.profile)} ${selection.workspaceRoot}`));
127
128
  }
128
129
 
130
+ /**
131
+ * The line every command prints before its output.
132
+ *
133
+ * It names the workspace, and whether that workspace was *typed* or came from
134
+ * `default_workspace`. The provenance is not decoration: ADR-037 removed sticky
135
+ * selection because "the dotfile nothing prints" is how an operator runs a
136
+ * command against the wrong thing, and ADR-061 gives the default back only on
137
+ * the condition that it announces itself. Dropping the `(default)` marker for
138
+ * tidiness would reverse that decision.
139
+ */
129
140
  export function announce(resolution: Resolution): void {
141
+ const provenance = wasDefaulted(resolution.target) ? ' (default)' : '';
142
+
130
143
  print(
131
144
  style.dim(
132
145
  `profile ${style.bold(resolution.profile)} ` +
133
- `target ${style.bold(resolution.target)} ` +
146
+ `workspace ${style.bold(resolution.target)}${provenance} ` +
134
147
  `${resolution.workspaceRoot}`,
135
148
  ),
136
149
  );
137
150
  }
138
151
 
152
+ /**
153
+ * The same line, for a command whose subject is the workspace.
154
+ *
155
+ * It names no profile, and that is the point rather than a saving. A
156
+ * workspace-level command still opens a runtime, and a runtime carries a
157
+ * profile — so `announce` would print whichever one happened to be picked, and
158
+ * a reader would take it for the thing being acted on. The audit log is one
159
+ * chain for the whole workspace; saying `profile personal` above it describes a
160
+ * log that does not exist.
161
+ */
162
+ export function announceWorkspace(resolution: Resolution): void {
163
+ const provenance = wasDefaulted(resolution.target) ? ' (default)' : '';
164
+
165
+ print(
166
+ style.dim(
167
+ `workspace ${style.bold(resolution.target)}${provenance} ${resolution.workspaceRoot}`,
168
+ ),
169
+ );
170
+ }
171
+
139
172
  /**
140
173
  * Print a machine-readable result, or fall through to the human rendering.
141
174
  *
@@ -53,7 +53,8 @@ export async function publishAndNotify(input: {
53
53
  readonly config: Config;
54
54
  readonly workspaceRoot: string;
55
55
  readonly target: string;
56
- readonly profile: string;
56
+ /** Every profile the edit touched. See `publishWorkspace`. */
57
+ readonly profile: string | readonly string[];
57
58
  readonly credentials: SecretStore;
58
59
  }): Promise<PublishOutcome> {
59
60
  let published: string | null = null;
@@ -98,6 +99,8 @@ export async function publishProfileEdit(input: {
98
99
  readonly resolution: { readonly workspaceRoot: string; readonly profile: string };
99
100
  readonly config: Config;
100
101
  readonly target: string;
102
+ /** Every profile the edit touched, where it reached more than the one named. */
103
+ readonly touched?: readonly string[] | undefined;
101
104
  }): Promise<PublishOutcome> {
102
105
  const credentials = await openSecretStoreFor(
103
106
  input.config,
@@ -109,7 +112,7 @@ export async function publishProfileEdit(input: {
109
112
  config: input.config,
110
113
  workspaceRoot: input.resolution.workspaceRoot,
111
114
  target: input.target,
112
- profile: input.resolution.profile,
115
+ profile: input.touched ?? input.resolution.profile,
113
116
  credentials,
114
117
  });
115
118
  }
@@ -6,11 +6,17 @@ import type { BlobStore } from '#stores/blobs';
6
6
  import type { AnyConnector, ProviderManifest } from '#connectivity';
7
7
  import { RateLimiter, allowedConnections } from '#policy';
8
8
  import {
9
+ assertGrantsResolve,
9
10
  layout,
10
11
  listProfiles,
12
+ readConnections,
13
+ selectConnections,
14
+ soleGrantFor,
11
15
  workspacePath,
12
16
  type Config,
17
+ type ConnectionConfig,
13
18
  type Resolution,
19
+ type SelectedConnection,
14
20
  type TargetConfig,
15
21
  } from '#profile';
16
22
  import { ProviderRegistry, toPolicyDocument } from '#registry';
@@ -33,6 +39,10 @@ import { resolveProfile, type GlobalFlags } from './select.ts';
33
39
  import { buildRegistryWithWorkspace, readSkillsForStart, reloadSkills } from './registry.ts';
34
40
  import { discoveryProbe } from './discovery.ts';
35
41
  import { openVault } from './vault.ts';
42
+ import { EMPTY_SKILL_STORE, skillStore } from './stores.ts';
43
+ import type { Runtime } from './types.ts';
44
+
45
+ export type { Runtime } from './types.ts';
36
46
 
37
47
  /**
38
48
  * Assembling a profile's runtime from its declared target.
@@ -43,86 +53,20 @@ import { openVault } from './vault.ts';
43
53
  * nothing at the application layer.
44
54
  */
45
55
 
46
- export interface Runtime {
47
- readonly resolution: Resolution;
48
- readonly config: Config;
49
- readonly target: string;
50
- /**
51
- * The target's adapter set, from the workspace that declares it.
52
- *
53
- * Here because the config no longer carries it. Every caller that used to
54
- * reach `config.targets[target]` — for a `deploy` block, a storage adapter, a
55
- * knowledge repository — reads this instead, and reads it without following
56
- * the pointer a second time (ADR-052).
57
- */
58
- readonly declared: TargetConfig;
59
- readonly state: RuntimeState;
60
- /** The durable log, for reading. Copies, if any, are write-only and not here. */
61
- readonly audit: AuditReader;
62
- readonly credentials: SecretStore;
63
- readonly storage: BlobStore;
64
- /** Where skills are kept — `data/<profile>/skills.d/` locally. */
65
- readonly skills: BlobStore;
66
- /**
67
- * Present only when this target keeps memory and skills in a repository.
68
- *
69
- * Nothing on the dispatch path reads it: `storage` and `skills` above are
70
- * already pointed at the right bytes, which is the whole design. It is here
71
- * so `target show` and `doctor` can say where those bytes are without
72
- * re-reading the config, and so the migration can reach the repository it is
73
- * committing to.
74
- */
75
- readonly knowledge?: KnowledgeStores | undefined;
76
- /** The vault's own store, so `lanes link vault` reaches the same bytes MCP does. */
77
- readonly vault: VaultStore;
78
- readonly registry: ProviderRegistry;
79
- /**
80
- * Re-read the skills into the registry when they have changed on the store.
81
- *
82
- * Cheap and idempotent — one `list()`, and a rebuild only when the listing
83
- * differs from the last one. The caller decides how often to ask.
84
- */
85
- refreshSkills(): Promise<void>;
86
- readonly dispatcher: Dispatcher;
87
- readonly authenticator: BearerAuthenticator;
88
- /** Same factory the dispatcher uses, exposed for commands that probe upstream. */
89
- connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
90
- /**
91
- * Same authorizer the dispatcher uses, for the same reason `connectorFor` is here.
92
- *
93
- * A command that probes upstream needs the credential on the request, and it
94
- * must not learn how to put it there — that is one switch on the resolved
95
- * shape (`connectivity/auth/authorize.ts`) and a second copy would be a
96
- * second answer. `settleIdentity` is the caller: it asks a provider whose
97
- * account was just authorised, over whatever method that provider declares.
98
- */
99
- authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
100
- /** A provider's manifest, so an omitted `credential_ref` can be derived from it. */
101
- manifestFor(providerId: string): ProviderManifest | undefined;
102
- close(): Promise<void>;
103
- }
104
-
105
56
  /**
106
- * Where this profile's skills live, in either target.
107
- *
108
- * Locally `data/<profile>/skills.d/`; deployed, the same key under the bucket
109
- * prefix. Going through the store rather than a filesystem path is what gives a
110
- * deployment skills at all — a path is baked into a container image at build
111
- * time and an object key is not, so before ADR-014 a deployed instance could
112
- * only ever serve the skills that existed when its image was built.
57
+ * The accounts a profile reaches, as most callers want them.
113
58
  *
114
- * **Per profile**, which reverses ADR-012 §1. Policy gating `skills.<name>`
115
- * was the whole isolation story while the bytes were shared, and it is a weak
116
- * one: it decides who may *run* a procedure, not who may read that it exists or
117
- * what it says. A skill written for work names work's accounts and work's
118
- * people. ADR-030.
119
- *
120
- * An explicit area, matching `openState` and `openAudit` — a profile that
121
- * declares its own `storage.path` moves its provider blobs, not the reserved
122
- * roots beside them.
59
+ * `runtime.connections` pairs each with the grant governing it, which is what
60
+ * policy and the setup surface need. Everything else probing credentials,
61
+ * listing accounts, reconciling state wants the rows alone, and unwrapping
62
+ * them at twenty call sites is twenty chances to reach for
63
+ * `workspaceConnections` by mistake and answer for accounts this profile was
64
+ * never granted.
123
65
  */
124
- function skillStore(storage: StorageFactory, profile: string): BlobStore {
125
- return storage(layout.skills(profile));
66
+ export function grantedConnections(
67
+ runtime: Pick<Runtime, 'connections'>,
68
+ ): ConnectionConfig[] {
69
+ return runtime.connections.map(({ connection }) => connection);
126
70
  }
127
71
 
128
72
  /**
@@ -152,6 +96,15 @@ export async function openRuntime(
152
96
  const root = resolved.workspaceRoot;
153
97
  const adapters: TargetInput = { declared, config, root, target };
154
98
 
99
+ // The workspace's accounts, and the check that this profile's grants name
100
+ // real ones (ADR-057). Read here rather than inside `resolveProfile` because
101
+ // it is a property of the workspace rather than of the selection, and because
102
+ // a command that only wants to know which profiles exist should not have to
103
+ // parse every connection to find out.
104
+ const connectionsFile = await readConnections(root);
105
+ assertGrantsResolve(config, connectionsFile.connections);
106
+ const selected = selectConnections(config, connectionsFile.connections);
107
+
155
108
  // Credentials first: an S3 key pair is itself a credential reference, so the
156
109
  // credential store has to exist before the blob store that names it. State
157
110
  // and the log then ride that blob store rather than opening backends of
@@ -174,7 +127,7 @@ export async function openRuntime(
174
127
  // own roots on the target's own storage and are untouched.
175
128
  const knowledge = await openKnowledge(adapters, credentials, options.fetch);
176
129
  const storage = knowledge ? routeBlobStore(storageFor(), knowledgeRoutes(knowledge)) : storageFor();
177
- const state = openState(storageFor, config.instance.profile);
130
+ const state = openState(storageFor);
178
131
 
179
132
  // The durable log, plus any copies the target declares. `sink` is what
180
133
  // dispatch writes to; `audit` is what `tail` and `verify` read, and those are
@@ -187,7 +140,18 @@ export async function openRuntime(
187
140
  (message) => logger.warn(message),
188
141
  );
189
142
 
190
- const skills = knowledge?.skills ?? skillStore(storageFor, config.instance.profile);
143
+ // Rooted at the skills connection this profile grants, not at the profile
144
+ // (ADR-059). At most one can be granted, which is what makes this a lookup
145
+ // rather than a choice — `load.ts` refuses a second one, for the reason a
146
+ // prompt has no argument to route on.
147
+ //
148
+ // `EMPTY_SKILLS` when none is granted: a profile that denies `skills.*` has
149
+ // no store to open, and handing it the workspace root instead would serve
150
+ // every other profile's procedures.
151
+ const skillsConnection = soleGrantFor(config, 'skills');
152
+ const skills =
153
+ knowledge?.skills ??
154
+ (skillsConnection === undefined ? undefined : skillStore(storageFor, skillsConnection));
191
155
 
192
156
  // The vault's own store, beside the credential store and never it: a separate
193
157
  // document, a separate key, and a separate environment variable
@@ -208,6 +172,7 @@ export async function openRuntime(
208
172
  let registry: ProviderRegistry;
209
173
  let fingerprint = '';
210
174
  const refreshSkills = async (): Promise<void> => {
175
+ if (skills === undefined) return;
211
176
  fingerprint = await reloadSkills(registry, skills, fingerprint, refreshSkills);
212
177
  };
213
178
 
@@ -226,23 +191,18 @@ export async function openRuntime(
226
191
  // registered into. Per call rather than a snapshot, so a policy the runtime
227
192
  // was opened with is re-evaluated rather than remembered.
228
193
  const reachable = (): ReadonlyArray<{ key: string; provider: string; account: string }> =>
229
- config.connections
230
- .filter((connection) =>
194
+ selected
195
+ .filter(({ ref, connection }) =>
231
196
  registry
232
197
  .capabilities()
233
198
  .some(
234
199
  ({ id }) =>
235
200
  id.startsWith(`${connection.provider}.`) &&
236
- allowedConnections(
237
- id,
238
- [`${connection.provider}.${connection.id}`],
239
- principal,
240
- policy,
241
- ).length > 0,
201
+ allowedConnections(id, [ref], principal, policy).length > 0,
242
202
  ),
243
203
  )
244
- .map((connection) => ({
245
- key: `${connection.provider}.${connection.id}`,
204
+ .map(({ ref, connection }) => ({
205
+ key: ref,
246
206
  provider: connection.provider,
247
207
  account: connection.account,
248
208
  ...(connection.label ? { label: connection.label } : {}),
@@ -253,14 +213,14 @@ export async function openRuntime(
253
213
  // tolerated when the store is a repository: a local directory that will not
254
214
  // read is a real fault worth failing on, exactly as it always was.
255
215
  const loaded = await readSkillsForStart(
256
- skills,
216
+ skills ?? EMPTY_SKILL_STORE,
257
217
  knowledge !== undefined,
258
218
  (message) => logger.warn(message),
259
- ` --profile ${config.instance.profile} --target ${target}`,
219
+ ` --profile ${config.instance.profile} --workspace ${target}`,
260
220
  );
261
221
 
262
- registry = await buildRegistryWithWorkspace(root, config.instance.profile, {
263
- skillStore: skills,
222
+ registry = await buildRegistryWithWorkspace(root, {
223
+ ...(skills ? { skillStore: skills } : {}),
264
224
  skills: loaded.skills,
265
225
  onSkillsChanged: refreshSkills,
266
226
  vault: { store: vaultStore, items: await vaultStore.ids() },
@@ -269,7 +229,7 @@ export async function openRuntime(
269
229
  target,
270
230
  profiles: await listProfiles(root),
271
231
  catalogue: PROVIDER_MANIFESTS,
272
- ownClients: Object.keys(config.oauth_apps),
232
+ ownClients: Object.keys(connectionsFile.oauth_apps),
273
233
  reachable,
274
234
  },
275
235
  // Straight off the config: unlike `reachable`, nothing has to be filtered
@@ -329,14 +289,16 @@ export async function openRuntime(
329
289
  credentials,
330
290
  // From the connection row, which is where a per-connection setting has
331
291
  // always lived. One lookup behind both, so they cannot disagree.
332
- connectionConfig: (provider, id) => row(config, provider, id)?.config,
333
- isDeclared: (provider, id) => row(config, provider, id) !== undefined,
292
+ connectionConfig: (provider, id) => row(connectionsFile.connections, provider, id)?.config,
293
+ isDeclared: (provider, id) => row(connectionsFile.connections, provider, id) !== undefined,
334
294
  });
335
295
  const authorizeRequest = requestAuthorizer(registry, credentials);
336
296
  let closed = false;
337
297
 
338
298
  const dispatcher = new Dispatcher({
339
299
  config,
300
+ connections: connectionsFile.connections,
301
+ oauthApps: Object.keys(connectionsFile.oauth_apps),
340
302
  registry,
341
303
  connectorFor,
342
304
  authorizeRequest,
@@ -354,6 +316,9 @@ export async function openRuntime(
354
316
  config,
355
317
  target,
356
318
  declared,
319
+ connections: selected,
320
+ workspaceConnections: connectionsFile.connections,
321
+ ownClients: Object.keys(connectionsFile.oauth_apps),
357
322
  state,
358
323
  audit,
359
324
  credentials,
@@ -390,5 +355,5 @@ export async function openRuntime(
390
355
  };
391
356
  }
392
357
 
393
- const row = (config: Config, provider: string, id: string) =>
394
- config.connections.find((connection) => connection.provider === provider && connection.id === id);
358
+ const row = (connections: readonly ConnectionConfig[], provider: string, id: string) =>
359
+ connections.find((connection) => connection.provider === provider && connection.id === id);
@@ -3,7 +3,7 @@ import type { ProviderDefinition } from '#connectivity';
3
3
  import { ConfigError } from '#profile';
4
4
  import { ProviderRegistry } from '#registry';
5
5
  import { RESERVED_BY_GRAMMAR } from '../commands/connect/custom/spec.ts';
6
- import { loadProfileProviders } from '#providers/custom/index.ts';
6
+ import { loadWorkspaceProviders } from '#providers/custom/index.ts';
7
7
  import { loadProfileSkills, type LoadedSkill } from '#providers/skills/store.ts';
8
8
  import { exampleProvider } from '#providers/example/provider.ts';
9
9
  import {
@@ -138,21 +138,20 @@ export function buildRegistry(owner: OwnerLayerOptions = {}): ProviderRegistry {
138
138
  * overriding it — an operator shadowing `gmail` by accident would be very hard
139
139
  * to diagnose from the outside.
140
140
  *
141
- * The profile is a parameter rather than read from a config here because two of
142
- * the three callers do not have one: `deploy` walks every profile in turn, and
143
- * `profile remove` is holding the name of the one being deleted. Passing it
144
- * makes "which profile's manifests" a question the caller has already answered.
141
+ * No profile parameter any more. Manifests moved to the workspace with the
142
+ * connections they describe (ADR-057), so "which profile's manifests" stopped
143
+ * being a question and with it the whole reason `deploy` had to rebuild a
144
+ * registry per profile to collect credential refs.
145
145
  */
146
146
  export async function buildRegistryWithWorkspace(
147
147
  root: string,
148
- profile: string,
149
148
  owner: OwnerLayerOptions = {},
150
149
  ): Promise<ProviderRegistry> {
151
150
  const skills =
152
151
  owner.skills ?? (owner.skillStore ? await loadProfileSkills(owner.skillStore) : []);
153
152
  const registry = buildRegistry({ ...owner, skills });
154
153
 
155
- for (const { manifest, path } of await loadProfileProviders(root, profile)) {
154
+ for (const { manifest, path } of await loadWorkspaceProviders(root)) {
156
155
  if (registry.has(manifest.id)) {
157
156
  throw new ConfigError(
158
157
  `${path}: provider "${manifest.id}" is already built in. Rename it, or remove the file to use the built-in.`,