@lanes-sh/link 0.9.2 → 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/skills/lanes-link/SKILL.md +73 -48
- 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 +27 -8
- 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/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,70 +1,340 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { CONNECTIONS_FILE, nextTokenId, readConnections, tokenRef } from '#profile';
|
|
2
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
3
|
+
import { ConfigError } from '#profile';
|
|
4
|
+
import { ConfigDocument } from '../../config-edit.ts';
|
|
5
|
+
import { openOrCreateConnections } from '../../config-repair-sweep.ts';
|
|
6
|
+
import { heading, ok, print, style, warn } from '../../output.ts';
|
|
7
|
+
import { openWorkspaceRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
3
8
|
|
|
4
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* `lanes link token` — the static tokens this workspace has issued.
|
|
11
|
+
*
|
|
12
|
+
* **A token names a person, and the workspace holds it** (ADR-068). Both halves
|
|
13
|
+
* changed at contract 5. It used to be one credential at `auth.token_ref`,
|
|
14
|
+
* whose default was the constant `profile/token` for every profile in a store
|
|
15
|
+
* that is one per workspace — so `--profile` was asked for and could not affect
|
|
16
|
+
* the answer, and removing a profile deleted the token its siblings were served
|
|
17
|
+
* by. And it resolved to the primary profile's owner with "every profile" as
|
|
18
|
+
* its reach, which made it the one credential on this endpoint that never said
|
|
19
|
+
* who was holding it.
|
|
20
|
+
*
|
|
21
|
+
* So `issue` takes a subject and `show`/`rotate`/`revoke` take a row id. What
|
|
22
|
+
* the token then reaches is whatever that subject is a member of, resolved on
|
|
23
|
+
* every call — the same rule an OAuth token has followed since ADR-060.
|
|
24
|
+
*
|
|
25
|
+
* These are for a machine with no browser. A person registers a client against
|
|
26
|
+
* the bare URL and signs in (ADR-062); `--headless` is the flag that says
|
|
27
|
+
* otherwise, and it is why this family still exists.
|
|
28
|
+
*/
|
|
5
29
|
|
|
6
|
-
|
|
30
|
+
export interface TokenFlags extends GlobalFlags {
|
|
31
|
+
readonly show?: boolean | undefined;
|
|
32
|
+
readonly raw?: boolean | undefined;
|
|
33
|
+
readonly id?: string | undefined;
|
|
34
|
+
readonly subject?: string | undefined;
|
|
35
|
+
readonly me?: boolean | undefined;
|
|
36
|
+
readonly label?: string | undefined;
|
|
37
|
+
readonly json?: boolean | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The token, or enough of it to recognise. One shape, so every command agrees. */
|
|
7
41
|
function show(token: string, reveal: boolean | undefined): string {
|
|
8
42
|
return reveal ? token : `${token.slice(0, 8)}… ${style.dim('(--show to reveal)')}`;
|
|
9
43
|
}
|
|
10
44
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Which subject a row is being issued to.
|
|
47
|
+
*
|
|
48
|
+
* `--me` reads the signed-in session rather than asking, because typing your own
|
|
49
|
+
* subject out of `lanes auth status` is a transcription step with a silent
|
|
50
|
+
* failure mode: a mistyped subject is a valid-looking row that matches no
|
|
51
|
+
* member, and the token it holds then reaches nothing for a reason nothing
|
|
52
|
+
* prints. Neither flag is a guess — one of the two is required.
|
|
53
|
+
*/
|
|
54
|
+
async function subjectFor(flags: TokenFlags): Promise<string> {
|
|
55
|
+
if (flags.subject !== undefined && flags.me === true) {
|
|
56
|
+
throw new ConfigError(
|
|
57
|
+
'--subject and --me both name who the token is for, so pass one.\n' +
|
|
58
|
+
' --me reads the subject you are signed in as.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (flags.subject !== undefined) return flags.subject;
|
|
63
|
+
|
|
64
|
+
if (flags.me !== true) {
|
|
65
|
+
throw new ConfigError(
|
|
66
|
+
'A token is issued to somebody, so say who.\n' +
|
|
67
|
+
' --me the subject you are signed in as\n' +
|
|
68
|
+
' --subject lanes:<id> somebody else, as "lanes members list" reports them\n' +
|
|
69
|
+
' What it reaches is whatever that subject is a member of, and nothing else.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const session = await readSession().catch(() => null);
|
|
74
|
+
if (!session?.subject) {
|
|
75
|
+
throw new ConfigError(
|
|
76
|
+
'--me needs a signed-in session, and there is none.\n' +
|
|
77
|
+
' Run: lanes auth login',
|
|
19
78
|
);
|
|
79
|
+
}
|
|
80
|
+
return session.subject;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refuse a `--profile` that can no longer mean anything.
|
|
85
|
+
*
|
|
86
|
+
* The alternative is accepting and ignoring it, which `selection.ts` names as
|
|
87
|
+
* the defect it exists to prevent: an operator who passes `--profile work` here
|
|
88
|
+
* believes they scoped the token to one profile, and it is the member list that
|
|
89
|
+
* decides. Saying so is the whole fix.
|
|
90
|
+
*/
|
|
91
|
+
function assertNoProfile(flags: TokenFlags, command: string): void {
|
|
92
|
+
if (flags.profile === undefined) return;
|
|
93
|
+
throw new ConfigError(
|
|
94
|
+
`--profile does not scope "lanes link token ${command}" (ADR-068).\n` +
|
|
95
|
+
' A token names the person it was issued to, and reaches every profile whose\n' +
|
|
96
|
+
' members list them. To narrow what one reaches, edit the member lists:\n' +
|
|
97
|
+
' lanes link profile members remove --subject <id> --profile <name> --workspace <name>',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function tokenIssue(flags: TokenFlags): Promise<void> {
|
|
102
|
+
assertNoProfile(flags, 'issue');
|
|
103
|
+
const subject = await subjectFor(flags);
|
|
104
|
+
|
|
105
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
106
|
+
try {
|
|
107
|
+
const root = runtime.resolution.workspaceRoot;
|
|
108
|
+
const existing = (await readConnections(root)).tokens;
|
|
109
|
+
const id = nextTokenId(existing.map((row) => row.id));
|
|
110
|
+
const ref = tokenRef(id);
|
|
111
|
+
|
|
112
|
+
const { generateProfileToken } = await import('#auth');
|
|
113
|
+
const token = generateProfileToken();
|
|
114
|
+
|
|
115
|
+
// The credential first, then the row. The other order leaves a row naming a
|
|
116
|
+
// ref with nothing behind it, which reads to the authenticator as a token
|
|
117
|
+
// that matches nothing — a working-looking registry whose token is refused.
|
|
118
|
+
await runtime.credentials.set(ref, token);
|
|
119
|
+
|
|
120
|
+
// Created from the template if absent, which is what a workspace whose
|
|
121
|
+
// profiles predate `connections.yaml` looks like — and `token issue` is a
|
|
122
|
+
// plausible first write into a fresh one.
|
|
123
|
+
const document = await openOrCreateConnections(root);
|
|
124
|
+
document.addTo(['tokens'], {
|
|
125
|
+
id,
|
|
126
|
+
subject,
|
|
127
|
+
ref,
|
|
128
|
+
...(flags.label === undefined ? {} : { label: flags.label }),
|
|
129
|
+
issued_at: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
await document.save();
|
|
132
|
+
|
|
133
|
+
print(ok(`issued ${style.bold(id)} to ${subject}`));
|
|
134
|
+
print(` ${show(token, flags.show)}`);
|
|
135
|
+
print();
|
|
136
|
+
|
|
137
|
+
const reaches = await profilesFor(root, subject);
|
|
138
|
+
if (reaches.length === 0) {
|
|
139
|
+
print(warn('no profile in this workspace lists that subject, so this token reaches nothing'));
|
|
140
|
+
print(
|
|
141
|
+
style.dim(
|
|
142
|
+
' Add them: lanes link profile members add --subject ' +
|
|
143
|
+
`${subject} --profile <name> --workspace ${runtime.target}`,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
print(style.dim(` Reaches: ${reaches.join(', ')} — every profile listing that subject.`));
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
await runtime.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function tokenList(flags: TokenFlags): Promise<void> {
|
|
155
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
156
|
+
try {
|
|
157
|
+
const root = runtime.resolution.workspaceRoot;
|
|
158
|
+
const rows = (await readConnections(root)).tokens;
|
|
159
|
+
|
|
160
|
+
if (flags.json) {
|
|
161
|
+
const payload = await Promise.all(
|
|
162
|
+
rows.map(async (row) => ({
|
|
163
|
+
id: row.id,
|
|
164
|
+
subject: row.subject,
|
|
165
|
+
...(row.label === undefined ? {} : { label: row.label }),
|
|
166
|
+
...(row.issued_at === undefined ? {} : { issued_at: row.issued_at }),
|
|
167
|
+
reaches: await profilesFor(root, row.subject),
|
|
168
|
+
present: (await runtime.credentials.get(row.ref)) !== null,
|
|
169
|
+
})),
|
|
170
|
+
);
|
|
171
|
+
print(JSON.stringify({ target: runtime.target, tokens: payload }, null, 2));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (rows.length === 0) {
|
|
176
|
+
print('No token has been issued in this workspace.');
|
|
177
|
+
print(
|
|
178
|
+
style.dim(
|
|
179
|
+
' A person does not need one: a client registers against the bare URL and signs in.\n' +
|
|
180
|
+
` For a machine with no browser: lanes link token issue --me --workspace ${runtime.target}`,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
heading(`Tokens (${rows.length})`);
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
const reaches = await profilesFor(root, row.subject);
|
|
189
|
+
// A row whose credential is gone matches nothing, and reads exactly like a
|
|
190
|
+
// wrong token from the client's side. Naming it here is the cheap half of
|
|
191
|
+
// what `doctor` says at length.
|
|
192
|
+
const missing = (await runtime.credentials.get(row.ref)) === null;
|
|
193
|
+
print(
|
|
194
|
+
` ${row.id} ${row.subject}` +
|
|
195
|
+
(row.label ? ` ${style.dim(`(${row.label})`)}` : '') +
|
|
196
|
+
` ${reaches.length > 0 ? reaches.join(', ') : style.dim('reaches nothing')}` +
|
|
197
|
+
(missing ? ` ${style.dim('— value missing from the store')}` : ''),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
print(style.dim(' What each reaches is every profile whose members list its subject.'));
|
|
201
|
+
} finally {
|
|
202
|
+
await runtime.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function tokenShow(flags: TokenFlags): Promise<void> {
|
|
207
|
+
assertNoProfile(flags, 'show');
|
|
208
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
209
|
+
try {
|
|
210
|
+
const root = runtime.resolution.workspaceRoot;
|
|
211
|
+
const row = pick((await readConnections(root)).tokens, flags, runtime.target);
|
|
212
|
+
const token = await runtime.credentials.get(row.ref);
|
|
213
|
+
|
|
214
|
+
if (token === null) {
|
|
215
|
+
throw new ConfigError(
|
|
216
|
+
`Token "${row.id}" has a row but no value at "${row.ref}" in this workspace's store.\n` +
|
|
217
|
+
` Re-mint it: lanes link token rotate --id ${row.id} --workspace ${runtime.target}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
20
220
|
|
|
21
221
|
// `--raw` prints the token and nothing else, for command substitution:
|
|
22
222
|
//
|
|
23
|
-
//
|
|
223
|
+
// export LANES_LINK_TOKEN="$(lanes link token show --raw --workspace local)"
|
|
24
224
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// this process to the harness and is never seen by the model at all.
|
|
225
|
+
// A token pasted from `--show` passes through the agent's context and into
|
|
226
|
+
// its transcript; substituted by the shell it goes from this process to the
|
|
227
|
+
// consumer and is never seen by the model at all.
|
|
29
228
|
if (flags.raw) {
|
|
30
229
|
process.stdout.write(`${token}\n`);
|
|
31
230
|
return;
|
|
32
231
|
}
|
|
33
232
|
|
|
34
|
-
|
|
35
|
-
if (created) print(warn('no token existed; a new one was minted'));
|
|
233
|
+
print(`${row.id} ${row.subject}`);
|
|
36
234
|
print(show(token, flags.show));
|
|
37
235
|
} finally {
|
|
38
236
|
await runtime.close();
|
|
39
237
|
}
|
|
40
238
|
}
|
|
41
239
|
|
|
42
|
-
export async function tokenRotate(
|
|
43
|
-
flags
|
|
44
|
-
|
|
45
|
-
const runtime = await openRuntime(flags);
|
|
240
|
+
export async function tokenRotate(flags: TokenFlags): Promise<void> {
|
|
241
|
+
assertNoProfile(flags, 'rotate');
|
|
242
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
46
243
|
try {
|
|
47
|
-
|
|
244
|
+
const root = runtime.resolution.workspaceRoot;
|
|
245
|
+
const row = pick((await readConnections(root)).tokens, flags, runtime.target);
|
|
48
246
|
|
|
49
247
|
const { generateProfileToken } = await import('#auth');
|
|
50
248
|
const token = generateProfileToken();
|
|
51
|
-
await runtime.credentials.set(
|
|
249
|
+
await runtime.credentials.set(row.ref, token);
|
|
52
250
|
|
|
53
|
-
print(ok(
|
|
54
|
-
// Gated the way `tokenShow` gates it, and for the reason
|
|
55
|
-
//
|
|
251
|
+
print(ok(`rotated ${style.bold(row.id)}`));
|
|
252
|
+
// Gated the way `tokenShow` gates it, and for the same reason: a token
|
|
253
|
+
// printed here goes into the transcript of whatever ran the command.
|
|
56
254
|
// Rotating is what an operator does *because* a token leaked, so printing
|
|
57
255
|
// the replacement unasked is the one moment it costs the most.
|
|
58
256
|
print(` ${show(token, flags.show)}`);
|
|
59
257
|
print();
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
print(warn(
|
|
65
|
-
print(style.dim('
|
|
66
|
-
print(style.dim(' Run: lanes link outputs with this profile and target for the command to re-run.'));
|
|
258
|
+
// Narrower than it used to be, and worth saying so. A rotate used to
|
|
259
|
+
// invalidate every agent on the endpoint, because there was one token and
|
|
260
|
+
// every registration carried it. Registrations do not carry one now
|
|
261
|
+
// (ADR-062), so this affects only what was given *this* row's value.
|
|
262
|
+
print(warn(`anything holding ${row.id} must be given the new value`));
|
|
263
|
+
print(style.dim(' Clients that signed in through a browser are unaffected — they hold their own tokens.'));
|
|
67
264
|
} finally {
|
|
68
265
|
await runtime.close();
|
|
69
266
|
}
|
|
70
267
|
}
|
|
268
|
+
|
|
269
|
+
export async function tokenRevoke(flags: TokenFlags): Promise<void> {
|
|
270
|
+
assertNoProfile(flags, 'revoke');
|
|
271
|
+
const runtime = await openWorkspaceRuntime(flags);
|
|
272
|
+
try {
|
|
273
|
+
const root = runtime.resolution.workspaceRoot;
|
|
274
|
+
const rows = (await readConnections(root)).tokens;
|
|
275
|
+
const row = pick(rows, flags, runtime.target);
|
|
276
|
+
|
|
277
|
+
// The row first, then the credential. This is the reverse of `issue` and
|
|
278
|
+
// for the same reason read the other way: what must never survive a partial
|
|
279
|
+
// failure is a *usable* token, so the thing that makes it usable goes last.
|
|
280
|
+
const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
|
|
281
|
+
document.removeFrom(['tokens'], rows.indexOf(row));
|
|
282
|
+
await document.save();
|
|
283
|
+
|
|
284
|
+
await runtime.credentials.delete(row.ref);
|
|
285
|
+
|
|
286
|
+
print(ok(`revoked ${style.bold(row.id)}`));
|
|
287
|
+
print(style.dim(' It is refused within the authenticator\'s cache window, which is seconds.'));
|
|
288
|
+
} finally {
|
|
289
|
+
await runtime.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The row a command acts on.
|
|
295
|
+
*
|
|
296
|
+
* With one row and no `--id`, that row: naming it would be ceremony, and the
|
|
297
|
+
* common workspace has exactly one. With several, it refuses and lists them —
|
|
298
|
+
* the `deploy` rule, for the same reason. Nothing is chosen from among
|
|
299
|
+
* candidates.
|
|
300
|
+
*/
|
|
301
|
+
function pick(
|
|
302
|
+
rows: readonly { id: string; subject: string; ref: string }[],
|
|
303
|
+
flags: TokenFlags,
|
|
304
|
+
target: string,
|
|
305
|
+
): { id: string; subject: string; ref: string } {
|
|
306
|
+
if (rows.length === 0) {
|
|
307
|
+
throw new ConfigError(
|
|
308
|
+
'No token has been issued in this workspace.\n' +
|
|
309
|
+
` Issue one: lanes link token issue --me --workspace ${target}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (flags.id !== undefined) {
|
|
314
|
+
const found = rows.find((row) => row.id === flags.id);
|
|
315
|
+
if (!found) {
|
|
316
|
+
throw new ConfigError(
|
|
317
|
+
`No token "${flags.id}" in this workspace. Have: ${rows.map((row) => row.id).join(', ')}.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return found;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (rows.length > 1) {
|
|
324
|
+
throw new ConfigError(
|
|
325
|
+
`This workspace has ${rows.length} tokens, so this command needs --id.\n` +
|
|
326
|
+
rows.map((row) => ` ${row.id} ${row.subject}`).join('\n'),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return rows[0]!;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Every profile in this workspace whose `members:` names a subject. */
|
|
334
|
+
async function profilesFor(root: string, subject: string): Promise<string[]> {
|
|
335
|
+
const { loadWorkspaceProfiles } = await import('#profile');
|
|
336
|
+
const { loaded } = await loadWorkspaceProfiles(root);
|
|
337
|
+
return loaded
|
|
338
|
+
.filter((entry) => entry.config.members.some((member) => member.subject === subject))
|
|
339
|
+
.map((entry) => entry.profile);
|
|
340
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { capabilityIdForToolName } from '#server/mcp';
|
|
2
|
+
import { anyIssuedToken } from '#profile';
|
|
2
3
|
import { deployedUrl, endpointHealth, localUrl } from '../../endpoint-url.ts';
|
|
3
4
|
import { announce, emit, heading, print, style, warn } from '../../output.ts';
|
|
4
|
-
import {
|
|
5
|
+
import { openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
5
6
|
|
|
6
7
|
export interface ToolsFlags extends GlobalFlags {
|
|
7
8
|
readonly json?: boolean | undefined;
|
|
@@ -27,7 +28,12 @@ export async function tools(flags: ToolsFlags): Promise<void> {
|
|
|
27
28
|
const runtime = await openRuntime(flags);
|
|
28
29
|
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
+
// Any token the workspace holds, or none (ADR-068). `tools` reports a
|
|
32
|
+
// profile's resolved surface, so it still names one — what it no longer
|
|
33
|
+
// does is mint a credential in order to read.
|
|
34
|
+
const token = (
|
|
35
|
+
await anyIssuedToken(runtime.resolution.workspaceRoot, runtime.credentials)
|
|
36
|
+
)?.value;
|
|
31
37
|
const declared = runtime.declared.deploy;
|
|
32
38
|
const deployed = await deployedUrl(declared);
|
|
33
39
|
// Not `endpointUrl`, which asks the platform a second time for an answer
|
|
@@ -40,9 +46,26 @@ export async function tools(flags: ToolsFlags): Promise<void> {
|
|
|
40
46
|
// token that works. Reporting that as the deployed endpoint's surface is
|
|
41
47
|
// the failure `endpoint-url.ts` calls "silent in the worst way".
|
|
42
48
|
const live = await endpointHealth(url, token);
|
|
43
|
-
const mine =
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
const mine =
|
|
50
|
+
token === undefined ? live !== null : live?.profile === runtime.resolution.profile;
|
|
51
|
+
|
|
52
|
+
// **Nothing to ask with** is a reportable state rather than a failure
|
|
53
|
+
// (ADR-068). Asking the endpoint for its tool list means being a client of
|
|
54
|
+
// it, and a client authenticates; a workspace that has issued no static
|
|
55
|
+
// token has nothing this command can present, and minting one would bind a
|
|
56
|
+
// credential to a subject nobody chose. What is still true without it is
|
|
57
|
+
// everything on the left of the comparison — the capabilities this
|
|
58
|
+
// *config* resolves to — which is most of what the command is for.
|
|
59
|
+
const surface =
|
|
60
|
+
token === undefined
|
|
61
|
+
? {
|
|
62
|
+
reachable: false,
|
|
63
|
+
reason:
|
|
64
|
+
'no static token is issued in this workspace, so the endpoint cannot be queried',
|
|
65
|
+
names: [],
|
|
66
|
+
bytes: 0,
|
|
67
|
+
}
|
|
68
|
+
: await askEndpoint(url, token);
|
|
46
69
|
const providers = [...new Set(runtime.registry.capabilities().map(({ id }) => id))];
|
|
47
70
|
|
|
48
71
|
await emit(
|
|
@@ -30,5 +30,11 @@ export { pair, PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF, type PairFlags } fro
|
|
|
30
30
|
export { desktop, settingsUrl, type DesktopFlags } from './operate/desktop.ts';
|
|
31
31
|
export { auditTail, auditVerify, markdownCell } from './operate/audit.ts';
|
|
32
32
|
export { attachFile } from './operate/attach.ts';
|
|
33
|
-
export {
|
|
33
|
+
export {
|
|
34
|
+
tokenIssue,
|
|
35
|
+
tokenList,
|
|
36
|
+
tokenRevoke,
|
|
37
|
+
tokenRotate,
|
|
38
|
+
tokenShow,
|
|
39
|
+
} from './operate/token.ts';
|
|
34
40
|
export { configShow, policyList, policyRule } from './operate/policy.ts';
|
|
@@ -51,17 +51,19 @@ export function declaredRefs(
|
|
|
51
51
|
* What the profiles that are staying declare.
|
|
52
52
|
*
|
|
53
53
|
* Nothing in here is deleted, however plainly the profile being removed also
|
|
54
|
-
* declares it. The
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
54
|
+
* declares it. The vault ref is read off the target and is identical for every
|
|
55
|
+
* profile there, which once made a sibling's sealed items unrecoverable in
|
|
56
|
+
* this command.
|
|
57
|
+
*
|
|
58
|
+
* **The endpoint token was the sharpest case here and is no longer a case.**
|
|
59
|
+
* Every profile took the default `token_ref: profile/token` out of one
|
|
60
|
+
* per-workspace store, so removing one deleted the token its siblings were
|
|
61
|
+
* served by. What fixed it is not a better survivor check: the token was never
|
|
62
|
+
* a profile's to declare (ADR-068), so removing one cannot reach it now.
|
|
61
63
|
*/
|
|
62
64
|
survivors: readonly Config[] = [],
|
|
63
65
|
): string[] {
|
|
64
|
-
const refs = new Set<string>(
|
|
66
|
+
const refs = new Set<string>();
|
|
65
67
|
|
|
66
68
|
// Read off the *target*, not the profile. `vaultTargetSchema` sits inside
|
|
67
69
|
// `targetSchema`, so two targets may seal the same items in different places;
|
|
@@ -100,7 +102,6 @@ export function declaredRefs(
|
|
|
100
102
|
// declare" has to have one answer.
|
|
101
103
|
const kept = new Set(
|
|
102
104
|
survivors.flatMap((other) => [
|
|
103
|
-
other.auth.token_ref,
|
|
104
105
|
...(declared.vault?.adapter === 'secret' ? [vaultRef(declared, other)] : []),
|
|
105
106
|
...(other.auth.authorization?.mode === 'oidc'
|
|
106
107
|
? [other.auth.authorization.client_id_ref]
|
|
@@ -42,14 +42,28 @@ import { DEFAULT_SURFACES, ensureOwnerLayer, repairLines, repaired } from './con
|
|
|
42
42
|
* for the workspaces 0.9.0 already migrated. One spelling, for the reason the
|
|
43
43
|
* template and `ensureOwnerLayer` share one: two would have to agree forever.
|
|
44
44
|
*/
|
|
45
|
-
export async function ensureRegistryContract(
|
|
45
|
+
export async function ensureRegistryContract(
|
|
46
|
+
workspaceRoot: string,
|
|
47
|
+
/**
|
|
48
|
+
* The contract to stamp, which is the one the caller is *producing*.
|
|
49
|
+
*
|
|
50
|
+
* Defaulted to the newest for the repair sweep, and passed explicitly by each
|
|
51
|
+
* migration step. A step that stamped the newest would put the registry ahead
|
|
52
|
+
* of the profiles it just wrote — and `isUnmigrated` reads exactly this field,
|
|
53
|
+
* so the registry would report the workspace as migrated with a later step
|
|
54
|
+
* still to run. That is the same defect the note on
|
|
55
|
+
* `the contract it stamps on the registry` describes, in the direction that
|
|
56
|
+
* fails silently rather than loudly.
|
|
57
|
+
*/
|
|
58
|
+
contract: number = SUPPORTED_CONTRACT,
|
|
59
|
+
): Promise<boolean> {
|
|
46
60
|
const files = workspaceFiles(workspaceRoot);
|
|
47
61
|
if (!(await files.has(WORKSPACE_FILE))) return false;
|
|
48
62
|
|
|
49
63
|
const document = await ConfigDocument.openKey(workspaceRoot, WORKSPACE_FILE);
|
|
50
|
-
if (document.getIn(['contract']) ===
|
|
64
|
+
if (document.getIn(['contract']) === contract) return false;
|
|
51
65
|
|
|
52
|
-
document.setIn(['contract'],
|
|
66
|
+
document.setIn(['contract'], contract);
|
|
53
67
|
await document.save();
|
|
54
68
|
return true;
|
|
55
69
|
}
|
|
@@ -79,7 +93,7 @@ function listSurfaces(): string {
|
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
/** The workspace's connections document, written from the template if missing. */
|
|
82
|
-
async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
96
|
+
export async function openOrCreateConnections(workspaceRoot: string): Promise<ConfigDocument> {
|
|
83
97
|
try {
|
|
84
98
|
return await ConfigDocument.openKey(workspaceRoot, CONNECTIONS_FILE);
|
|
85
99
|
} catch {
|
package/src/cli/config-repair.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { nextConnectionId } from './identity.ts';
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/** Lanes' own provider ids, and the label each row carries. */
|
|
21
|
-
const RESERVED_SURFACES = {
|
|
21
|
+
export const RESERVED_SURFACES = {
|
|
22
22
|
lanes_memory: 'Memory',
|
|
23
23
|
lanes_tasks: 'Tasks',
|
|
24
24
|
lanes_assets: 'Assets',
|
|
@@ -27,7 +27,7 @@ export function newProfileTemplate(profile: string, port: number, subject?: stri
|
|
|
27
27
|
#
|
|
28
28
|
# Edit it by hand or through the CLI; both are supported, and CLI edits preserve
|
|
29
29
|
# your comments and ordering.
|
|
30
|
-
contract:
|
|
30
|
+
contract: 5
|
|
31
31
|
|
|
32
32
|
instance:
|
|
33
33
|
profile: ${profile}
|
|
@@ -47,12 +47,13 @@ instance:
|
|
|
47
47
|
#
|
|
48
48
|
# lanes link status --profile ${profile} --workspace <name>
|
|
49
49
|
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
50
|
+
# No token here. A profile declares no endpoint credential (ADR-068) — the
|
|
51
|
+
# workspace does, in "tokens:" in connections.yaml, one row per person it was
|
|
52
|
+
# issued to. People sign in rather than holding one: a client that asks for
|
|
53
|
+
# authorization is sent to the Lanes login and comes back as somebody
|
|
54
|
+
# (ADR-062). "lanes link token issue" is for a runner with no browser.
|
|
53
55
|
auth:
|
|
54
56
|
mode: bearer
|
|
55
|
-
token_ref: profile/token
|
|
56
57
|
authorization:
|
|
57
58
|
mode: self
|
|
58
59
|
|
|
@@ -146,7 +147,7 @@ export function newWorkspaceTemplate(): string {
|
|
|
146
147
|
# uses it prints which one it got. Commands that publish or destroy — deploy,
|
|
147
148
|
# sync, secrets push, profile remove, disconnect, token rotate — refuse it and
|
|
148
149
|
# make you type the name (ADR-061).
|
|
149
|
-
contract:
|
|
150
|
+
contract: 5
|
|
150
151
|
default_workspace: local
|
|
151
152
|
workspaces:
|
|
152
153
|
local:
|
|
@@ -182,7 +183,7 @@ export function newConnectionsTemplate(): string {
|
|
|
182
183
|
# still keeps its own bytes — what you write through one profile is absent in
|
|
183
184
|
# another (ADR-066). A second instance is for holding two of something in one
|
|
184
185
|
# profile: "lanes link connect lanes_memory --id lan9".
|
|
185
|
-
contract:
|
|
186
|
+
contract: 5
|
|
186
187
|
|
|
187
188
|
connections:
|
|
188
189
|
- { id: lan1, provider: lanes_memory, account: Memory }
|
|
@@ -195,6 +196,12 @@ connections:
|
|
|
195
196
|
|
|
196
197
|
# App registrations, shared by every connection of that vendor.
|
|
197
198
|
oauth_apps: {}
|
|
199
|
+
|
|
200
|
+
# Static endpoint tokens, one row per person one was issued to (ADR-068). Empty
|
|
201
|
+
# is the ordinary state: a client registers against the bare URL and signs in,
|
|
202
|
+
# so nothing needs one until something headless does. A row reaches whatever its
|
|
203
|
+
# subject is a member of — "lanes link token issue --me --workspace <name>".
|
|
204
|
+
tokens: []
|
|
198
205
|
`;
|
|
199
206
|
}
|
|
200
207
|
|
|
@@ -145,10 +145,10 @@ async function readMerged(
|
|
|
145
145
|
// values for one key, and there is no merge that means anything.
|
|
146
146
|
//
|
|
147
147
|
// Left behind rather than picked between. It is minted locally rather
|
|
148
|
-
// than granted by anybody
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
148
|
+
// than granted by anybody and the old stores are not deleted, so the cost
|
|
149
|
+
// is re-registering a client and no account has to be authorised again.
|
|
150
|
+
// Contract 5 ends this problem rather than solving it: the token is the
|
|
151
|
+
// workspace's, so there are no longer several to merge (ADR-068).
|
|
152
152
|
let value: string | null;
|
|
153
153
|
try {
|
|
154
154
|
value = await store.get(ref);
|
|
@@ -212,10 +212,10 @@ async function readMerged(
|
|
|
212
212
|
// routine `update`, for a conflict that did not exist.
|
|
213
213
|
//
|
|
214
214
|
// Where they do disagree it is still left behind rather than picked between.
|
|
215
|
-
// It is minted locally rather than granted by anybody
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
215
|
+
// It is minted locally rather than granted by anybody and the old stores are
|
|
216
|
+
// not deleted, so the cost is re-registering a client and no account has to be
|
|
217
|
+
// authorised again. Contract 5 removes the possibility of the disagreement:
|
|
218
|
+
// one token registry per workspace (ADR-068).
|
|
219
219
|
for (const [ref, byProfile] of endpoint) {
|
|
220
220
|
const values = new Set(byProfile.values());
|
|
221
221
|
const agreed = values.size === 1 ? [...values][0] : undefined;
|
package/src/cli/contract4.ts
CHANGED
|
@@ -159,7 +159,9 @@ export async function migrateToContract4(
|
|
|
159
159
|
// unchanged. Here rather than inside `renameRegistry` because that function
|
|
160
160
|
// returns early on a workspace already holding `workspaces.yaml`, which is
|
|
161
161
|
// exactly the workspace whose stamp is stale.
|
|
162
|
-
|
|
162
|
+
// Four, not the newest: contract 5 runs after this, and a registry claiming it
|
|
163
|
+
// makes `isUnmigrated` report a finished migration with a step still to go.
|
|
164
|
+
await ensureRegistryContract(workspaceRoot, 4);
|
|
163
165
|
await applyMoves(files, plan.moves);
|
|
164
166
|
|
|
165
167
|
// Credentials before the rows: a ref is derived from the id, so the rows must
|
|
@@ -185,7 +187,10 @@ export async function migrateToContract4(
|
|
|
185
187
|
for (const profile of profiles) {
|
|
186
188
|
const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
|
|
187
189
|
document.setIn(['contract'], 4);
|
|
188
|
-
|
|
190
|
+
// The contract it is *producing*, which is not the newest one any more:
|
|
191
|
+
// contract 5 exists, so a bare `save()` here validates this document
|
|
192
|
+
// against a schema it is one step short of and refuses its own output.
|
|
193
|
+
await document.save({ contract: 4 });
|
|
189
194
|
}
|
|
190
195
|
|
|
191
196
|
return {
|