@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
@@ -0,0 +1,229 @@
1
+ import { clearSession, isFresh, readSession } from '#auth/lanes/session.ts';
2
+ import { DEFAULT_API_URL, currentIdToken, login } from '#auth/lanes/login.ts';
3
+ import { completionPage } from '../callback-page.ts';
4
+ import { print, ok, style, warn } from '../output.ts';
5
+
6
+ /**
7
+ * `lanes auth` — who this machine is signed in as.
8
+ *
9
+ * A separate area from `lanes link auth`, which is a per-connection credential
10
+ * diagnostic and answers a different question entirely. Neither is renamed and
11
+ * neither gains an alias: one spelling per command, and the help text on each
12
+ * says which is which.
13
+ *
14
+ * Sign-in is required to consume a profile, local endpoints included (ADR-060).
15
+ * That is a real dependency on lanes.sh for a self-hostable tool, and the shape
16
+ * of it is worth stating plainly: **the network is needed to sign in and to
17
+ * refresh, not per request.** A machine offline for a day keeps serving, and
18
+ * `status` below is the command that says how long that has left to run — a
19
+ * session that lapses silently would present as an endpoint that worked
20
+ * yesterday and does not today, which is the worst failure to debug from the
21
+ * outside.
22
+ */
23
+
24
+ export interface AuthFlags {
25
+ readonly json?: boolean | undefined;
26
+ readonly apiUrl?: string | undefined;
27
+ }
28
+
29
+ export async function authLogin(flags: AuthFlags = {}): Promise<void> {
30
+ const opened: string[] = [];
31
+
32
+ const session = await login({
33
+ apiUrl: flags.apiUrl,
34
+ // The same card a provider connect flow ends on. For a few seconds this
35
+ // page is the whole product, and it used to be an unstyled sentence on a
36
+ // white rectangle — in a browser set to dark, on a sign-in that had just
37
+ // worked. `brand.ts` carries the palette for both modes.
38
+ page: (outcome) =>
39
+ completionPage(
40
+ outcome.ok
41
+ ? { label: 'Lanes', heading: 'Signed in', detail: outcome.detail, ok: true }
42
+ : { label: 'Lanes', heading: 'That did not work', detail: outcome.detail, ok: false },
43
+ ),
44
+ onUrl: (url) => opened.push(url),
45
+ open: async (url) => {
46
+ // Printed as well as opened. A machine with no browser — a container, an
47
+ // SSH session — gets a URL it can paste somewhere that has one, which is
48
+ // the whole difference between "this does not work here" and "this takes
49
+ // one more step here".
50
+ print(style.dim(' Opening your browser to sign in. If nothing opens, use this:'));
51
+ print(` ${url}`);
52
+ print('');
53
+ await openInBrowser(url);
54
+ },
55
+ });
56
+
57
+ if (flags.json === true) {
58
+ print(JSON.stringify({ subject: session.subject, email: session.email }, null, 2));
59
+ return;
60
+ }
61
+
62
+ print(ok(`signed in as ${style.bold(session.email ?? session.subject)}`));
63
+ print(style.dim(` subject ${session.subject}`));
64
+ print('');
65
+ print(
66
+ style.dim(
67
+ ' A profile reaches you only where its members list that subject:\n' +
68
+ ' lanes link profile members add <subject> --profile <name>',
69
+ ),
70
+ );
71
+ void opened;
72
+ }
73
+
74
+ export async function authLogout(flags: AuthFlags = {}): Promise<void> {
75
+ const session = await readSession();
76
+ await clearSession();
77
+
78
+ if (flags.json === true) {
79
+ print(JSON.stringify({ signedOut: session !== null }, null, 2));
80
+ return;
81
+ }
82
+
83
+ print(
84
+ session === null
85
+ ? style.dim('Not signed in; nothing to do.')
86
+ : ok(`signed out ${style.bold(session.email ?? session.subject)}`),
87
+ );
88
+
89
+ // Said out loud, because it is the surprising half. Signing out removes the
90
+ // identity this machine presents; it does not touch a profile's members list,
91
+ // and it does not revoke a token an agent already holds.
92
+ print(
93
+ style.dim(
94
+ ' Profiles still list you, and a client holding a token still holds it.\n' +
95
+ ' To take a token back: lanes link token rotate --workspace <name>',
96
+ ),
97
+ );
98
+ }
99
+
100
+ export async function authStatus(flags: AuthFlags = {}): Promise<void> {
101
+ const session = await readSession();
102
+
103
+ if (session === null) {
104
+ if (flags.json === true) {
105
+ print(JSON.stringify({ signedIn: false }, null, 2));
106
+ return;
107
+ }
108
+ print(warn('not signed in'));
109
+ print(style.dim(' Run: lanes auth login'));
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+
114
+ const fresh = isFresh(session);
115
+
116
+ if (flags.json === true) {
117
+ print(
118
+ JSON.stringify(
119
+ {
120
+ signedIn: true,
121
+ subject: session.subject,
122
+ email: session.email,
123
+ expiresAt: session.expiresAt,
124
+ fresh,
125
+ apiUrl: session.apiUrl,
126
+ },
127
+ null,
128
+ 2,
129
+ ),
130
+ );
131
+ return;
132
+ }
133
+
134
+ print(ok(`signed in as ${style.bold(session.email ?? session.subject)}`));
135
+ print(` subject ${style.dim(session.subject)}`);
136
+ print(` api ${style.dim(session.apiUrl)}`);
137
+ print(
138
+ ` token ${
139
+ fresh
140
+ ? style.dim(`valid until ${session.expiresAt}`)
141
+ : style.dim('expired — the next command refreshes it')
142
+ }`,
143
+ );
144
+
145
+ if (session.apiUrl !== DEFAULT_API_URL) {
146
+ print(style.dim(` This session was issued by ${session.apiUrl}, not the default.`));
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Every workspace this identity can reach, and what it may do there.
152
+ *
153
+ * `GET /v1/me` is the source, and it is the server that decides — the client
154
+ * sends no uid, and the answer is derived from the token. That is what makes
155
+ * this listing something an operator can trust rather than a local guess.
156
+ */
157
+ export async function authWorkspaces(flags: AuthFlags = {}): Promise<void> {
158
+ const session = await readSession();
159
+ if (session === null) {
160
+ print(warn('not signed in'));
161
+ print(style.dim(' Run: lanes auth login'));
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ const token = await currentIdToken();
167
+ if (token === null) {
168
+ print(warn('the session could not be refreshed'));
169
+ print(style.dim(' Run: lanes auth login'));
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+
174
+ const response = await fetch(`${session.apiUrl}/v1/me`, {
175
+ headers: { authorization: `Bearer ${token}` },
176
+ });
177
+
178
+ if (!response.ok) {
179
+ print(warn(`${session.apiUrl} answered ${response.status}`));
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+
184
+ const body = (await response.json()) as {
185
+ memberships?: { workspace_id: string; workspace_name: string; role: string }[];
186
+ };
187
+ const memberships = body.memberships ?? [];
188
+
189
+ if (flags.json === true) {
190
+ print(JSON.stringify({ memberships }, null, 2));
191
+ return;
192
+ }
193
+
194
+ print(style.dim(`signed in as ${session.email ?? session.subject}`));
195
+ print('');
196
+
197
+ if (memberships.length === 0) {
198
+ print(style.dim('You are a member of no Lanes workspace yet.'));
199
+ } else {
200
+ for (const one of memberships) {
201
+ print(` ${style.bold(one.workspace_name)} ${style.dim(one.role)}`);
202
+ print(` ${style.dim(one.workspace_id)}`);
203
+ }
204
+ }
205
+
206
+ print('');
207
+ print(
208
+ style.dim(
209
+ 'A remote lanes link workspace binds to one of these with `lanes_workspace:`\n' +
210
+ 'in lanes-link.yaml, which is whose members a profile may delegate to.',
211
+ ),
212
+ );
213
+ }
214
+
215
+ /** Opens a URL, and says nothing if it cannot. The URL is printed either way. */
216
+ async function openInBrowser(url: string): Promise<void> {
217
+ const command =
218
+ process.platform === 'darwin'
219
+ ? ['open', url]
220
+ : process.platform === 'win32'
221
+ ? ['cmd', '/c', 'start', '', url]
222
+ : ['xdg-open', url];
223
+
224
+ try {
225
+ await Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).exited;
226
+ } catch {
227
+ // Nothing to report: the URL is on screen above, which is the fallback.
228
+ }
229
+ }
@@ -32,12 +32,12 @@ export function credentialApp(manifest: ProviderManifest): string | undefined {
32
32
  */
33
33
  export function accountSiblings(
34
34
  manifest: ProviderManifest,
35
- config: Config,
35
+ connections: readonly ConnectionConfig[],
36
36
  registry: { manifest(id: string): ProviderManifest | undefined },
37
37
  ): ConnectionConfig[] {
38
38
  const app = credentialApp(manifest);
39
39
 
40
- return config.connections.filter((connection) => {
40
+ return connections.filter((connection) => {
41
41
  if (connection.provider === manifest.id) return true;
42
42
  if (!app) return false;
43
43
  const sibling = registry.manifest(connection.provider);
@@ -55,7 +55,7 @@ export function accountSiblings(
55
55
  */
56
56
  export function siblingAccountId(
57
57
  manifest: ProviderManifest,
58
- config: Config,
58
+ connections: readonly ConnectionConfig[],
59
59
  registry: { manifest(id: string): ProviderManifest | undefined },
60
60
  ): string | undefined {
61
61
  if (!credentialApp(manifest)) return undefined;
@@ -63,7 +63,7 @@ export function siblingAccountId(
63
63
  // Asked of the registry, not inferred from the id: `app` is a manifest field,
64
64
  // and a provider is free to declare `app: icloud` under any name it likes.
65
65
  const ids = new Set(
66
- accountSiblings(manifest, config, registry)
66
+ accountSiblings(manifest, connections, registry)
67
67
  .filter((connection) => connection.provider !== manifest.id)
68
68
  .map((connection) => connection.id),
69
69
  );
@@ -1,3 +1,4 @@
1
+ import { registering } from './registration.ts';
1
2
  import { auth, discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/client';
2
3
  import { CredentialOAuthProvider } from '#connectivity/auth/index.ts';
3
4
  import type { SecretStore } from '#secrets';
@@ -146,10 +147,9 @@ export async function authorise(input: {
146
147
 
147
148
  const scope = manifest.auth.scopes.length > 0 ? manifest.auth.scopes.join(' ') : undefined;
148
149
 
149
- const first = await auth(provider as never, {
150
- serverUrl,
151
- ...(scope ? { scope } : {}),
152
- });
150
+ const first = await registering(manifest, serverUrl, () =>
151
+ auth(provider as never, { serverUrl, ...(scope ? { scope } : {}) }),
152
+ );
153
153
 
154
154
  if (first === 'AUTHORIZED') {
155
155
  progress(ok('already authorised'));
@@ -1,3 +1,4 @@
1
+ import { grantedConnections } from '../../runtime.ts';
1
2
  import { bindConnectionCredentials, type BindOutcome } from '#deployments/bind.ts';
2
3
  import type { Runtime } from '../../runtime.ts';
3
4
 
@@ -35,7 +36,7 @@ export function bindNewCredential(
35
36
  connectionId: string,
36
37
  account: string,
37
38
  ): Promise<BindOutcome> {
38
- const declared = runtime.config.connections.find(
39
+ const declared = grantedConnections(runtime).find(
39
40
  (candidate) => candidate.provider === providerId && candidate.id === connectionId,
40
41
  );
41
42
 
@@ -92,7 +92,7 @@ export async function connectCustom(
92
92
  if (options.json !== true) announce(resolution);
93
93
 
94
94
  const path = manifestPath(workspaceRoot, profile, providerId);
95
- const rerun = `${PROGRAM} connect custom ${providerId} --profile ${profile} --target ${target}`;
95
+ const rerun = `${PROGRAM} connect custom ${providerId} --profile ${profile} --workspace ${target}`;
96
96
 
97
97
  const prompter =
98
98
  options.prompter ??
@@ -17,7 +17,7 @@ import { parseManifest } from '#providers/custom/index.ts';
17
17
 
18
18
  /** Where this profile keeps its own declarations. */
19
19
  export function manifestPath(workspaceRoot: string, profile: string, id: string): string {
20
- return join(workspaceRoot, layout.providers(profile), `${id}.yaml`);
20
+ return join(workspaceRoot, layout.providers(), `${id}.yaml`);
21
21
  }
22
22
 
23
23
  /**
@@ -142,7 +142,7 @@ export async function checkOpenapiReachable(
142
142
 
143
143
  const beside = isAbsolute(value)
144
144
  ? value
145
- : resolve(join(workspaceRoot, layout.providers(profile)), value);
145
+ : resolve(join(workspaceRoot, layout.providers()), value);
146
146
 
147
147
  if (await exists(beside)) return;
148
148
 
@@ -1,27 +1,42 @@
1
+ import type { Config } from '#profile';
1
2
  import type { ConfigDocument } from '../../config-edit.ts';
2
3
  import { matchesRule } from './accounts.ts';
3
4
 
4
5
  /**
5
6
  * What connecting grants.
6
7
  *
7
- * One rule per provider, not one per capability. The pinned-per-tool form this
8
- * replaced was 85 lines for four providers and unreadable, and what it bought —
9
- * a vendor cannot widen your policy by shipping a new tool is preserved
10
- * instead by `doctor`, which reports capabilities that appeared after you
11
- * connected.
8
+ * One row naming the connection, carrying the wildcard for its provider not
9
+ * one rule per capability. The pinned-per-tool form this replaced was 85 lines
10
+ * for four providers and unreadable, and what it bought a vendor cannot widen
11
+ * your policy by shipping a new tool is preserved instead by `doctor`, which
12
+ * reports capabilities that appeared after you connected.
12
13
  *
13
- * Idempotent, and by matching rather than by equality: a profile already holding
14
- * a broader rule that covers this one is not widened, and a second connect to
15
- * the same provider adds nothing.
14
+ * A row rather than a rule, since contract 3. The connection and the permission
15
+ * used to be two writes into two blocks, which is what made "a row with no rule"
16
+ * and "a rule with no row" both expressible and both silent: the first served
17
+ * nothing, the second was refused at load. A grant is one thing now (ADR-058),
18
+ * so neither state exists to be repaired.
19
+ *
20
+ * Idempotent, and by matching rather than by equality: a profile whose row for
21
+ * this connection already holds a broader rule is not widened, and a second
22
+ * connect to the same account adds nothing.
16
23
  */
17
- export function grantProvider(
24
+ export function grantConnection(
18
25
  document: ConfigDocument,
19
- allow: readonly { readonly capability: string }[],
20
- providerId: string,
26
+ config: Config,
27
+ connection: string,
21
28
  ): string[] {
22
- const rule = `${providerId}.*`;
23
- if (allow.some((existing) => matchesRule(existing.capability, rule))) return [];
29
+ const rule = `${connection.split('.')[0] ?? ''}.*`;
30
+ const index = config.grants.findIndex((grant) => grant.connection === connection);
31
+
32
+ if (index === -1) {
33
+ document.addTo(['grants'], { connection, allow: [rule], deny: [] });
34
+ return [rule];
35
+ }
36
+
37
+ const existing = config.grants[index]!;
38
+ if (existing.allow.some((held) => matchesRule(held.capability, rule))) return [];
24
39
 
25
- document.addTo(['policy', 'allow'], rule, { inline: true });
40
+ document.addTo(['grants', index, 'allow'], rule, { inline: true });
26
41
  return [rule];
27
42
  }
@@ -1,11 +1,21 @@
1
+ import { CONNECTIONS_FILE } from '#profile';
1
2
  import { credentialRefForConnection, WRITE_BUNDLE } from '#connectivity';
3
+ import { recordConfigChange } from '../../audit-change.ts';
2
4
  import { ConfigDocument } from '../../config-edit.ts';
3
5
  import { ensureOwnerLayer, repaired } from '../../config-repair.ts';
4
6
  import { emit, print } from '../../output.ts';
5
7
  import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
6
- import { openRuntime, type GlobalFlags } from '../../runtime.ts';
8
+ import type { ConnectOptions } from './options.ts';
9
+
10
+ export type { ConnectOptions } from './options.ts';
11
+ import {
12
+ grantedConnections,
13
+ openRuntime,
14
+ primaryProfile,
15
+ type GlobalFlags,
16
+ } from '../../runtime.ts';
7
17
  import { moveCredential, siblingAccountId } from './accounts.ts';
8
- import { grantProvider } from './grant.ts';
18
+ import { grantConnection } from './grant.ts';
9
19
  import { acquireCredential } from './acquire.ts';
10
20
  import { declareConnection } from './declare.ts';
11
21
  import { resolveConnectionAddress, type ResolvedAddress } from './variables.ts';
@@ -26,79 +36,6 @@ import { runStrategySetup } from './strategy.ts';
26
36
  import { announceConnectTarget } from './target-note.ts';
27
37
  import { unknownProvider } from './unknown.ts';
28
38
 
29
- /**
30
- * `lanes link connect <provider>` — the one command that adds an account.
31
- *
32
- * The same command regardless of connectivity. A local provider declares a
33
- * connection and stops; an MCP provider authorises, asks the upstream server
34
- * what it exposes, and grants it. Core learns nothing about any vendor: the
35
- * manifest says how to reach them and what to ask the operator for.
36
- *
37
- * The five numbered steps below are the whole command, and each one that grew
38
- * past a paragraph moved out: `authorise.ts` gets the token, `setup.ts` asks the
39
- * operator for what the vendor's console produced, `settle.ts` works out whose
40
- * account it was, and `accounts.ts` knows which connections are siblings.
41
- */
42
-
43
- export interface ConnectOptions extends GlobalFlags {
44
- readonly id?: string | undefined;
45
- readonly displayName?: string | undefined;
46
- /** `--set key=value`, repeatable: where this connection's service is. */
47
- readonly set?: readonly string[] | string | undefined;
48
- /**
49
- * `--label`: what to call this connection, instead of being asked.
50
- *
51
- * Distinct from `--display-name`, which answers *whose account this is* for a
52
- * provider that cannot report it. This one never touches the identity, so it
53
- * is safe to pass anything a person would say out loud.
54
- */
55
- readonly label?: string | undefined;
56
- /** Ask for the stored credential again — a key rotated, or a password revoked. */
57
- readonly replace?: boolean | undefined;
58
- /**
59
- * Answer nothing from a terminal: resolve every declared value from the
60
- * credential store, and refuse with instructions where one is missing.
61
- */
62
- readonly nonInteractive?: boolean | undefined;
63
- /** The operator has already agreed to scopes broader than the provider needs. */
64
- readonly acceptBroadScopes?: boolean | undefined;
65
- /**
66
- * Register an OAuth client of your own rather than using a hosted one.
67
- *
68
- * Sticky by consequence rather than by flag: it writes the `oauth_apps` entry,
69
- * and a profile that declares one is never moved off it. So this is typed once
70
- * and then forgotten, which is the right shape for a decision about a client
71
- * that is shared by every connection of that vendor.
72
- */
73
- /**
74
- * `--own-client`, the older spelling of one of the routes `--auth` now names.
75
- *
76
- * Kept because it is in scripts and in a year of documentation, and because
77
- * it still says something true. It resolves to `--auth own_client`.
78
- */
79
- readonly ownClient?: boolean | undefined;
80
- /**
81
- * `--auth <method>`: which way in, for a provider that offers more than one.
82
- *
83
- * Unset means ask, where there is somebody to ask and something to ask
84
- * about. It is not sticky the way `--own-client` is: `--own-client` writes an
85
- * `oauth_apps` entry that every connection of the vendor then reads, whereas
86
- * this decides one connection's credential and is recorded by that credential
87
- * existing. Two accounts on the same profile may honestly differ.
88
- */
89
- readonly auth?: string | undefined;
90
- /** Injected for tests. The broker is the only thing `connect` fetches. */
91
- readonly fetch?: typeof globalThis.fetch | undefined;
92
- readonly json?: boolean | undefined;
93
- }
94
-
95
-
96
- /**
97
- * The connection id used before the provider has said whose account this is.
98
- *
99
- * It is a placeholder rather than a name, and `setup.ts` reads it as one: a
100
- * credential filed under it belongs to a `connect` that did not finish.
101
- */
102
39
  const PROVISIONAL_ID = 'pending';
103
40
 
104
41
  export async function connect(target: string, options: ConnectOptions): Promise<void> {
@@ -128,7 +65,13 @@ export async function runConnect(
128
65
 
129
66
  // The runtime is opened first because its registry includes workspace
130
67
  // manifests — a custom provider must be as connectable as a built-in.
131
- const runtime = await openRuntime(options);
68
+ // A connection belongs to the workspace, so connecting does not need a
69
+ // profile (ADR-057). One is still resolved, because the registry that reads
70
+ // `providers.d/` is opened through a runtime and a runtime carries one — but
71
+ // it is not the subject, and nothing is written to it unless `--profile` was
72
+ // actually given.
73
+ const granting = options.profile !== undefined;
74
+ const runtime = await openRuntime({ ...options, profile: await primaryProfile(options) });
132
75
 
133
76
  // Declared out here so the `finally` can close whatever it built.
134
77
  let address: ResolvedAddress | undefined;
@@ -139,7 +82,7 @@ export async function runConnect(
139
82
  // `gs://` workspace is a second network read of the same YAML. Still long
140
83
  // before the browser opens, which is the part that matters. Inside the
141
84
  // `try` so the `finally` closes the runtime if the rendering throws.
142
- if (!announced) announceConnectTarget(runtime, options.json);
85
+ if (!announced) announceConnectTarget(runtime, options.json, granting);
143
86
 
144
87
  const registry = runtime.registry;
145
88
  const entry = registry.get(providerId);
@@ -171,7 +114,17 @@ export async function runConnect(
171
114
  }
172
115
 
173
116
  const manifest = entry.manifest;
174
- const document = await ConfigDocument.open(runtime.resolution.workspaceRoot, runtime.resolution.profile);
117
+ // Two documents, because a connection and a grant live in two files
118
+ // (ADR-057). Both opened here, so a failure to open either happens before
119
+ // anything is written to the other.
120
+ const document = await ConfigDocument.open(
121
+ runtime.resolution.workspaceRoot,
122
+ runtime.resolution.profile,
123
+ );
124
+ const connectionsDocument = await ConfigDocument.openKey(
125
+ runtime.resolution.workspaceRoot,
126
+ CONNECTIONS_FILE,
127
+ );
175
128
  const changes: string[] = [];
176
129
 
177
130
  // 1. Authorise, if this provider needs it.
@@ -183,7 +136,7 @@ export async function runConnect(
183
136
  // Unless a sibling already knows: when several providers of one vendor
184
137
  // share a per-account credential, the second one should adopt the first's
185
138
  // id rather than invent `pending` and ask for a password already held.
186
- const adopted = siblingAccountId(manifest, runtime.config, registry);
139
+ const adopted = siblingAccountId(manifest, runtime.workspaceConnections, registry);
187
140
  const named = options.id ?? namedId;
188
141
  const provisionalId = named ?? adopted ?? PROVISIONAL_ID;
189
142
 
@@ -252,10 +205,17 @@ export async function runConnect(
252
205
  manifest,
253
206
  provisionalId,
254
207
  credentials: runtime.credentials,
255
- document,
208
+ // The connections file, not the profile. `oauth_apps` moved there in
209
+ // contract 3 — `configSchema` does not declare it any more and only
210
+ // `connectionsFileSchema` does — so `--own-client` was writing the switch
211
+ // into a key nothing reads. With no `--profile` the profile document is
212
+ // not even saved, so it was reported and discarded; with one, it landed in
213
+ // a block the schema drops, and dispatch fell back to the broker client
214
+ // while `deploy` bound none of the operator's own refs.
215
+ document: connectionsDocument,
256
216
  changes,
257
217
  providerId,
258
- connections: runtime.config.connections,
218
+ connections: grantedConnections(runtime),
259
219
  target,
260
220
  profile,
261
221
  prompter,
@@ -313,8 +273,8 @@ export async function runConnect(
313
273
  // 4. Declare the connection, or update the one this account already has.
314
274
  changes.push(
315
275
  ...declareConnection({
316
- document,
317
- connections: runtime.config.connections,
276
+ document: connectionsDocument,
277
+ connections: runtime.workspaceConnections,
318
278
  providerId,
319
279
  connectionId,
320
280
  account,
@@ -324,8 +284,17 @@ export async function runConnect(
324
284
  }),
325
285
  );
326
286
 
327
- // 5. Grant it — one rule per provider; `grant.ts` says why not per capability.
328
- const granted = grantProvider(document, runtime.config.policy.allow, providerId);
287
+ // 5. Grant it — one row naming the connection, carrying its provider's
288
+ // wildcard (ADR-058). The row *is* the grant, so neither half can be
289
+ // written without the other.
290
+ // Only when a profile was named. Connecting authorises an account into
291
+ // the workspace; deciding what may be done with it belongs to a profile,
292
+ // and those are two acts — so `connect` without `--profile` leaves the
293
+ // account granted to nobody and says how to grant it. Writing a grant
294
+ // into a profile the operator did not name would be choosing for them.
295
+ const granted = granting
296
+ ? grantConnection(document, runtime.config, `${providerId}.${connectionId}`)
297
+ : [];
329
298
 
330
299
  // 6. Repair the owner layer if this profile predates it.
331
300
  //
@@ -342,7 +311,7 @@ export async function runConnect(
342
311
  // sentence in it is something a caller counting edits has to recognise
343
312
  // and skip, and `granted` is the field that answers "what did this widen"
344
313
  // — an audit reading it would have missed `setup.*` entirely.
345
- const repair = ensureOwnerLayer(document);
314
+ const repair = ensureOwnerLayer(connectionsDocument, document, { grants: granting });
346
315
  changes.push(...repair.changes);
347
316
  granted.push(...repair.granted);
348
317
 
@@ -369,7 +338,39 @@ export async function runConnect(
369
338
  };
370
339
  }
371
340
 
372
- await document.save();
341
+ // The connections file first, always, and the profile second.
342
+ //
343
+ // First because a grant naming a connection the workspace does not hold is
344
+ // refused at load by `assertGrantsResolve` — so if only one of these two
345
+ // writes lands, it has to be this one. The other order leaves a workspace
346
+ // that will not open.
347
+ //
348
+ // Always because this is where the connection is *declared*, and until this
349
+ // release it was never written at all: `declareConnection` edited a document
350
+ // in memory and nothing saved it, so a connect stored the credential, wrote
351
+ // the grant, and left no row. The account was authorised, invisible, and
352
+ // unusable.
353
+ await connectionsDocument.save();
354
+
355
+ // Untouched when no profile was named, and `save()` on an unchanged document
356
+ // still rewrites the file — which would restamp a profile nobody asked to
357
+ // edit.
358
+ if (granting) await document.save();
359
+
360
+ // A connection belongs to the workspace, so the row is scoped to the
361
+ // workspace even when a profile was granted it in the same breath — the
362
+ // grant is its own event, written by `grant`.
363
+ await recordConfigChange(
364
+ runtime.config,
365
+ runtime.resolution.workspaceRoot,
366
+ runtime.target,
367
+ {
368
+ capability: 'config.connection.create',
369
+ scope: runtime.target,
370
+ connection: connectionKey,
371
+ arguments: { account, ...(label ? { label } : {}) },
372
+ },
373
+ );
373
374
 
374
375
  // Where the endpoint that has to serve this reads its config, and then a
375
376
  // nudge to re-read it. Neither is a deploy (ADR-029).