@lanes-sh/link 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/callback-page.ts +36 -115
- 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 -109
- 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
package/src/profile/load.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { parse as parseYaml } from 'yaml';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import {
|
|
4
|
+
SINGLE_INSTANCE_PROVIDERS,
|
|
4
5
|
SUPPORTED_CONTRACT,
|
|
5
6
|
configSchema,
|
|
6
7
|
type Config,
|
|
7
|
-
type PolicyRuleConfig,
|
|
8
8
|
} from './schema.ts';
|
|
9
9
|
import { findSecrets, formatSecretFindings } from './secret-detection.ts';
|
|
10
10
|
|
|
@@ -21,7 +21,7 @@ export class ConfigError extends Error {
|
|
|
21
21
|
export interface LoadedConfig {
|
|
22
22
|
readonly config: Config;
|
|
23
23
|
readonly source: string;
|
|
24
|
-
/** `provider.id` for every
|
|
24
|
+
/** `provider.id` for every connection this profile grants, in declaration order. */
|
|
25
25
|
readonly connectionKeys: readonly string[];
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -208,7 +208,7 @@ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
|
|
|
208
208
|
* and whoever is reading this refusal just typed which one.
|
|
209
209
|
*/
|
|
210
210
|
function repairCommand(config: Config): string {
|
|
211
|
-
return `lanes link doctor --fix --profile ${config.instance.profile} --
|
|
211
|
+
return `lanes link doctor --fix --profile ${config.instance.profile} --workspace <name>`;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/** The rename a row is owed, or `null` when it is owed none. */
|
|
@@ -221,7 +221,7 @@ export function renamedProviderFor(connection: {
|
|
|
221
221
|
return moved;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
function
|
|
224
|
+
export function describeRename(
|
|
225
225
|
connection: { provider: string; account: string },
|
|
226
226
|
repair: string,
|
|
227
227
|
): string | null {
|
|
@@ -241,30 +241,74 @@ function renamedProvider(
|
|
|
241
241
|
function assertReferentialIntegrity(config: Config, source: string): void {
|
|
242
242
|
const problems: string[] = [];
|
|
243
243
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
// `
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
244
|
+
// Nothing about targets is checked here any more. A profile declares none
|
|
245
|
+
// (ADR-052): the workspace holding this file declares the one it lives in.
|
|
246
|
+
//
|
|
247
|
+
// Nothing about *connections* is either, and that is newer. A connection
|
|
248
|
+
// belongs to the workspace (ADR-057), so whether a grant names a real one is
|
|
249
|
+
// a question this file cannot answer on its own — `assertGrantsResolve` in
|
|
250
|
+
// `./connections.ts` answers it once `connections.yaml` has been read. What
|
|
251
|
+
// is checkable from one profile alone is checked here, and only that.
|
|
252
|
+
|
|
253
|
+
// A grant names one connection, so every capability in it belongs to that
|
|
254
|
+
// connection's provider. Worth refusing rather than warning: `allowedConnections`
|
|
255
|
+
// filters candidates to the capability's own provider before policy is
|
|
256
|
+
// consulted, so `allow: [calendar.*]` on a row granting `gmail.personal`
|
|
257
|
+
// matches nothing, ever, while reading exactly like a grant that works.
|
|
258
|
+
const grantKeys = new Set<string>();
|
|
259
|
+
config.grants.forEach((grant, index) => {
|
|
260
|
+
if (grantKeys.has(grant.connection)) {
|
|
261
|
+
problems.push(`grants[${index}]: duplicate grant for "${grant.connection}"`);
|
|
258
262
|
}
|
|
259
|
-
|
|
263
|
+
grantKeys.add(grant.connection);
|
|
264
|
+
|
|
265
|
+
const provider = grant.connection.split('.')[0] ?? '';
|
|
266
|
+
|
|
267
|
+
// The renamed-provider check is not here any more. It compares a row's
|
|
268
|
+
// *account* against the label the built-in keeps — "is this really Google
|
|
269
|
+
// Tasks?" — and an account lives in `connections.yaml` now (ADR-057). A
|
|
270
|
+
// profile grant carries only the key, so asking here would either need the
|
|
271
|
+
// other file or answer from nothing; `assertNoRenamedProviders` asks where
|
|
272
|
+
// the answer is.
|
|
273
|
+
|
|
274
|
+
for (const [field, rules] of [
|
|
275
|
+
['allow', grant.allow],
|
|
276
|
+
['deny', grant.deny],
|
|
277
|
+
] as const) {
|
|
278
|
+
rules.forEach((rule, ruleIndex) => {
|
|
279
|
+
if (rule.capability === '*') return;
|
|
280
|
+
const named = rule.capability.split('.')[0] ?? '';
|
|
281
|
+
if (named === provider) return;
|
|
260
282
|
|
|
261
|
-
|
|
262
|
-
|
|
283
|
+
problems.push(
|
|
284
|
+
`grants[${index}].${field}[${ruleIndex}]: "${rule.capability}" names provider ` +
|
|
285
|
+
`"${named}", but this row grants "${grant.connection}". A rule here governs ` +
|
|
286
|
+
`that connection and nothing else, so it can only name "${provider}.*".`,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
263
290
|
});
|
|
264
291
|
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
292
|
+
// At most one skills grant and one vault grant, for the reason
|
|
293
|
+
// `SINGLE_INSTANCE_PROVIDERS` gives: both surface as flat names with no
|
|
294
|
+
// argument to route on, so a second instance is a collision rather than a
|
|
295
|
+
// choice.
|
|
296
|
+
for (const provider of SINGLE_INSTANCE_PROVIDERS) {
|
|
297
|
+
const granted = config.grants
|
|
298
|
+
.map((grant) => grant.connection)
|
|
299
|
+
.filter((ref) => ref.startsWith(`${provider}.`));
|
|
300
|
+
|
|
301
|
+
if (granted.length > 1) {
|
|
302
|
+
problems.push(
|
|
303
|
+
`grants: ${granted.join(' and ')} — a profile may grant one "${provider}" connection. ` +
|
|
304
|
+
`It is surfaced by name with nothing to route on, so two would be one name for two things.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Same reason as a duplicate grant: two entries with the same kind and value
|
|
310
|
+
// cannot both be meant, and the one that loses is invisible. It matters more
|
|
311
|
+
// here than it looks, because the two would usually differ only in their
|
|
268
312
|
// `note` — so the discarded one is precisely the guidance someone wrote down
|
|
269
313
|
// to stop an agent picking wrong.
|
|
270
314
|
const identityKeys = new Set<string>();
|
|
@@ -276,28 +320,17 @@ function assertReferentialIntegrity(config: Config, source: string): void {
|
|
|
276
320
|
identityKeys.add(key);
|
|
277
321
|
});
|
|
278
322
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (field === 'allow' && !providerNames.has(providerOfCapability)) {
|
|
291
|
-
problems.push(
|
|
292
|
-
`${where}: "${rule.capability}" names provider "${providerOfCapability}", which has no connection` +
|
|
293
|
-
(providerNames.size > 0 ? ` (have: ${[...providerNames].join(', ')})` : ''),
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
checkRules(config.policy.allow, 'allow');
|
|
300
|
-
checkRules(config.policy.deny, 'deny');
|
|
323
|
+
// A subject listed twice is one row deciding the role and the other doing
|
|
324
|
+
// nothing, and which one wins is iteration order. Since the two would differ
|
|
325
|
+
// only in `role`, the silent loser is exactly the line someone added to
|
|
326
|
+
// promote or demote somebody (ADR-060).
|
|
327
|
+
const subjects = new Set<string>();
|
|
328
|
+
config.members.forEach((member, index) => {
|
|
329
|
+
if (subjects.has(member.subject)) {
|
|
330
|
+
problems.push(`members[${index}]: duplicate subject "${member.subject}"`);
|
|
331
|
+
}
|
|
332
|
+
subjects.add(member.subject);
|
|
333
|
+
});
|
|
301
334
|
|
|
302
335
|
if (problems.length > 0) {
|
|
303
336
|
throw new ConfigError(`${source}:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
|
|
@@ -316,7 +349,7 @@ export function parseConfig(text: string, source = '<config>'): LoadedConfig {
|
|
|
316
349
|
return {
|
|
317
350
|
config,
|
|
318
351
|
source,
|
|
319
|
-
connectionKeys: config.
|
|
352
|
+
connectionKeys: config.grants.map((grant) => grant.connection),
|
|
320
353
|
};
|
|
321
354
|
}
|
|
322
355
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the dashboard's pairing credentials live in a workspace's store.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than beside the command that writes them, because three
|
|
5
|
+
* components now read these names and only one of them is the CLI: the server
|
|
6
|
+
* opens the read surface with them (`#server/read`), and a deploy binds the
|
|
7
|
+
* token so the revision may read it (`#deployments/prepare.ts`). Importing a
|
|
8
|
+
* *command* module for a string constant dragged `cli/output.ts`,
|
|
9
|
+
* `cli/prompt.ts` and the terminal handling behind them into the container's
|
|
10
|
+
* runtime graph, which is a large amount of the CLI to load in order to learn
|
|
11
|
+
* three names.
|
|
12
|
+
*
|
|
13
|
+
* Underscores, not dots. A secret reference is `[a-z0-9_-]` separated by `/`
|
|
14
|
+
* (see `isValidSecretRef`), and that is not arbitrary: these names become
|
|
15
|
+
* Secret Manager entries on a deployed workspace, and Google allows no dots
|
|
16
|
+
* there either. `workspace/pair.cert` was refused at the moment somebody first
|
|
17
|
+
* ran the command.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The credential the dashboard presents. Reads everything; can change nothing. */
|
|
21
|
+
export const PAIR_TOKEN_REF = 'workspace/pair_token';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The certificate the loopback read listener terminates TLS with, and its key.
|
|
25
|
+
*
|
|
26
|
+
* Loopback only, and deliberately not bound on a deployed workspace: Cloud Run
|
|
27
|
+
* terminates TLS with a certificate a browser already trusts, so a deployed
|
|
28
|
+
* revision never calls `serveRead` and never reads either of these. Binding two
|
|
29
|
+
* secrets nothing reads would say the boundary is wider than it is.
|
|
30
|
+
*/
|
|
31
|
+
export const PAIR_CERT_REF = 'workspace/pair_cert';
|
|
32
|
+
export const PAIR_KEY_REF = 'workspace/pair_key';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
4
|
+
* The string shapes the config contract is built out of.
|
|
5
5
|
*
|
|
6
6
|
* Their own file because more than one schema module needs them, and the
|
|
7
7
|
* alternative was either a circular import or a second copy of a regex that
|
|
@@ -66,3 +66,37 @@ export const browserOrigin = z.string().refine(
|
|
|
66
66
|
},
|
|
67
67
|
'must be "*" or an origin with no trailing slash or path, e.g. "https://chat.example"',
|
|
68
68
|
);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `<provider>.<connection>` — how a grant names what it governs.
|
|
72
|
+
*
|
|
73
|
+
* One string rather than two fields, because it is one string everywhere else
|
|
74
|
+
* it appears: the `connection` argument an agent passes, the audit event, the
|
|
75
|
+
* `setup_overview` listing, and the refusal a mismatched pairing produces.
|
|
76
|
+
* Splitting it in the config alone would mean every reader joins it back
|
|
77
|
+
* together.
|
|
78
|
+
*/
|
|
79
|
+
export const connectionRef = z
|
|
80
|
+
.string()
|
|
81
|
+
.regex(
|
|
82
|
+
/^[a-z][a-z0-9_]*\.[a-z0-9][a-z0-9_]*$/,
|
|
83
|
+
'must be "<provider>.<connection>", e.g. "gmail.personal"',
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Who a profile is delegated to: `lanes:<subject>` (ADR-060).
|
|
88
|
+
*
|
|
89
|
+
* The prefix is load-bearing twice over, and the second reason is the one that
|
|
90
|
+
* would otherwise be found the hard way. A Lanes subject is a 28-character
|
|
91
|
+
* mixed-case alphanumeric string — which is exactly what `secret-detection.ts`
|
|
92
|
+
* refuses as a high-entropy blob, so a bare one could not be written into a
|
|
93
|
+
* profile at all. The colon takes the value out of `OPAQUE_TOKEN`'s character
|
|
94
|
+
* class, so saying which identity provider vouched for the subject and being
|
|
95
|
+
* storable are the same decision rather than an exemption list.
|
|
96
|
+
*
|
|
97
|
+
* A pasted credential still cannot be smuggled in here: it does not match this
|
|
98
|
+
* pattern either, which is what makes the fix a narrowing rather than a hole.
|
|
99
|
+
*/
|
|
100
|
+
export const subjectRef = z
|
|
101
|
+
.string()
|
|
102
|
+
.regex(/^lanes:[A-Za-z0-9]{6,64}$/, 'must be "lanes:<subject>", as written by lanes auth login');
|
package/src/profile/registry.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
* A machine reaches a target it does not hold through a **pointer** — a registry
|
|
28
28
|
* entry carrying `workspace:` and nothing else. Following one is a read of that
|
|
29
29
|
* workspace's own file, which is why every function here is async and why
|
|
30
|
-
* `--
|
|
30
|
+
* `--workspace cloud` needs the bucket reachable. That is the trade ADR-052 takes
|
|
31
31
|
* deliberately: a cloud target that cannot be read says so, where the shape it
|
|
32
32
|
* replaces answered instantly from a copy that had been wrong for eight hours.
|
|
33
33
|
*/
|
|
@@ -51,7 +51,7 @@ export interface ResolvedTarget {
|
|
|
51
51
|
/** Every target the workspace at `root` knows about. Empty when it has no file. */
|
|
52
52
|
export async function readRegistry(root: string): Promise<Record<string, WorkspaceTarget>> {
|
|
53
53
|
const workspace = await readWorkspace(root);
|
|
54
|
-
return workspace?.
|
|
54
|
+
return workspace?.workspaces ?? {};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -66,7 +66,7 @@ export async function resolveTargetWorkspace(root: string, target: string): Prom
|
|
|
66
66
|
const registry = await readRegistry(root);
|
|
67
67
|
const entry = registry[target];
|
|
68
68
|
if (!entry) throw notInRegistry(target, registry, root);
|
|
69
|
-
return isPointer(entry) ? entry.
|
|
69
|
+
return isPointer(entry) ? entry.at.replace(/\/$/, '') : root;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
@@ -90,7 +90,7 @@ export async function openTarget(root: string, target: string): Promise<Resolved
|
|
|
90
90
|
return { target, workspaceRoot: root, declared, entry, remote: false };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
const workspaceRoot = entry.
|
|
93
|
+
const workspaceRoot = entry.at.replace(/\/$/, '');
|
|
94
94
|
const remoteRegistry = await readRegistry(workspaceRoot);
|
|
95
95
|
const remoteEntry = remoteRegistry[target];
|
|
96
96
|
|
|
@@ -146,7 +146,7 @@ function pointerMissesTarget(
|
|
|
146
146
|
return new ConfigError(
|
|
147
147
|
`${root} says target "${target}" lives at ${workspaceRoot}, but that workspace does not ` +
|
|
148
148
|
`declare it (it declares: ${there}).\n` +
|
|
149
|
-
` Adopt what is really there: lanes link sync targets --
|
|
149
|
+
` Adopt what is really there: lanes link sync targets --workspace ${target} --from ${workspaceRoot}`,
|
|
150
150
|
);
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -176,7 +176,7 @@ function remoteAtContractOne(target: string, workspaceRoot: string): ConfigError
|
|
|
176
176
|
return new ConfigError(
|
|
177
177
|
`${workspaceRoot} is a contract 1 workspace, so it does not declare "${target}" yet.\n` +
|
|
178
178
|
' Its profiles still carry their own targets: block, which this version does not read.\n\n' +
|
|
179
|
-
` lanes link deploy --
|
|
179
|
+
` lanes link deploy --workspace ${target}\n` +
|
|
180
180
|
' migrates it and rolls the image that can read it, in that order — which is what\n' +
|
|
181
181
|
' keeps the endpoint in front of it serving throughout (ADR-052).',
|
|
182
182
|
);
|
package/src/profile/schema.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
browserOrigin,
|
|
4
|
+
capabilityPattern,
|
|
5
|
+
connectionRef,
|
|
6
|
+
credentialRef,
|
|
7
|
+
identifier,
|
|
8
|
+
subjectRef,
|
|
9
|
+
} from './primitives.ts';
|
|
3
10
|
import { authorizationSchema } from './authorization.ts';
|
|
4
11
|
import { identitySchema } from './identity.ts';
|
|
5
12
|
import { knowledgeTargetSchema } from './knowledge.ts';
|
|
@@ -35,11 +42,19 @@ import { knowledgeTargetSchema } from './knowledge.ts';
|
|
|
35
42
|
* workspace *is* a target: it declares its own adapters once, holds the profiles
|
|
36
43
|
* that live in it, and a profile is one copy in one place.
|
|
37
44
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* **3 moved `connections:` out of the profile and into the workspace**
|
|
46
|
+
* (ADR-057), and replaced the profile's flat `policy:` with `grants:`, one row
|
|
47
|
+
* per connection (ADR-058). A profile stopped being an inventory of accounts
|
|
48
|
+
* and became a selection over them, which is what makes "read this mailbox,
|
|
49
|
+
* write that calendar" a thing that can be written down. It also gained
|
|
50
|
+
* `members:`, because a profile worth sharing needs to say who may consume it
|
|
51
|
+
* (ADR-060).
|
|
52
|
+
*
|
|
53
|
+
* A hard cut each time, and only the newest is read here. `./legacy.ts`
|
|
54
|
+
* understands the older shapes and only the migration uses it — a runtime that
|
|
55
|
+
* loaded either would be the two-sources-of-truth problem again, one level up.
|
|
41
56
|
*/
|
|
42
|
-
export const SUPPORTED_CONTRACT =
|
|
57
|
+
export const SUPPORTED_CONTRACT = 3;
|
|
43
58
|
|
|
44
59
|
/**
|
|
45
60
|
* There is no `database:` block any more.
|
|
@@ -136,7 +151,7 @@ export const auditTargetSchema = z.object({
|
|
|
136
151
|
*/
|
|
137
152
|
export const vaultTargetSchema = z.object({
|
|
138
153
|
adapter: z.enum(['file', 'blob', 'secret']),
|
|
139
|
-
/** File path, or blob key. Defaults to
|
|
154
|
+
/** File path, or blob key. Defaults to `data/vault.d/<connection>.enc`. */
|
|
140
155
|
path: z.string().optional(),
|
|
141
156
|
/** `secret` only: where the sealed document lives. Defaults to `vault/document`. */
|
|
142
157
|
ref: credentialRef.optional(),
|
|
@@ -327,6 +342,69 @@ export const policySchema = z.object({
|
|
|
327
342
|
deny: z.array(policyRuleSchema).default([]),
|
|
328
343
|
});
|
|
329
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Providers a profile may grant at most one instance of.
|
|
347
|
+
*
|
|
348
|
+
* Not a limitation of the store — `data/skills.d/<id>/` and
|
|
349
|
+
* `data/vault.d/<id>.enc` hold as many as anyone makes (ADR-059). It is a
|
|
350
|
+
* limitation of the surface, and it comes from the protocol rather than from
|
|
351
|
+
* here.
|
|
352
|
+
*
|
|
353
|
+
* Every other owner-layer tool takes a `connection` argument, so two memory
|
|
354
|
+
* instances are two routes through one tool and the caller says which. These
|
|
355
|
+
* two have nowhere to put that. A skill is surfaced as an MCP **prompt**, and a
|
|
356
|
+
* prompt is selected by name with no arguments to route on (ADR-012) — two
|
|
357
|
+
* `triage` skills in one profile would be one name for two procedures. A vault
|
|
358
|
+
* item becomes its own `vault.get.<id>` capability, and capability ids are flat
|
|
359
|
+
* for the same reason: two instances holding `stripe_key` would be one
|
|
360
|
+
* capability naming two secrets, which is the worst of the three collisions
|
|
361
|
+
* because the wrong answer is a credential.
|
|
362
|
+
*
|
|
363
|
+
* So the refusal is at load, where it names both rows, rather than at call time
|
|
364
|
+
* where one would silently win.
|
|
365
|
+
*/
|
|
366
|
+
export const SINGLE_INSTANCE_PROVIDERS: readonly string[] = ['skills', 'vault'];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* One connection, and what may be done with it (ADR-058).
|
|
370
|
+
*
|
|
371
|
+
* The rules are the same `allow`/`deny` pair `policySchema` has always carried;
|
|
372
|
+
* what is new is that a row names the account they govern. ADR-003 kept rules
|
|
373
|
+
* connection-blind on the reasoning that "a narrower grant is a narrower
|
|
374
|
+
* profile" — sound while a connection lived in exactly one profile, because the
|
|
375
|
+
* second profile *was* the granularity. ADR-057 removed that mechanism, so the
|
|
376
|
+
* granularity moves into the rule.
|
|
377
|
+
*
|
|
378
|
+
* **There is no profile-wide `allow` beside these rows, deliberately.** A
|
|
379
|
+
* second place a connection could be granted is a second answer to "may this
|
|
380
|
+
* call proceed", and two answers to one question is the failure ADR-052 exists
|
|
381
|
+
* to prevent. A profile that wants the same rules on eight connections writes
|
|
382
|
+
* eight rows; the repetition is the cost of there being one place to look.
|
|
383
|
+
*
|
|
384
|
+
* Default deny is unchanged and now bites one step earlier: a connection with
|
|
385
|
+
* no row is not reachable, and is never advertised.
|
|
386
|
+
*/
|
|
387
|
+
export const grantSchema = z.object({
|
|
388
|
+
connection: connectionRef,
|
|
389
|
+
allow: z.array(policyRuleSchema).default([]),
|
|
390
|
+
deny: z.array(policyRuleSchema).default([]),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Who may consume this profile (ADR-060).
|
|
395
|
+
*
|
|
396
|
+
* `role` decides who may edit this list and nothing else. Both roles reach
|
|
397
|
+
* exactly what the grants above allow, because a role that changed what an
|
|
398
|
+
* agent could call would be a second policy system beside `grants:`, with
|
|
399
|
+
* precedence rules between them, answering a question the first one already
|
|
400
|
+
* answers. Two people needing different scopes is two profiles, which is cheap
|
|
401
|
+
* now that neither of them re-authorises an account.
|
|
402
|
+
*/
|
|
403
|
+
export const memberSchema = z.object({
|
|
404
|
+
subject: subjectRef,
|
|
405
|
+
role: z.enum(['owner', 'member']).default('member'),
|
|
406
|
+
});
|
|
407
|
+
|
|
330
408
|
/**
|
|
331
409
|
* One authorised account.
|
|
332
410
|
*
|
|
@@ -412,15 +490,36 @@ export const configSchema = z.object({
|
|
|
412
490
|
})
|
|
413
491
|
.default({ requests_per_minute: 120, upstream_calls_per_minute: 60 }),
|
|
414
492
|
|
|
415
|
-
|
|
493
|
+
/**
|
|
494
|
+
* What this profile is for, in the owner's own words.
|
|
495
|
+
*
|
|
496
|
+
* New in contract 3, and it earns its place because a profile is now a thing
|
|
497
|
+
* you hand to somebody (ADR-060). "personal-assistant" is a name; "reads my
|
|
498
|
+
* mail, keeps the calendar, never sends" is what a member needs to know
|
|
499
|
+
* before accepting it. The dashboard and `setup_overview` show it.
|
|
500
|
+
*/
|
|
501
|
+
description: z.string().min(1).optional(),
|
|
416
502
|
|
|
417
503
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
504
|
+
* The connections this profile selects, and what may be done with each.
|
|
505
|
+
*
|
|
506
|
+
* There is no `connections:` block here any more — a connection belongs to
|
|
507
|
+
* the workspace (ADR-057), and `connections.yaml` beside this file is where
|
|
508
|
+
* it lives. A grant naming a connection the workspace does not hold is
|
|
509
|
+
* refused by `assertReferentialIntegrity` rather than loading and reaching
|
|
510
|
+
* nothing.
|
|
421
511
|
*/
|
|
422
|
-
|
|
423
|
-
|
|
512
|
+
grants: z.array(grantSchema).default([]),
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Who may consume this profile (ADR-060).
|
|
516
|
+
*
|
|
517
|
+
* Empty is not "everyone" — it is nobody, which is default deny applied to
|
|
518
|
+
* the identity axis. `profile add` writes the signed-in subject as `owner`,
|
|
519
|
+
* so the empty state is one a hand-edit produces rather than one anybody is
|
|
520
|
+
* handed.
|
|
521
|
+
*/
|
|
522
|
+
members: z.array(memberSchema).default([]),
|
|
424
523
|
|
|
425
524
|
/**
|
|
426
525
|
* Memory and skills, somewhere other than the target's own storage (ADR-041).
|
|
@@ -446,8 +545,35 @@ export const configSchema = z.object({
|
|
|
446
545
|
identity: identitySchema.default([]),
|
|
447
546
|
});
|
|
448
547
|
|
|
548
|
+
/**
|
|
549
|
+
* `connections.yaml` — every account authorised in this workspace (ADR-057).
|
|
550
|
+
*
|
|
551
|
+
* Its own file rather than a block in `lanes-link.yaml`, because that file is
|
|
552
|
+
* the registry and is read before anything else can be: resolving which
|
|
553
|
+
* workspace a command means must not require parsing every connection in it.
|
|
554
|
+
* And its own file rather than staying in the profile, because authorising an
|
|
555
|
+
* account and deciding what may be done with it are two acts, and only the
|
|
556
|
+
* second is a property of a profile.
|
|
557
|
+
*
|
|
558
|
+
* `oauth_apps` comes with them. A registered client belongs to the account it
|
|
559
|
+
* authenticates, not to whichever selection happens to name that account.
|
|
560
|
+
*
|
|
561
|
+
* There is still no `providers` block. A provider is enabled by having a
|
|
562
|
+
* connection to it — a second place to say so could only ever disagree with the
|
|
563
|
+
* first, and everything else a provider needs is in its manifest.
|
|
564
|
+
*/
|
|
565
|
+
export const connectionsFileSchema = z.object({
|
|
566
|
+
contract: z.number().int().positive(),
|
|
567
|
+
connections: z.array(connectionSchema).default([]),
|
|
568
|
+
oauth_apps: z.record(identifier, oauthAppSchema).default({}),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
export type ConnectionsFile = z.infer<typeof connectionsFileSchema>;
|
|
572
|
+
|
|
449
573
|
export type Config = z.infer<typeof configSchema>;
|
|
450
574
|
export type ConnectionConfig = z.infer<typeof connectionSchema>;
|
|
575
|
+
export type GrantConfig = z.infer<typeof grantSchema>;
|
|
576
|
+
export type MemberConfig = z.infer<typeof memberSchema>;
|
|
451
577
|
export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
|
|
452
578
|
export type TargetConfig = z.infer<typeof targetSchema>;
|
|
453
579
|
export type DeployConfig = z.infer<typeof deployTargetSchema>;
|
|
@@ -482,8 +608,14 @@ export type { IdentityEntry } from './identity.ts';
|
|
|
482
608
|
*/
|
|
483
609
|
export const workspaceTargetSchema = z
|
|
484
610
|
.object({
|
|
485
|
-
/**
|
|
486
|
-
|
|
611
|
+
/**
|
|
612
|
+
* A pointer: where the workspace declaring this one lives.
|
|
613
|
+
*
|
|
614
|
+
* Spelled `at:` rather than `workspace:` since contract 3. The block is
|
|
615
|
+
* `workspaces:` now (ADR-061), and `workspaces.cloud.workspace` names the
|
|
616
|
+
* concept twice and reads as a typo.
|
|
617
|
+
*/
|
|
618
|
+
at: z.string().min(1).optional(),
|
|
487
619
|
credentials: credentialsTargetSchema.optional(),
|
|
488
620
|
audit: auditTargetSchema.optional(),
|
|
489
621
|
storage: storageTargetSchema.optional(),
|
|
@@ -497,6 +629,15 @@ export const workspaceTargetSchema = z
|
|
|
497
629
|
* gets in — the one question about a deployment that must not be guessed at.
|
|
498
630
|
*/
|
|
499
631
|
primary: identifier.optional(),
|
|
632
|
+
/**
|
|
633
|
+
* The Lanes workspace this one serves, when it serves one (ADR-060).
|
|
634
|
+
*
|
|
635
|
+
* A binding, not an identity: it says whose membership list
|
|
636
|
+
* `profile members add` validates a subject against. Absent means the
|
|
637
|
+
* workspace delegates only to the signed-in owner, which is what `local`
|
|
638
|
+
* does.
|
|
639
|
+
*/
|
|
640
|
+
lanes_workspace: z.string().min(1).optional(),
|
|
500
641
|
last_deploy: z.string().optional(),
|
|
501
642
|
/**
|
|
502
643
|
* The CLI release that rolled the revision serving this target.
|
|
@@ -520,21 +661,21 @@ export const workspaceTargetSchema = z
|
|
|
520
661
|
// rather than preferring one: a pointer beside a declaration is two answers
|
|
521
662
|
// to "where are this target's bytes", and picking either silently is how the
|
|
522
663
|
// fifteen-connection bucket got reported as seven.
|
|
523
|
-
if (declares && entry.
|
|
664
|
+
if (declares && entry.at !== undefined) {
|
|
524
665
|
ctx.addIssue({
|
|
525
666
|
code: 'custom',
|
|
526
667
|
message:
|
|
527
|
-
'names
|
|
668
|
+
'names an "at" and also declares adapters — a workspace is declared in exactly ' +
|
|
528
669
|
'one workspace. Keep the adapters here, or keep the pointer and declare them there.',
|
|
529
670
|
});
|
|
530
671
|
return;
|
|
531
672
|
}
|
|
532
673
|
|
|
533
|
-
if (!declares && entry.
|
|
674
|
+
if (!declares && entry.at === undefined) {
|
|
534
675
|
ctx.addIssue({
|
|
535
676
|
code: 'custom',
|
|
536
677
|
message:
|
|
537
|
-
'declares neither "
|
|
678
|
+
'declares neither "at" nor "credentials" and "storage" — a workspace either ' +
|
|
538
679
|
'lives here or points at where it does.',
|
|
539
680
|
});
|
|
540
681
|
return;
|
|
@@ -559,7 +700,17 @@ export const workspaceTargetSchema = z
|
|
|
559
700
|
export const workspaceSchema = z.object({
|
|
560
701
|
contract: z.number().int().positive(),
|
|
561
702
|
default_profile: identifier.optional(),
|
|
562
|
-
|
|
703
|
+
/**
|
|
704
|
+
* The workspace a command acts in when `--workspace` is absent (ADR-061).
|
|
705
|
+
*
|
|
706
|
+
* The first key in this file that is read rather than parsed and ignored
|
|
707
|
+
* since ADR-037. What makes it not the resolution chain that decision removed
|
|
708
|
+
* is that there is one source, it is printed on every command that uses it,
|
|
709
|
+
* and every command that publishes or destroys refuses it outright. If the
|
|
710
|
+
* echo is ever dropped for tidiness, ADR-061 has been reversed.
|
|
711
|
+
*/
|
|
712
|
+
default_workspace: z.string().optional(),
|
|
713
|
+
workspaces: z.record(z.string(), workspaceTargetSchema).default({}),
|
|
563
714
|
});
|
|
564
715
|
|
|
565
716
|
export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
|
|
@@ -568,8 +719,8 @@ export type WorkspaceTarget = z.infer<typeof workspaceTargetSchema>;
|
|
|
568
719
|
/** Whether a registry entry points elsewhere rather than declaring the target. */
|
|
569
720
|
export function isPointer(
|
|
570
721
|
entry: WorkspaceTarget,
|
|
571
|
-
): entry is WorkspaceTarget & {
|
|
572
|
-
return entry.
|
|
722
|
+
): entry is WorkspaceTarget & { at: string } {
|
|
723
|
+
return entry.at !== undefined;
|
|
573
724
|
}
|
|
574
725
|
|
|
575
726
|
/**
|
package/src/profile/targets.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { isPointer, type WorkspaceTarget } from './schema.ts';
|
|
|
16
16
|
* The chain this replaces resolved `--target`, then the variable, then the key,
|
|
17
17
|
* and printed which of the three it landed on. What that bought was one flag
|
|
18
18
|
* saved per command. What it cost was that an *ignored* flag still produced a
|
|
19
|
-
* working command — `profile add --
|
|
19
|
+
* working command — `profile add --workspace cloud` dropped the flag on the floor
|
|
20
20
|
* and the next command carried on from a different source, so the mistake
|
|
21
21
|
* surfaced one command later with nothing connecting it to its cause. A
|
|
22
22
|
* resolver with nowhere to fall back to cannot fail that way.
|
|
@@ -69,27 +69,39 @@ export function noTargetNamed(
|
|
|
69
69
|
registry: Registry,
|
|
70
70
|
root?: string,
|
|
71
71
|
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
72
|
+
options: { refusedDefault?: boolean } = {},
|
|
72
73
|
): ConfigError {
|
|
73
74
|
const names = Object.keys(registry).sort();
|
|
74
75
|
const where = root ?? 'this workspace';
|
|
75
76
|
|
|
76
77
|
if (names.length === 0) {
|
|
77
78
|
return new ConfigError(
|
|
78
|
-
`--
|
|
79
|
-
' Create one with: lanes link profile add <name> --
|
|
79
|
+
`--workspace is required, and ${where} declares none.\n` +
|
|
80
|
+
' Create one with: lanes link profile add <name> --workspace local',
|
|
80
81
|
);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
const stale = env[LEGACY_TARGET_ENV];
|
|
84
85
|
|
|
86
|
+
// The refusal a *destructive* command gives is a different sentence, because
|
|
87
|
+
// the operator has a default set and is entitled to know why it was not used
|
|
88
|
+
// (ADR-061). Saying only "--workspace is required" to somebody who configured
|
|
89
|
+
// one reads as a bug in the tool.
|
|
90
|
+
const because =
|
|
91
|
+
options.refusedDefault === true
|
|
92
|
+
? "--workspace is required. This command publishes or destroys, so the\ndefault is not used for it.\n\n"
|
|
93
|
+
: "--workspace is required. This command opens a workspace's stores, and\nnothing else selects one.\n\n";
|
|
94
|
+
|
|
85
95
|
return new ConfigError(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
because +
|
|
97
|
+
` Workspaces in ${where}\n${rows(registry, names)}\n` +
|
|
98
|
+
`\n e.g. lanes link status --workspace ${names[0]}` +
|
|
99
|
+
(options.refusedDefault === true
|
|
100
|
+
? '\n\n A default is set and is used by every command that only reads.'
|
|
101
|
+
: '') +
|
|
90
102
|
(stale
|
|
91
103
|
? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
|
|
92
|
-
' Unset it, or pass --
|
|
104
|
+
' Unset it, or pass --workspace.'
|
|
93
105
|
: ''),
|
|
94
106
|
);
|
|
95
107
|
}
|
|
@@ -126,7 +138,7 @@ function rows(registry: Registry, names: readonly string[]): string {
|
|
|
126
138
|
return names
|
|
127
139
|
.map((name) => {
|
|
128
140
|
const entry = registry[name]!;
|
|
129
|
-
if (isPointer(entry)) return ` ${name} ${entry.
|
|
141
|
+
if (isPointer(entry)) return ` ${name} ${entry.at}`;
|
|
130
142
|
const adapters = [entry.credentials?.adapter, entry.storage?.adapter]
|
|
131
143
|
.filter(Boolean)
|
|
132
144
|
.join(' ');
|