@lanes-sh/link 0.9.1 → 0.9.3
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 +10 -4
- package/instructions/agents/lanes-link-scout.md +3 -3
- package/instructions/skills/lanes-link/SKILL.md +95 -57
- package/package.json +1 -1
- package/src/auth/index.ts +127 -26
- package/src/cli/accepts.ts +13 -3
- package/src/cli/commands/connect/declare.ts +16 -7
- package/src/cli/commands/connect/index.ts +4 -4
- package/src/cli/commands/connect/settle.ts +33 -7
- package/src/cli/commands/connection-list.ts +25 -1
- package/src/cli/commands/mcp/harnesses.ts +36 -22
- package/src/cli/commands/mcp/register.ts +63 -16
- package/src/cli/commands/mcp/stdio.ts +0 -1
- package/src/cli/commands/operate/inspect.ts +21 -10
- package/src/cli/commands/operate/outputs.ts +94 -61
- package/src/cli/commands/operate/serve.ts +0 -4
- package/src/cli/commands/operate/token.ts +305 -35
- package/src/cli/commands/operate/tools.ts +28 -5
- package/src/cli/commands/operate.ts +7 -1
- package/src/cli/commands/profile/removal.ts +10 -9
- package/src/cli/config-repair-sweep.ts +18 -4
- package/src/cli/config-repair.ts +1 -1
- package/src/cli/config-templates.ts +14 -7
- package/src/cli/contract3-credentials.ts +8 -8
- package/src/cli/contract4.ts +7 -2
- package/src/cli/contract5.ts +234 -0
- package/src/cli/endpoint-url.ts +17 -3
- package/src/cli/main.ts +28 -4
- package/src/cli/publish.ts +13 -4
- package/src/cli/runtime/open.ts +19 -2
- package/src/cli/runtime/select.ts +0 -12
- package/src/cli/runtime.ts +0 -1
- package/src/cli/selection.ts +40 -10
- package/src/cli/usage.ts +12 -7
- package/src/cli/workspace-migrate.ts +18 -12
- package/src/connectivity/context.ts +17 -0
- package/src/connectivity/manifest/provider.ts +9 -1
- package/src/deployments/adapters/audit-blob.ts +22 -1
- package/src/deployments/prepare.ts +8 -33
- package/src/deployments/report.ts +6 -3
- package/src/dispatch/context.ts +3 -0
- package/src/dispatch/dispatch.ts +5 -0
- package/src/profile/connections.ts +32 -0
- package/src/profile/index.ts +9 -0
- package/src/profile/schema.ts +47 -3
- package/src/profile/tokens.ts +137 -0
- package/src/profile/workspace.ts +1 -1
- package/src/providers/harness.ts +1 -0
- package/src/providers/setup/plan.ts +16 -0
- package/src/providers/setup/provider.ts +39 -12
- package/src/server/container.ts +3 -3
- package/src/server/endpoint.ts +17 -29
- package/src/server/harness.ts +28 -3
- package/src/server/index.ts +8 -8
- package/src/server/mcp/visibility.ts +11 -3
- package/src/server/read/deployed.ts +4 -0
- package/src/server/read/open.ts +4 -0
- package/src/server/read/routes.ts +15 -2
- package/src/server/read/state.ts +30 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createMcpConnector } from '#connectivity/transports';
|
|
2
2
|
import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
|
|
3
3
|
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { defaultConnectionLabel } from '#profile';
|
|
4
5
|
import type { ConnectionConfig, Config } from '#profile';
|
|
5
6
|
import type { AnyConnector, ProviderManifest } from '#connectivity';
|
|
6
7
|
import { nextConnectionId, resolveAccount } from '../../identity.ts';
|
|
@@ -46,7 +47,21 @@ export async function settleIdentity(input: {
|
|
|
46
47
|
authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
|
|
47
48
|
};
|
|
48
49
|
prompter?: Prompter;
|
|
49
|
-
}): Promise<{
|
|
50
|
+
}): Promise<{
|
|
51
|
+
connectionId: string;
|
|
52
|
+
account: string;
|
|
53
|
+
label: string;
|
|
54
|
+
/**
|
|
55
|
+
* What this row is called with nobody's word for it, carried to the writer.
|
|
56
|
+
*
|
|
57
|
+
* `declareConnection` writes no label equal to it, for the reason it never
|
|
58
|
+
* wrote one equal to the account: a line saying what the two lines above it
|
|
59
|
+
* say is a line to read past forever. Returned rather than derived twice, so
|
|
60
|
+
* the string the operator was offered and the string compared against it
|
|
61
|
+
* cannot come apart.
|
|
62
|
+
*/
|
|
63
|
+
defaultLabel: string;
|
|
64
|
+
}> {
|
|
50
65
|
const { manifest, provisionalId, explicitId, runtime } = input;
|
|
51
66
|
const prompter = input.prompter ?? terminalPrompter;
|
|
52
67
|
|
|
@@ -169,17 +184,20 @@ export async function settleIdentity(input: {
|
|
|
169
184
|
(candidate) => candidate.account.toLowerCase() === account!.toLowerCase(),
|
|
170
185
|
)?.id ?? nextConnectionId(taken, false)));
|
|
171
186
|
|
|
187
|
+
const defaultLabel = defaultConnectionLabel(manifest.name, account);
|
|
188
|
+
|
|
172
189
|
return {
|
|
173
190
|
connectionId,
|
|
174
191
|
account,
|
|
192
|
+
defaultLabel,
|
|
175
193
|
label: await settleLabel({
|
|
176
194
|
given: input.label,
|
|
195
|
+
fallback: defaultLabel,
|
|
177
196
|
// What the row this is about to land on is already called. Looked up
|
|
178
197
|
// across the whole vendor account rather than this provider alone, for the
|
|
179
198
|
// reason `accountSiblings` exists: `connect icloud_calendar` adopts iCloud
|
|
180
199
|
// Mail's id, and should adopt the name that goes with it too.
|
|
181
200
|
declared: siblings.find((candidate) => candidate.id === connectionId)?.label,
|
|
182
|
-
account,
|
|
183
201
|
typed,
|
|
184
202
|
prompter,
|
|
185
203
|
}),
|
|
@@ -198,21 +216,29 @@ export async function settleIdentity(input: {
|
|
|
198
216
|
* The suggestion is in the question and an empty answer takes it, so the cost of
|
|
199
217
|
* always asking is one keystroke. Nothing addresses a connection by its label,
|
|
200
218
|
* so there is no answer here that can break anything.
|
|
219
|
+
*
|
|
220
|
+
* **The suggestion is the provider and the account, not the account.** It was
|
|
221
|
+
* the address alone, which made the label a second copy of the field beside it
|
|
222
|
+
* — and left every surface that shows a name without one, because a default
|
|
223
|
+
* that only repeats another line is a default nothing writes down.
|
|
224
|
+
* `defaultConnectionLabel` is the whole rule and every reader derives the same
|
|
225
|
+
* string from it.
|
|
201
226
|
*/
|
|
202
227
|
async function settleLabel(input: {
|
|
203
228
|
given: string | undefined;
|
|
204
229
|
declared: string | undefined;
|
|
205
|
-
|
|
230
|
+
/** What this row is called when nobody says otherwise. */
|
|
231
|
+
fallback: string;
|
|
206
232
|
typed: boolean;
|
|
207
233
|
prompter: Prompter;
|
|
208
234
|
}): Promise<string> {
|
|
209
|
-
const { given, declared,
|
|
235
|
+
const { given, declared, fallback, typed, prompter } = input;
|
|
210
236
|
|
|
211
237
|
if (given) return given;
|
|
212
238
|
|
|
213
|
-
// A label already chosen wins over the
|
|
214
|
-
// credential does not quietly undo the operator's own word for the row.
|
|
215
|
-
const suggestion = declared ??
|
|
239
|
+
// A label already chosen wins over the derived one, so re-authorising an
|
|
240
|
+
// expired credential does not quietly undo the operator's own word for the row.
|
|
241
|
+
const suggestion = declared ?? fallback;
|
|
216
242
|
|
|
217
243
|
if (typed || !prompter.interactive) return suggestion;
|
|
218
244
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
|
+
import { PROVIDER_MANIFESTS } from '#providers/index.ts';
|
|
2
3
|
import {
|
|
3
4
|
connectionRefOf,
|
|
5
|
+
defaultConnectionLabel,
|
|
4
6
|
listProfiles,
|
|
5
7
|
loadProfileConfig,
|
|
6
8
|
readConnections,
|
|
7
9
|
resolveTargetWorkspace,
|
|
8
10
|
resolveWorkspaceRoot,
|
|
9
11
|
} from '#profile';
|
|
12
|
+
import { RESERVED_SURFACES } from '../config-repair.ts';
|
|
10
13
|
import { emit, heading, print, style, table } from '../output.ts';
|
|
11
14
|
import type { GlobalFlags } from '../runtime.ts';
|
|
12
15
|
|
|
@@ -104,6 +107,20 @@ export async function connectionList(flags: GlobalFlags & { json?: boolean }): P
|
|
|
104
107
|
});
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
/**
|
|
111
|
+
* What each provider is called, by id.
|
|
112
|
+
*
|
|
113
|
+
* The catalogue's manifests plus the owner layer, which is registered separately
|
|
114
|
+
* and is deliberately absent from `PROVIDERS`. A workspace-local manifest is not
|
|
115
|
+
* here: this command reads files rather than building a registry, and one custom
|
|
116
|
+
* provider falling back to its account is a smaller price than a registry build
|
|
117
|
+
* on a listing.
|
|
118
|
+
*/
|
|
119
|
+
const PROVIDER_NAMES = new Map<string, string>([
|
|
120
|
+
...PROVIDER_MANIFESTS.map((manifest): [string, string] => [manifest.id, manifest.name]),
|
|
121
|
+
...Object.entries(RESERVED_SURFACES),
|
|
122
|
+
]);
|
|
123
|
+
|
|
107
124
|
function row(one: ConnectionSummary): string[] {
|
|
108
125
|
// "granted to nobody" is said rather than left blank, because a blank column
|
|
109
126
|
// reads as "not loaded yet" and this is a fact about the config.
|
|
@@ -112,5 +129,12 @@ function row(one: ConnectionSummary): string[] {
|
|
|
112
129
|
? style.dim('no profile grants it')
|
|
113
130
|
: one.grantedTo.join(', ');
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
// The account was this column's fallback, which meant an unlabelled row read
|
|
133
|
+
// as its address and said nothing about which service the address is at. The
|
|
134
|
+
// derived name says both, and is the one the dashboard and the connect prompt
|
|
135
|
+
// show for the same row.
|
|
136
|
+
const named = PROVIDER_NAMES.get(one.provider);
|
|
137
|
+
const label = one.label ?? (named ? defaultConnectionLabel(named, one.account) : one.account);
|
|
138
|
+
|
|
139
|
+
return [` ${style.bold(one.key)}`, label, reach];
|
|
116
140
|
}
|
|
@@ -45,14 +45,17 @@ export interface AddInput {
|
|
|
45
45
|
readonly tokenEnv: string;
|
|
46
46
|
readonly scope: string;
|
|
47
47
|
/**
|
|
48
|
-
* Which
|
|
48
|
+
* Which workspace this registration is for.
|
|
49
49
|
*
|
|
50
|
-
* Not part of the URL —
|
|
51
|
-
*
|
|
52
|
-
* told to run afterwards do need it, and a `lanes link
|
|
53
|
-
* substitutes to nothing.
|
|
50
|
+
* Not part of the URL — one endpoint serves every profile in the workspace and
|
|
51
|
+
* each call names one in its `profile` argument. It is here because the shell
|
|
52
|
+
* commands a harness is told to run afterwards do need it, and a `lanes link
|
|
53
|
+
* token show` without it substitutes to nothing.
|
|
54
|
+
*
|
|
55
|
+
* There is no `profile` beside it any more (ADR-068). It was here for exactly
|
|
56
|
+
* one reader — the `export` line below — and what that line names is a token,
|
|
57
|
+
* which is the workspace's.
|
|
54
58
|
*/
|
|
55
|
-
readonly profile: string;
|
|
56
59
|
readonly target: string;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -142,14 +145,18 @@ export const HARNESSES: readonly Harness[] = [
|
|
|
142
145
|
scoped: false,
|
|
143
146
|
storesToken: false,
|
|
144
147
|
home: CODEX_HOME,
|
|
145
|
-
|
|
148
|
+
// The env var only under `--headless`, which is what the header comment
|
|
149
|
+
// above claims and this did not do: it was passed unconditionally, so every
|
|
150
|
+
// Codex registration was a token registration while `storesToken: false`
|
|
151
|
+
// said otherwise and `afterAdd` told the operator to export a variable an
|
|
152
|
+
// OAuth-capable client would never read.
|
|
153
|
+
add: ({ name, url, tokenEnv, token }) => [
|
|
146
154
|
'mcp',
|
|
147
155
|
'add',
|
|
148
156
|
name,
|
|
149
157
|
'--url',
|
|
150
158
|
url,
|
|
151
|
-
'--bearer-token-env-var',
|
|
152
|
-
tokenEnv,
|
|
159
|
+
...(token ? ['--bearer-token-env-var', tokenEnv] : []),
|
|
153
160
|
],
|
|
154
161
|
get: (name) => ['mcp', 'get', name],
|
|
155
162
|
remove: (name) => ['mcp', 'remove', name],
|
|
@@ -157,19 +164,26 @@ export const HARNESSES: readonly Harness[] = [
|
|
|
157
164
|
// installs to both unchanged. Codex has no subagent directory, so it gets
|
|
158
165
|
// the skill and not the scout — and `mcp add` says which it did.
|
|
159
166
|
skills: () => join(CODEX_HOME(), 'skills'),
|
|
160
|
-
afterAdd: ({ tokenEnv,
|
|
161
|
-
|
|
162
|
-
'
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
167
|
+
afterAdd: ({ tokenEnv, target, token }) =>
|
|
168
|
+
// Nothing to say unless a token is actually in play. Without `--headless`
|
|
169
|
+
// this registration is a bare URL like Claude Code's, and telling somebody
|
|
170
|
+
// to export a variable nothing reads was the instruction that made
|
|
171
|
+
// `storesToken: false` read as a contradiction.
|
|
172
|
+
token === undefined
|
|
173
|
+
? []
|
|
174
|
+
: [
|
|
175
|
+
`Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
|
|
176
|
+
'',
|
|
177
|
+
// `--workspace`, and this line is the reason it matters more here
|
|
178
|
+
// than anywhere else: an unresolvable substitution yields the empty
|
|
179
|
+
// string, the header becomes "Bearer ", and the only symptom is a
|
|
180
|
+
// 401 that reads as a bad token rather than a command that refused.
|
|
181
|
+
` export ${tokenEnv}="$(lanes link token show --raw --workspace ${target})"`,
|
|
182
|
+
'',
|
|
183
|
+
'Add that to your shell profile. This is the better half of the bargain: the token never',
|
|
184
|
+
'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
|
|
185
|
+
'with no re-registration.',
|
|
186
|
+
],
|
|
173
187
|
},
|
|
174
188
|
];
|
|
175
189
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { anyIssuedToken } from '#profile';
|
|
1
2
|
import { endpointUrl } from '../../endpoint-url.ts';
|
|
2
3
|
import { fail, ok, print, style, warn } from '../../output.ts';
|
|
3
|
-
import {
|
|
4
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
4
5
|
import { installFor } from './assets.ts';
|
|
5
6
|
import { HARNESSES, type AddInput, type Harness } from './harnesses.ts';
|
|
6
7
|
|
|
@@ -45,6 +46,41 @@ export interface McpAddOptions extends GlobalFlags {
|
|
|
45
46
|
readonly headless?: boolean | undefined;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* What to say about the token, which depends on whether one was actually stored.
|
|
51
|
+
*
|
|
52
|
+
* **`token`, not `harness.storesToken`.** The harness property says the client
|
|
53
|
+
* *can* hold a token; whether one was passed is a different question, and only
|
|
54
|
+
* the headless path passes one — the ordinary path registers the bare URL and
|
|
55
|
+
* lets the client discover the protected-resource document and run the
|
|
56
|
+
* authorization itself. Keyed on the property, this told an operator who had
|
|
57
|
+
* just registered against a deployed endpoint that "the token was stored" and
|
|
58
|
+
* that a rotate meant re-registering, when nothing had been stored and a rotate
|
|
59
|
+
* would not touch that entry at all.
|
|
60
|
+
*
|
|
61
|
+
* A separate function so the decision can be tested without spawning a client
|
|
62
|
+
* binary, which is the only reason the branch above it cannot be.
|
|
63
|
+
*/
|
|
64
|
+
export function tokenNote(
|
|
65
|
+
harness: { readonly storesToken: boolean; readonly label: string },
|
|
66
|
+
token: string | undefined,
|
|
67
|
+
): readonly string[] {
|
|
68
|
+
if (!harness.storesToken) return [];
|
|
69
|
+
|
|
70
|
+
if (token) {
|
|
71
|
+
return [
|
|
72
|
+
'The token was stored as a value, not a command, so "lanes link token rotate"',
|
|
73
|
+
'means running this again with --force.',
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
`No token was stored: ${harness.label} reads the endpoint's own`,
|
|
79
|
+
'protected-resource document and signs you in. A "lanes link token rotate"',
|
|
80
|
+
'does not affect this registration.',
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
export async function mcpAdd(target: string | undefined, options: McpAddOptions): Promise<void> {
|
|
49
85
|
// No harness named: every one that is actually installed. Registering with
|
|
50
86
|
// whatever is present is what someone means by "add my mcp", and naming one
|
|
@@ -79,13 +115,32 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
79
115
|
const scope = options.scope ?? 'user';
|
|
80
116
|
const tokenEnv = options.tokenEnv ?? 'LANES_LINK_TOKEN';
|
|
81
117
|
|
|
82
|
-
|
|
118
|
+
// **The workspace, not a profile** (ADR-068). One endpoint serves every
|
|
119
|
+
// profile in the workspace and each call names one in its `profile` argument,
|
|
120
|
+
// so a registration was never per-profile: two different `--profile` values
|
|
121
|
+
// produced byte-identical harness commands. What kept the flag required was
|
|
122
|
+
// that the endpoint's token lived at `auth.token_ref` on a profile — a ref
|
|
123
|
+
// whose default was the same constant for all of them — and reading it meant
|
|
124
|
+
// resolving one. The token is the workspace's now, so there is nothing left
|
|
125
|
+
// to ask. `--profile` is still accepted, and narrows nothing here.
|
|
126
|
+
const runtime = await openWorkspaceRuntime(options);
|
|
83
127
|
|
|
84
128
|
try {
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
129
|
+
// **Only for `--headless`, and only if one has been issued.** The ordinary
|
|
130
|
+
// path registers a bare URL and the client authorises itself (ADR-062).
|
|
131
|
+
// Nothing is minted here: a token names the person it was issued to, and
|
|
132
|
+
// `mcp add` does not know who — which is what `token issue` is for.
|
|
133
|
+
const token = options.headless === true
|
|
134
|
+
? (await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials))?.value
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
if (options.headless === true && token === undefined) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
'No static token is issued in this workspace, so --headless has nothing to write.\n' +
|
|
140
|
+
` Issue one: lanes link token issue --me --workspace ${runtime.target}\n` +
|
|
141
|
+
' Without --headless the client signs in for itself and needs none.',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
89
144
|
|
|
90
145
|
// The target's own address, not the local one. This built
|
|
91
146
|
// `http://<host>:<port>/mcp` unconditionally, so `mcp add --workspace cloud`
|
|
@@ -95,10 +150,9 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
95
150
|
const input: AddInput = {
|
|
96
151
|
name,
|
|
97
152
|
url,
|
|
98
|
-
...(
|
|
153
|
+
...(token === undefined ? {} : { token }),
|
|
99
154
|
tokenEnv,
|
|
100
155
|
scope,
|
|
101
|
-
profile: runtime.resolution.profile,
|
|
102
156
|
target: runtime.resolution.target,
|
|
103
157
|
};
|
|
104
158
|
|
|
@@ -174,14 +228,7 @@ async function register(
|
|
|
174
228
|
|
|
175
229
|
if (!options.noSkill) await installFor(harness, input.scope, {});
|
|
176
230
|
|
|
177
|
-
|
|
178
|
-
print(
|
|
179
|
-
style.dim(
|
|
180
|
-
' The token was stored as a value, not a command, so "lanes link token rotate"\n' +
|
|
181
|
-
' means running this again with --force.',
|
|
182
|
-
),
|
|
183
|
-
);
|
|
184
|
-
}
|
|
231
|
+
for (const line of tokenNote(harness, input.token)) print(style.dim(` ${line}`));
|
|
185
232
|
|
|
186
233
|
const after = harness.afterAdd?.(input);
|
|
187
234
|
if (after) {
|
|
@@ -48,7 +48,6 @@ export async function mcpStdio(
|
|
|
48
48
|
reconciled: ({ profile, plan, ofMany }) =>
|
|
49
49
|
printErr(`${ofMany ? `${profile}\n` : ''}${plan}`),
|
|
50
50
|
// Unreachable: this path never mints a token, because it never needs one.
|
|
51
|
-
tokenMinted: () => {},
|
|
52
51
|
},
|
|
53
52
|
log: {
|
|
54
53
|
debug() {},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
|
-
import type
|
|
2
|
+
import { readEndpointTokens, type ConnectionConfig, type SelectedConnection } from '#profile';
|
|
3
3
|
import { DEFAULT_SURFACES } from '../../config-repair.ts';
|
|
4
4
|
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
5
5
|
import { staleNudge } from '../../release.ts';
|
|
@@ -120,16 +120,27 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
|
|
|
120
120
|
checks.push('config is valid');
|
|
121
121
|
checks.push('state store is reachable');
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
// **A row whose value is missing, not a missing token** (ADR-068). Having
|
|
124
|
+
// issued none is the healthy default — a client signs in for itself — so
|
|
125
|
+
// reporting that as a problem would make every working workspace fail
|
|
126
|
+
// doctor. What is genuinely broken is a row pointing at nothing: it matches
|
|
127
|
+
// no credential, and from the client's side reads exactly like a wrong
|
|
128
|
+
// token. That is what a half-finished `secrets push` leaves behind.
|
|
129
|
+
const issued = await readEndpointTokens(runtime.resolution.workspaceRoot);
|
|
130
|
+
const orphaned: string[] = [];
|
|
131
|
+
for (const row of issued) {
|
|
132
|
+
if ((await runtime.credentials.get(row.ref)) === null) orphaned.push(row.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (issued.length > 0 && orphaned.length === 0) {
|
|
136
|
+
checks.push(`${issued.length} endpoint token(s) present`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const id of orphaned) {
|
|
129
140
|
warnings.push({
|
|
130
|
-
kind: '
|
|
131
|
-
message:
|
|
132
|
-
fix:
|
|
141
|
+
kind: 'orphaned_endpoint_token',
|
|
142
|
+
message: `token "${id}" has a row but no value in this workspace's store — it matches nothing`,
|
|
143
|
+
fix: `lanes link token rotate --id ${id} --workspace ${runtime.resolution.target}`,
|
|
133
144
|
});
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { listProfiles } from '#profile';
|
|
1
|
+
import { anyIssuedToken, listProfiles } from '#profile';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { announceWorkspace, heading, print, style, warn } from '../../output.ts';
|
|
5
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
6
6
|
|
|
7
7
|
export interface OutputsFlags extends GlobalFlags {
|
|
8
8
|
readonly show?: boolean | undefined;
|
|
@@ -23,18 +23,27 @@ export interface OutputsFlags extends GlobalFlags {
|
|
|
23
23
|
* the harness's business.
|
|
24
24
|
*/
|
|
25
25
|
export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
26
|
-
|
|
26
|
+
// The workspace, matching what this command's own subject has always been
|
|
27
|
+
// (ADR-068). It asked for a profile only to find the endpoint's token, and
|
|
28
|
+
// that token is the workspace's.
|
|
29
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
27
30
|
|
|
28
31
|
try {
|
|
29
|
-
|
|
32
|
+
// Whatever has been issued, or nothing — which is the ordinary state.
|
|
33
|
+
// Nothing is minted: `outputs` reports, and a token names a person.
|
|
34
|
+
const held = await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials);
|
|
35
|
+
const token = held?.value;
|
|
30
36
|
const declared = runtime.declared.deploy;
|
|
31
37
|
const deployed = await deployedUrl(declared);
|
|
32
38
|
// Not `endpointUrl`, which would ask the platform a second time for an
|
|
33
39
|
// answer this line already has.
|
|
34
40
|
const url = deployed ?? localUrl(runtime.config);
|
|
35
41
|
|
|
42
|
+
// Unauthenticated when nothing is issued. `/health` answers either way; what
|
|
43
|
+
// it withholds without a credential is the profile list, so `mine` below is
|
|
44
|
+
// then decided by the endpoint answering at all.
|
|
36
45
|
const live = await endpointHealth(url, token);
|
|
37
|
-
const mine = live
|
|
46
|
+
const mine = live !== null;
|
|
38
47
|
|
|
39
48
|
// Live if it is up, otherwise every profile in this target's workspace.
|
|
40
49
|
// Those are the same set now: a profile lives in exactly one target
|
|
@@ -51,9 +60,8 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
51
60
|
running: mine,
|
|
52
61
|
deployed: deployed !== null,
|
|
53
62
|
target: runtime.target,
|
|
54
|
-
primary: runtime.resolution.profile,
|
|
55
63
|
profiles,
|
|
56
|
-
...(flags.show ? { token } : {}),
|
|
64
|
+
...(flags.show && token !== undefined ? { token } : {}),
|
|
57
65
|
},
|
|
58
66
|
null,
|
|
59
67
|
2,
|
|
@@ -62,7 +70,7 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
62
70
|
return;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
announceWorkspace(runtime.resolution);
|
|
66
74
|
|
|
67
75
|
heading('Endpoint');
|
|
68
76
|
print(
|
|
@@ -75,21 +83,19 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
75
83
|
print(style.dim(` ${declared.platform} service "${declared.service}" for target "${runtime.target}".`));
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
print(style.dim(' Each call names one, in its `profile` argument.'));
|
|
86
|
+
heading(`Profiles served by it (${profiles.length})`);
|
|
87
|
+
for (const profile of profiles) print(` ${profile}`);
|
|
88
|
+
print(
|
|
89
|
+
style.dim(
|
|
90
|
+
' Each call names one, in its `profile` argument. Which of these a client\n' +
|
|
91
|
+
' actually reaches is decided by who signs in — every profile whose members\n' +
|
|
92
|
+
' list them, and no others.',
|
|
93
|
+
),
|
|
94
|
+
);
|
|
89
95
|
|
|
90
|
-
if (flags.show) {
|
|
96
|
+
if (flags.show && token !== undefined) {
|
|
91
97
|
heading('Token');
|
|
92
|
-
print(` ${token}`);
|
|
98
|
+
print(` ${token} ${style.dim(`(${held?.id})`)}`);
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
heading('Register with your agent');
|
|
@@ -100,36 +106,59 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
100
106
|
);
|
|
101
107
|
print('');
|
|
102
108
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
` --header "Authorization: Bearer $(${invocation.command})"`,
|
|
112
|
-
);
|
|
109
|
+
// **The bare URL, and no header** (ADR-062). This printed the
|
|
110
|
+
// `Authorization: Bearer $(…)` form unconditionally, which was the shape
|
|
111
|
+
// before every endpoint ran the authorization flow — a registration that
|
|
112
|
+
// carries a credential, bypasses consent and expiry, and leaves a long-lived
|
|
113
|
+
// token in a harness config. The client discovers
|
|
114
|
+
// `/.well-known/oauth-protected-resource` from the 401 and signs its owner
|
|
115
|
+
// in instead.
|
|
116
|
+
print(` claude mcp add --transport http lanes-link ${url}`);
|
|
113
117
|
print('');
|
|
114
|
-
|
|
115
|
-
if (!invocation.onPath) {
|
|
116
|
-
// The failure this prevents is nasty: an unresolvable command substitutes
|
|
117
|
-
// to the empty string, the header becomes "Bearer ", and the only symptom
|
|
118
|
-
// is a 401 that looks like a bad token rather than a missing binary.
|
|
119
|
-
print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
|
|
120
|
-
print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
|
|
121
|
-
print(` cd ${process.cwd()} && bun link`);
|
|
122
|
-
print('');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
118
|
print(
|
|
126
119
|
style.dim(
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
120
|
+
' No credential goes into that command. The client reads this endpoint\'s\n' +
|
|
121
|
+
' protected-resource document, sends its owner to sign in, and comes back\n' +
|
|
122
|
+
' holding a token of its own — so a config file synced to a dotfiles repo\n' +
|
|
123
|
+
' is not a leak, and rotating a static token does not invalidate it.',
|
|
131
124
|
),
|
|
132
125
|
);
|
|
126
|
+
|
|
127
|
+
heading('For a machine with no browser');
|
|
128
|
+
if (token === undefined) {
|
|
129
|
+
print(style.dim(' No static token is issued in this workspace.'));
|
|
130
|
+
print(
|
|
131
|
+
style.dim(
|
|
132
|
+
` lanes link token issue --me --workspace ${runtime.target}\n` +
|
|
133
|
+
' It reaches the profiles that list your subject as a member, and nothing else.',
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
const invocation = await tokenInvocation(runtime.resolution.target);
|
|
138
|
+
print(
|
|
139
|
+
` claude mcp add --transport http lanes-link ${url} \\\n` +
|
|
140
|
+
` --header "Authorization: Bearer $(${invocation.command})"`,
|
|
141
|
+
);
|
|
142
|
+
print('');
|
|
143
|
+
if (!invocation.onPath) {
|
|
144
|
+
// The failure this prevents is nasty: an unresolvable command
|
|
145
|
+
// substitutes to the empty string, the header becomes "Bearer ", and
|
|
146
|
+
// the only symptom is a 401 that looks like a bad token rather than a
|
|
147
|
+
// missing binary.
|
|
148
|
+
print(warn('lanes is not on your PATH, so the short form would substitute to nothing.'));
|
|
149
|
+
print(style.dim(' The command above uses this checkout instead. To shorten it permanently:'));
|
|
150
|
+
print(` cd ${process.cwd()} && bun link`);
|
|
151
|
+
print('');
|
|
152
|
+
}
|
|
153
|
+
print(
|
|
154
|
+
style.dim(
|
|
155
|
+
' CI only, and it is narrower than it looks: the token reaches the profiles\n' +
|
|
156
|
+
' its subject is a member of. The $(…) keeps it out of your agent\'s context\n' +
|
|
157
|
+
' and out of the transcript, but it resolves once and is stored as a literal —\n' +
|
|
158
|
+
' so a rotate means registering again.',
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
133
162
|
} finally {
|
|
134
163
|
await runtime.close();
|
|
135
164
|
}
|
|
@@ -146,28 +175,32 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
146
175
|
* checkout, which always works.
|
|
147
176
|
*/
|
|
148
177
|
export async function tokenInvocation(
|
|
149
|
-
expected: string,
|
|
150
|
-
profile: string,
|
|
151
178
|
target: string,
|
|
152
179
|
): Promise<{ command: string; onPath: boolean }> {
|
|
153
|
-
//
|
|
154
|
-
// token is per-
|
|
155
|
-
// `token show --raw` hands over the local one beside a deployed URL — a
|
|
156
|
-
// credential that looks like an answer and fails as a wrong password.
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
180
|
+
// `--workspace`, always, and from the *resolved* selection rather than the
|
|
181
|
+
// flags. A token is per-workspace, so `outputs --workspace cloud` printing a
|
|
182
|
+
// bare `token show --raw` hands over the local one beside a deployed URL — a
|
|
183
|
+
// credential that looks like an answer and fails as a wrong password.
|
|
184
|
+
//
|
|
185
|
+
// No `--profile` any more (ADR-068): `token show` refuses one, so printing it
|
|
186
|
+
// here would emit a line that cannot be pasted.
|
|
187
|
+
const selection = ` --workspace ${target}`;
|
|
160
188
|
const short = `lanes link token show --raw${selection}`;
|
|
161
|
-
const argv = ['link', 'token', 'show', '--raw', '--
|
|
189
|
+
const argv = ['link', 'token', 'show', '--raw', '--workspace', target];
|
|
162
190
|
|
|
163
191
|
const resolved = Bun.which('lanes');
|
|
164
192
|
if (resolved) {
|
|
165
193
|
try {
|
|
166
194
|
const result = Bun.spawnSync([resolved, ...argv]);
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
195
|
+
// Exit status and a plausible token, rather than a comparison against a
|
|
196
|
+
// known value. This used to be handed the expected token and check for
|
|
197
|
+
// equality, which caught a `lanes` on PATH belonging to a different
|
|
198
|
+
// workspace. It cannot now: with several rows issued, `token show`
|
|
199
|
+
// refuses without `--id` — so demanding one value back would reject a
|
|
200
|
+
// correctly-installed binary. What survives is the check that matters for
|
|
201
|
+
// the failure this function exists to prevent, which is a substitution
|
|
202
|
+
// that yields nothing at all.
|
|
203
|
+
if (result.success && new TextDecoder().decode(result.stdout).trim().startsWith('llk_')) {
|
|
171
204
|
return { command: short, onPath: true };
|
|
172
205
|
}
|
|
173
206
|
} catch {
|
|
@@ -61,7 +61,6 @@ export async function start(
|
|
|
61
61
|
flags: resolved,
|
|
62
62
|
port: flags.port,
|
|
63
63
|
only: flags.only,
|
|
64
|
-
mintToken: true,
|
|
65
64
|
// Stderr, not stdout: `--json` and `--raw` callers parse the other stream.
|
|
66
65
|
// A refused credential is the event worth seeing while this runs in the
|
|
67
66
|
// foreground, and until now nothing printed it.
|
|
@@ -72,9 +71,6 @@ export async function start(
|
|
|
72
71
|
print(plan);
|
|
73
72
|
print(ok(`reconciled ${ofMany ? profile : ''}`.trim()));
|
|
74
73
|
},
|
|
75
|
-
tokenMinted({ target }) {
|
|
76
|
-
print(warn(`minted a token — run: lanes link outputs --show --workspace ${target}`));
|
|
77
|
-
},
|
|
78
74
|
},
|
|
79
75
|
});
|
|
80
76
|
|