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