@lanes-sh/link 0.7.2 → 0.9.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 (190) 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 +148 -73
  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 +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -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 +90 -88
  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 +16 -6
  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 +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
  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 +53 -5
  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/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,86 @@
1
+ import { print } from '../../output.ts';
2
+
3
+ /**
4
+ * `lanes link mcp install-instructions` — the block a client should be told once.
5
+ *
6
+ * A registration points a client at this endpoint. It does not tell the *model*
7
+ * anything, and the model is what decides whether to look here at all. The
8
+ * common failure is not a client that cannot reach the endpoint; it is one that
9
+ * can and answers from nothing anyway, because reaching for a tool is a habit
10
+ * and no habit is installed by adding a server to a config file.
11
+ *
12
+ * So this prints a short block for a person to paste into whatever their client
13
+ * treats as durable instruction — a project file, a custom instruction, a
14
+ * memory. It says where things are and what to read first, and nothing else:
15
+ * the full account lives at `lanes://instructions` and is served by the
16
+ * endpoint, so it can be corrected without anybody pasting anything again.
17
+ *
18
+ * **Printed rather than written.** Every client keeps this somewhere different
19
+ * and several keep it somewhere a person curates by hand. Writing into one of
20
+ * those is the class of thing ADR-016 puts on the other side of the line from
21
+ * `mcp add`: delegate where the harness owns a command, write the file only
22
+ * where it does not, and where neither is true, hand over the text.
23
+ */
24
+
25
+ /** What each client calls the place this belongs, so the hint is actionable. */
26
+ const WHERE: Record<string, string> = {
27
+ claude: 'CLAUDE.md in your project, or ~/.claude/CLAUDE.md for every project',
28
+ chatgpt: "Settings → Personalization → Custom instructions, under 'anything else'",
29
+ codex: 'AGENTS.md in your project, or ~/.codex/AGENTS.md',
30
+ cursor: '.cursor/rules/ in your project',
31
+ };
32
+
33
+ export interface InstallInstructionsFlags {
34
+ readonly client?: string | undefined;
35
+ }
36
+
37
+ export function installInstructions(flags: InstallInstructionsFlags = {}): void {
38
+ const client = flags.client?.toLowerCase();
39
+
40
+ if (client !== undefined && !(client in WHERE)) {
41
+ throw new Error(
42
+ `Unknown client "${client}". Known: ${Object.keys(WHERE).join(', ')}.\n` +
43
+ ' Omit --client for the block on its own.',
44
+ );
45
+ }
46
+
47
+ if (client !== undefined) {
48
+ // To stderr, so the block on stdout stays pasteable. Somebody piping this
49
+ // into a file wants the text and not the advice about where to put it.
50
+ process.stderr.write(`Paste this into ${WHERE[client]}:\n\n`);
51
+ }
52
+
53
+ print(BLOCK);
54
+ }
55
+
56
+ /**
57
+ * The block itself.
58
+ *
59
+ * Deliberately short and deliberately not a copy of the endpoint's own
60
+ * instructions. It exists to establish one habit — look here before answering
61
+ * from nothing — and to name the document that carries the rest. A longer block
62
+ * pasted into a project file is one that goes stale in a place nobody will
63
+ * think to update.
64
+ *
65
+ * No vendor names and no account details, for the same reason nothing under
66
+ * `server/` may carry one: what the owner connected is theirs.
67
+ */
68
+ const BLOCK = `## Lanes Link
69
+
70
+ Lanes Link is the route to my accounts, my memory, my notes, my saved procedures
71
+ and my contacts. It is an MCP endpoint, and it is already connected.
72
+
73
+ - Before saying you do not know something about me or my work, search memory.
74
+ - Before using anyone's address or handle, look them up in entities. It returns
75
+ every match and never picks one; if there is more than one, ask me.
76
+ - A thing for me to do is a task, not a memory. Tasks have a status.
77
+ - If I have a saved procedure for something, tell me it exists and let me run it
78
+ rather than improvising your own version.
79
+ - Before telling me something cannot be reached or that an account is missing,
80
+ ask the setup surface. It reports what exists and gives the exact command for
81
+ what does not. Run nothing yourself: every change here is mine to make.
82
+ - Read \`lanes://instructions\` before your first call for the full account of
83
+ how routing, profiles and refusals work.
84
+
85
+ Every tool takes a profile. A profile is how I keep things apart, so when it is
86
+ ambiguous which one I mean, ask rather than picking the first.`;
@@ -32,6 +32,17 @@ export interface McpAddOptions extends GlobalFlags {
32
32
  readonly force?: boolean | undefined;
33
33
  /** Register only; leave the skill and the agent alone. */
34
34
  readonly noSkill?: boolean | undefined;
35
+ /**
36
+ * Write the endpoint token into the registration, for a machine with no browser.
37
+ *
38
+ * The ordinary path registers a bare URL and the client authorises itself
39
+ * against lanes.sh (ADR-062), which is both safer and less to get wrong. This
40
+ * is the CI escape hatch, and it is a flag rather than a fallback because the
41
+ * difference has to be a decision somebody made: a registration that quietly
42
+ * embedded a credential when a browser was unavailable is one nobody would
43
+ * notice had done so.
44
+ */
45
+ readonly headless?: boolean | undefined;
35
46
  }
36
47
 
37
48
  export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
@@ -71,17 +82,20 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
71
82
  const runtime = await openRuntime(options);
72
83
 
73
84
  try {
85
+ // Minted either way. The endpoint needs one to serve at all, and `outputs`
86
+ // prints it; what `--headless` decides is whether it is written into
87
+ // somebody's agent config.
74
88
  const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
75
89
 
76
90
  // The target's own address, not the local one. This built
77
- // `http://<host>:<port>/mcp` unconditionally, so `mcp add --target cloud`
91
+ // `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
78
92
  // registered loopback with the agent: a registration that reports success,
79
93
  // names the right server, and points at a port with nothing behind it.
80
94
  const url = await endpointUrl(runtime.config, runtime.declared);
81
95
  const input: AddInput = {
82
96
  name,
83
97
  url,
84
- token,
98
+ ...(options.headless === true ? { token } : {}),
85
99
  tokenEnv,
86
100
  scope,
87
101
  profile: runtime.resolution.profile,
@@ -20,3 +20,4 @@ export { harnessCommands } from './mcp/harnesses.ts';
20
20
  export { mcpList } from './mcp/list.ts';
21
21
  export { mcpAdd, type McpAddOptions } from './mcp/register.ts';
22
22
  export { mcpStdio } from './mcp/stdio.ts';
23
+ export { installInstructions, type InstallInstructionsFlags } from './mcp/onboarding.ts';
@@ -0,0 +1,288 @@
1
+ import { readSession } from '#auth/lanes/session.ts';
2
+ import { recordConfigChange } from '../audit-change.ts';
3
+ import {
4
+ ConfigError,
5
+ readRegistry,
6
+ resolveTargetWorkspace,
7
+ resolveWorkspaceRoot,
8
+ } from '#profile';
9
+ import { ConfigDocument } from '../config-edit.ts';
10
+ import { describeMember, workspaceMembers } from '#auth/lanes/members.ts';
11
+ import { announce, emit, heading, ok, print, style, table, warn } from '../output.ts';
12
+ import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
13
+ import { resolveProfile, type GlobalFlags } from '../runtime.ts';
14
+
15
+ /**
16
+ * `lanes link profile members` — who may consume a profile (ADR-060).
17
+ *
18
+ * CLI-only, like everything that authorises future agent behaviour (ADR-007),
19
+ * and for the sharpest version of that argument: an agent able to edit this
20
+ * could add itself.
21
+ *
22
+ * **This is a selection from the Lanes workspace, not a list beside it.**
23
+ * Membership is managed on the dashboard — invited, accepted, given a role,
24
+ * removed — and a workspace bound with `lanes_workspace:` is asked who it holds.
25
+ * Somebody added there shows up here the moment they accept, and granting them a
26
+ * profile is a local edit naming a subject the server already vouches for.
27
+ *
28
+ * For `local` there is no such list, so the only subject accepted is the
29
+ * signed-in one — a local workspace delegating to a stranger is a typo, not a
30
+ * use case.
31
+ */
32
+
33
+ export interface MemberRow {
34
+ readonly subject: string;
35
+ readonly role: 'owner' | 'member';
36
+ /** Whether this is the identity running the command. */
37
+ readonly you: boolean;
38
+ }
39
+
40
+ export async function membersList(flags: GlobalFlags & { json?: boolean }): Promise<void> {
41
+ const { resolution, config, target } = await resolveProfile(flags);
42
+ const session = await readSession();
43
+
44
+ const rows: MemberRow[] = config.members.map((member) => ({
45
+ subject: member.subject,
46
+ role: member.role,
47
+ you: member.subject === session?.subject,
48
+ }));
49
+
50
+ // The workspace's own people, so this listing answers both halves of the
51
+ // question: who may use this profile, and who *could* be given it. Without
52
+ // the second, adding somebody means going to the dashboard to copy a subject
53
+ // out of it.
54
+ const held = await workspaceMembers(await boundWorkspace(target));
55
+ const granted = new Set(rows.map((row) => row.subject));
56
+ const available = held.members.filter(
57
+ (member) => member.subject === null || !granted.has(member.subject),
58
+ );
59
+
60
+ return emit(
61
+ flags.json,
62
+ { profile: resolution.profile, members: rows, available: available },
63
+ () => {
64
+ announce(resolution);
65
+
66
+ if (rows.length === 0) {
67
+ // Empty is nobody, and saying so matters: the natural reading of a blank
68
+ // list is "no restriction", which is the opposite of what it means.
69
+ print(style.dim('No members. Nobody may consume this profile.'));
70
+ print(style.dim(' Add yourself with: lanes link profile members add --me'));
71
+ } else {
72
+ heading(`May consume ${resolution.profile} (${rows.length})`);
73
+ table(
74
+ rows.map((row) => [
75
+ ` ${style.bold(row.subject)}`,
76
+ row.role,
77
+ row.you ? style.dim('you') : '',
78
+ ]),
79
+ );
80
+ }
81
+
82
+ if (available.length > 0) {
83
+ heading(`In the workspace, not granted (${available.length})`);
84
+ table(
85
+ available.map((member) => [
86
+ ` ${style.bold(describeMember(member))}`,
87
+ member.role,
88
+ member.status === 'pending'
89
+ ? style.dim('invitation not accepted')
90
+ : style.dim(member.subject ?? ''),
91
+ ]),
92
+ );
93
+ }
94
+
95
+ if (held.unavailable !== null && held.unavailable !== 'this workspace is not bound to a Lanes workspace') {
96
+ print('');
97
+ print(style.dim(`Could not list the workspace's members: ${held.unavailable}`));
98
+ }
99
+ },
100
+ );
101
+ }
102
+
103
+ /** The Lanes workspace this one is bound to, if any. */
104
+ async function boundWorkspace(target: string): Promise<string | undefined> {
105
+ // **The workspace that declares the target, which is not this machine once it
106
+ // is deployed.** `lanes_workspace` is a declaration field, and
107
+ // `recordDeployment` writes the declaration into the bucket while leaving
108
+ // this machine a pointer carrying only `at`, `primary` and the deploy stamps.
109
+ // So reading it here found nothing the moment a target was deployed: binding
110
+ // a workspace stopped taking effect, `members add` refused everyone but the
111
+ // person at the keyboard, and the remedy it printed — add `lanes_workspace:`
112
+ // under `workspaces.<target>` — named the pointer entry, which the next
113
+ // deploy overwrites. A declaration resolves back to this root unchanged.
114
+ const local = resolveWorkspaceRoot();
115
+ const registry = await readRegistry(await resolveTargetWorkspace(local, target).catch(() => local));
116
+ return registry[target]?.lanes_workspace;
117
+ }
118
+
119
+ export interface MembersFlags extends GlobalFlags {
120
+ readonly json?: boolean | undefined;
121
+ /** Add the signed-in subject, rather than one typed out. */
122
+ readonly me?: boolean | undefined;
123
+ readonly role?: string | undefined;
124
+ }
125
+
126
+ export async function membersAdd(
127
+ subject: string | undefined,
128
+ flags: MembersFlags,
129
+ ): Promise<void> {
130
+ const { resolution, config, target } = await resolveProfile(flags);
131
+ const session = await readSession();
132
+
133
+ const wanted = flags.me === true ? session?.subject : subject;
134
+
135
+ if (wanted === undefined) {
136
+ throw new ConfigError(
137
+ flags.me === true
138
+ ? 'Not signed in, so there is no "me" to add. Run: lanes auth login'
139
+ : 'Which subject? Run: lanes link profile members add <subject> --profile <name>\n' +
140
+ ' Or add yourself: lanes link profile members add --me --profile <name>',
141
+ );
142
+ }
143
+
144
+ const role = flags.role === 'owner' ? 'owner' : 'member';
145
+
146
+ if (config.members.some((member) => member.subject === wanted)) {
147
+ print(style.dim(`${resolution.profile} already lists ${wanted}.`));
148
+ return;
149
+ }
150
+
151
+ await assertDelegatable(wanted, target, session?.subject);
152
+
153
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
154
+ document.addTo(['members'], { subject: wanted, role }, { inline: true });
155
+ await document.save();
156
+
157
+ await recordConfigChange(
158
+ config,
159
+ resolution.workspaceRoot,
160
+ target,
161
+ {
162
+ capability: 'config.member.add',
163
+ scope: resolution.profile,
164
+ arguments: { subject: wanted, role },
165
+ },
166
+ (note) => print(warn(note)),
167
+ );
168
+
169
+ const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
170
+
171
+ return emit(flags.json, { profile: resolution.profile, subject: wanted, role }, () => {
172
+ announce(resolution);
173
+ print(ok(`${wanted} may now consume ${style.bold(resolution.profile)} as ${role}`));
174
+ print(style.dim(' They reach exactly what this profile grants, and nothing else.'));
175
+ if (published) print(style.dim(` ${published}`));
176
+ });
177
+ }
178
+
179
+ export async function membersRemove(
180
+ subject: string | undefined,
181
+ flags: MembersFlags,
182
+ ): Promise<void> {
183
+ const { resolution, config, target } = await resolveProfile(flags);
184
+ if (!subject) throw new ConfigError('Which subject? Run: lanes link profile members list');
185
+
186
+ const at = config.members.findIndex((member) => member.subject === subject);
187
+ if (at === -1) {
188
+ print(style.dim(`${resolution.profile} does not list ${subject}.`));
189
+ return;
190
+ }
191
+
192
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
193
+ document.removeFrom(['members'], at);
194
+ await document.save();
195
+
196
+ await recordConfigChange(
197
+ config,
198
+ resolution.workspaceRoot,
199
+ target,
200
+ { capability: 'config.member.remove', scope: resolution.profile, arguments: { subject } },
201
+ (note) => print(warn(note)),
202
+ );
203
+
204
+ const published = nextAfterEdit(await publishProfileEdit({ resolution, config, target }));
205
+
206
+ return emit(flags.json, { profile: resolution.profile, subject }, () => {
207
+ announce(resolution);
208
+ print(ok(`${subject} may no longer consume ${style.bold(resolution.profile)}`));
209
+
210
+ // The half that is not obvious, and is the difference between this and a
211
+ // session manager. A token already issued keeps working until it expires:
212
+ // membership is read when one is minted, not on every call (ADR-060).
213
+ print(
214
+ style.dim(
215
+ ' A token they already hold keeps working until it expires.\n' +
216
+ ` To close that window now: lanes link token rotate --workspace ${target}`,
217
+ ),
218
+ );
219
+ if (published) print(style.dim(` ${published}`));
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Whether this workspace may delegate to that subject.
225
+ *
226
+ * The check exists because the alternative is a profile listing a subject
227
+ * nobody can produce a token for — which reads exactly like a working
228
+ * delegation until the person tries to use it.
229
+ *
230
+ * Bound to a Lanes workspace, the answer is the workspace's own member list:
231
+ * somebody added on the dashboard is delegatable here, and nobody else is.
232
+ * Unbound, there is no list to ask, so the only verifiable subject is the one at
233
+ * the keyboard.
234
+ */
235
+ async function assertDelegatable(
236
+ subject: string,
237
+ target: string,
238
+ signedIn: string | undefined,
239
+ ): Promise<void> {
240
+ const bound = await boundWorkspace(target);
241
+
242
+ if (bound === undefined) {
243
+ if (subject === signedIn) return;
244
+
245
+ throw new ConfigError(
246
+ `Workspace "${target}" is not bound to a Lanes workspace, so it can only delegate to you.\n` +
247
+ ' Add yourself with: lanes link profile members add --me\n' +
248
+ ' To delegate to somebody else, bind the workspace:\n' +
249
+ ` lanes_workspace: <id> # in lanes-link.yaml, under workspaces.${target}\n` +
250
+ ' Run "lanes auth workspaces" for the ids you belong to.',
251
+ );
252
+ }
253
+
254
+ const held = await workspaceMembers(bound);
255
+
256
+ // Unreachable rather than empty. Refusing on a network failure would make a
257
+ // local edit depend on our uptime, and the endpoint verifies the subject when
258
+ // it mints a token regardless — so this warns and proceeds.
259
+ if (held.unavailable !== null) {
260
+ print(
261
+ style.dim(
262
+ ` Could not check the workspace's members (${held.unavailable}), so this was not verified.`,
263
+ ),
264
+ );
265
+ return;
266
+ }
267
+
268
+ const match = held.members.find((member) => member.subject === subject);
269
+ if (match !== undefined) return;
270
+
271
+ // A pending invitation is the interesting refusal: the person exists, the
272
+ // operator can see them on the dashboard, and there is still no subject to
273
+ // write down. Saying "not a member" would send them to add somebody who is
274
+ // already there.
275
+ const pending = held.members.filter((member) => member.status === 'pending');
276
+
277
+ throw new ConfigError(
278
+ `${subject} is not a member of the Lanes workspace behind "${target}".\n` +
279
+ (held.members.length > 0
280
+ ? ` It holds: ${held.members.map(describeMember).join(', ')}\n`
281
+ : ' It holds nobody yet.\n') +
282
+ (pending.length > 0
283
+ ? ` ${pending.map(describeMember).join(', ')} ${pending.length === 1 ? 'has' : 'have'} not ` +
284
+ 'accepted the invitation yet, so there is no subject for them to be granted.\n'
285
+ : '') +
286
+ ' Invite them on the dashboard first: https://lanes.sh/dashboard/settings/members',
287
+ );
288
+ }
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { basename } from 'node:path';
3
3
  import { ownerPrincipal } from '#auth';
4
4
  import { announce, print, style } from '../../output.ts';
5
- import { openRuntime, type GlobalFlags } from '../../runtime.ts';
5
+ import { grantedConnections, openRuntime, type GlobalFlags } from '../../runtime.ts';
6
6
 
7
7
  /**
8
8
  * `lanes link attach <file> --connection <provider>.<account>`
@@ -44,11 +44,11 @@ export async function attachFile(
44
44
 
45
45
  // Checked against config rather than trusted, so a typo names the mistake
46
46
  // instead of staging into a namespace nothing will ever read.
47
- const known = runtime.config.connections.some(
47
+ const known = grantedConnections(runtime).some(
48
48
  (connection) => connection.provider === providerId && connection.id === connectionId,
49
49
  );
50
50
  if (!known) {
51
- const have = runtime.config.connections.map((c) => `${c.provider}.${c.id}`);
51
+ const have = grantedConnections(runtime).map((c) => `${c.provider}.${c.id}`);
52
52
  throw new Error(
53
53
  `No connection "${flags.connection}" in this profile. Configured: ${have.join(', ') || 'none'}.`,
54
54
  );
@@ -1,5 +1,5 @@
1
- import { announce, print, style, table } from '../../output.ts';
2
- import { openRuntime, type GlobalFlags } from '../../runtime.ts';
1
+ import { announceWorkspace, print, style, table } from '../../output.ts';
2
+ import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
3
3
 
4
4
  /** `lanes link audit tail` — the log the dispatcher writes, rendered for a terminal. */
5
5
 
@@ -15,11 +15,11 @@ export async function auditTail(
15
15
  }
16
16
  const markdown = flags.format === 'md';
17
17
 
18
- const runtime = await openRuntime(flags);
18
+ const runtime = await openWorkspaceRuntime(flags);
19
19
  try {
20
20
  // The banner is chrome around a table; in Markdown it is a stray line in
21
21
  // the middle of a document someone is pasting somewhere.
22
- if (!markdown) announce(runtime.resolution);
22
+ if (!markdown) announceWorkspace(runtime.resolution);
23
23
 
24
24
  const events = await runtime.audit.tail({
25
25
  limit: flags.limit ?? 25,
@@ -35,7 +35,11 @@ export async function auditTail(
35
35
  // The log lives in the database; this is a rendering of it, not a second
36
36
  // copy of it. See ADR-013 — storage format and display format are not the
37
37
  // same decision.
38
- print(`# Audit ${runtime.resolution.profile}\n`);
38
+ // The workspace, not the profile. Contract 3 gave the workspace one
39
+ // chain covering every profile in it, and a heading naming whichever
40
+ // profile happened to open the store described a log that does not
41
+ // exist. Each row carries its own profile.
42
+ print(`# Audit — ${runtime.resolution.target}\n`);
39
43
  print('| Time | Result | Capability | Connection | Duration | Arguments |');
40
44
  print('|---|---|---|---|---|---|');
41
45
  for (const event of events) {
@@ -81,9 +85,9 @@ export async function auditTail(
81
85
  * can see how much was looked at.
82
86
  */
83
87
  export async function auditVerify(flags: GlobalFlags): Promise<void> {
84
- const runtime = await openRuntime(flags);
88
+ const runtime = await openWorkspaceRuntime(flags);
85
89
  try {
86
- announce(runtime.resolution);
90
+ announceWorkspace(runtime.resolution);
87
91
  const report = await runtime.audit.verify();
88
92
 
89
93
  const scope = `${report.events} event${report.events === 1 ? '' : 's'} across ${report.runs} run${report.runs === 1 ? '' : 's'}`;
@@ -1,8 +1,15 @@
1
+ import type { ConnectionConfig } from '#profile';
1
2
  import { credentialResolver, ReauthRequired } from '#connectivity/auth/index.ts';
2
3
  import type { ResolvedCredential } from '#connectivity/auth/credential.ts';
3
4
  import { credentialRefFor } from '#registry';
4
- import { announce, emit, fail, ok, print, warn } from '../../output.ts';
5
- import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
5
+ import { announceWorkspace, emit, fail, ok, print, warn } from '../../output.ts';
6
+ import { readConnections } from '#profile';
7
+ import {
8
+ grantedConnections,
9
+ openWorkspaceRuntime,
10
+ type GlobalFlags,
11
+ type Runtime,
12
+ } from '../../runtime.ts';
6
13
 
7
14
  /**
8
15
  * Whether each connection could still authenticate, asked rather than guessed.
@@ -146,13 +153,13 @@ export interface AuthFlags extends GlobalFlags {
146
153
  */
147
154
  export async function probeConnections(
148
155
  runtime: Runtime,
149
- connections: readonly Runtime['config']['connections'][number][],
156
+ connections: readonly ConnectionConfig[],
150
157
  forSelection: (command: string) => string,
151
158
  ): Promise<ConnectionAuth[]> {
152
159
  const resolve = credentialResolver(runtime.registry, runtime.credentials);
153
160
 
154
161
  const probe = async (
155
- connection: Runtime['config']['connections'][number],
162
+ connection: ConnectionConfig,
156
163
  ): Promise<ConnectionAuth> => {
157
164
  const key = `${connection.provider}.${connection.id}`;
158
165
  const manifest = runtime.manifestFor(connection.provider);
@@ -245,20 +252,30 @@ export async function probeConnections(
245
252
  }
246
253
 
247
254
  export async function auth(flags: AuthFlags): Promise<void> {
248
- const runtime = await openRuntime(flags);
255
+ const runtime = await openWorkspaceRuntime(flags);
249
256
 
250
257
  try {
251
258
  const { profile, target } = runtime.resolution;
252
- const forSelection = (command: string) => `${command} --profile ${profile} --target ${target}`;
259
+ const forSelection = (command: string) => `${command} --workspace ${target}`;
260
+
261
+ // Every connection the workspace holds, not the ones one profile grants.
262
+ // Whether an account can still authenticate is a fact about the account
263
+ // (ADR-057), and scoping it to a profile's grants meant an account that had
264
+ // just been connected could not be checked until somebody granted it —
265
+ // which is exactly the moment you want to check.
266
+ //
267
+ // `--profile` narrows to what that profile can reach, because "can the
268
+ // things this profile uses still sign in" is also a real question.
269
+ const all = flags.profile === undefined
270
+ ? (await readConnections(runtime.resolution.workspaceRoot)).connections
271
+ : grantedConnections(runtime);
253
272
 
254
273
  const wanted = flags.connection;
255
- const connections = wanted
256
- ? runtime.config.connections.filter((c) => `${c.provider}.${c.id}` === wanted)
257
- : runtime.config.connections;
274
+ const connections = wanted ? all.filter((c) => `${c.provider}.${c.id}` === wanted) : all;
258
275
 
259
276
  if (wanted && connections.length === 0) {
260
277
  throw new Error(
261
- `No connection "${wanted}" in profile ${profile}. Run: ${forSelection('lanes link status')}`,
278
+ `No connection "${wanted}" in workspace ${target}. Run: ${forSelection('lanes link connection list')}`,
262
279
  );
263
280
  }
264
281
 
@@ -270,7 +287,7 @@ export async function auth(flags: AuthFlags): Promise<void> {
270
287
  // why it cannot read `doctor`. A connection needing a person is the answer
271
288
  // this command was asked for, not a failure to produce one.
272
289
  return emit(flags.json, { profile, target, ok: needsSomeone.length === 0, connections: results }, () => {
273
- announce(runtime.resolution);
290
+ announceWorkspace(runtime.resolution);
274
291
 
275
292
  for (const result of results) {
276
293
  const line = `${result.key} — ${describe(result.verdict)}`;
@@ -1,3 +1,4 @@
1
+ import { grantedConnections } from '../../runtime.ts';
1
2
  import { ownerPrincipal } from '#auth';
2
3
  import type { DiscoveredCapability } from '#connectivity';
3
4
  import { allowedConnections } from '#policy';
@@ -49,7 +50,7 @@ export async function reportCapabilityDrift(
49
50
  const principal = ownerPrincipal(runtime.config.instance.profile).id;
50
51
 
51
52
  for (const entry of runtime.registry.list()) {
52
- const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
53
+ const connection = grantedConnections(runtime).find((c) => c.provider === entry.manifest.id);
53
54
  if (!connection) continue;
54
55
 
55
56
  const connector = runtime.connectorFor(entry.manifest.id, connection.id);