@lanes-sh/link 0.1.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/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { startEndpoint } from './endpoint.ts';
|
|
3
|
+
import { streamLogger } from './logging.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The container entrypoint — `src/deployments/gcp/Dockerfile` runs this.
|
|
7
|
+
*
|
|
8
|
+
* Not `lanes link start`. That command is a terminal UI: it announces the
|
|
9
|
+
* resolved profile with colour, mints a token if none exists, and expects
|
|
10
|
+
* someone to be reading. A container needs the opposite of each — plain lines
|
|
11
|
+
* on stdout for Cloud Logging, a refusal rather than an invention when the
|
|
12
|
+
* token is missing, and a clean SIGTERM. Everything between those two is the
|
|
13
|
+
* same `startEndpoint`, which is the point: the deployed instance runs the
|
|
14
|
+
* same bootstrap as the local one, against a different adapter set.
|
|
15
|
+
*
|
|
16
|
+
* What the environment has to provide:
|
|
17
|
+
*
|
|
18
|
+
* LANES_LINK_HOME the workspace root, as `gs://<bucket>`. Set by
|
|
19
|
+
* `lanes link deploy` at rollout rather than baked into
|
|
20
|
+
* the image, because the bucket is the operator's and the
|
|
21
|
+
* image is meant to serve any workspace (ADR-023).
|
|
22
|
+
* Unset, `resolveWorkspaceRoot` falls back to
|
|
23
|
+
* `~/.lanes-link`, which in a container is a directory
|
|
24
|
+
* nobody wrote — so the endpoint refuses rather than
|
|
25
|
+
* serving an empty workspace.
|
|
26
|
+
* LANES_LINK_TARGET which target's adapters to open. Baked to `cloud`.
|
|
27
|
+
* LANES_LINK_PROFILE optional; the workspace default otherwise.
|
|
28
|
+
* PORT injected by Cloud Run. 8080 is its default.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const env = process.env;
|
|
32
|
+
|
|
33
|
+
// Cloud Run routes to whatever the container listens on at $PORT, and health
|
|
34
|
+
// checks fail against a process bound to loopback.
|
|
35
|
+
const port = Number(env['PORT'] ?? 8080);
|
|
36
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
37
|
+
// Refuse rather than fall back to a default: a container listening somewhere
|
|
38
|
+
// other than where its platform routes is a startup probe that never passes,
|
|
39
|
+
// reported as a health failure with nothing to explain it.
|
|
40
|
+
process.stderr.write(`PORT is ${JSON.stringify(env['PORT'])}, which is not a port number.\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const host = env['LANES_LINK_HOST'] ?? '0.0.0.0';
|
|
44
|
+
|
|
45
|
+
const log = (message: string): void => {
|
|
46
|
+
process.stdout.write(`${new Date().toISOString()} ${message}\n`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const endpoint = await startEndpoint({
|
|
51
|
+
flags: {
|
|
52
|
+
...(env['LANES_LINK_PROFILE'] ? { profile: env['LANES_LINK_PROFILE'] } : {}),
|
|
53
|
+
...(env['LANES_LINK_TARGET'] ? { target: env['LANES_LINK_TARGET'] } : {}),
|
|
54
|
+
quiet: true,
|
|
55
|
+
},
|
|
56
|
+
port,
|
|
57
|
+
host,
|
|
58
|
+
// A deployed instance never mints its own token: see `endpoint.ts`.
|
|
59
|
+
mintToken: false,
|
|
60
|
+
// Stdout is where Cloud Run collects logs, and a rejected credential on a
|
|
61
|
+
// public URL is the event this exists for.
|
|
62
|
+
log: streamLogger((line) => process.stdout.write(`${line}\n`)),
|
|
63
|
+
reporter: {
|
|
64
|
+
reconciled({ profile, plan }) {
|
|
65
|
+
// Reconcile output is the record of what a deploy changed, and it is
|
|
66
|
+
// the only place that record exists — the config it applied is baked
|
|
67
|
+
// into an image that will be replaced.
|
|
68
|
+
log(`reconciled ${profile}\n${plan}`);
|
|
69
|
+
},
|
|
70
|
+
tokenMinted() {},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
log(`serving ${endpoint.url}`);
|
|
75
|
+
log(`profiles: ${endpoint.profiles.join(', ')}`);
|
|
76
|
+
|
|
77
|
+
// Cloud Run sends SIGTERM and then waits. Closing the runtimes is what writes
|
|
78
|
+
// each audit run's close marker, which is the difference between a run that
|
|
79
|
+
// ended and one whose tail was cut off — see ADR-020.
|
|
80
|
+
const shutdown = async (): Promise<void> => {
|
|
81
|
+
log('shutting down');
|
|
82
|
+
await endpoint.stop();
|
|
83
|
+
process.exit(0);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
process.on('SIGTERM', () => void shutdown());
|
|
87
|
+
process.on('SIGINT', () => void shutdown());
|
|
88
|
+
|
|
89
|
+
await new Promise(() => {});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// Exit non-zero so the revision fails to go healthy and Cloud Run keeps
|
|
92
|
+
// serving the previous one, rather than rolling out an endpoint that answers
|
|
93
|
+
// every request with an error.
|
|
94
|
+
process.stderr.write(`${(error as Error).message}\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { RateLimiter } from '#policy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A ceiling on failed authentication.
|
|
5
|
+
*
|
|
6
|
+
* Every credential that does not match the cached one costs a re-read of the
|
|
7
|
+
* credential store — a decrypt locally, a network call on a deployed instance.
|
|
8
|
+
* That re-read is what makes a rotated-in token work on its first call, and it
|
|
9
|
+
* is also a way to make the endpoint do expensive work on behalf of a caller
|
|
10
|
+
* holding no credential at all. This bounds it.
|
|
11
|
+
*
|
|
12
|
+
* Not a security boundary, and `#policy`'s own note says so: 256 bits behind
|
|
13
|
+
* `llk_` is what makes guessing hopeless. This is about cost, and about giving
|
|
14
|
+
* a sustained probe a shape in the log rather than an unbroken run of 401s.
|
|
15
|
+
*/
|
|
16
|
+
export const FAILED_AUTH_PER_MINUTE = 30;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Who an attempt is counted against.
|
|
20
|
+
*
|
|
21
|
+
* `x-forwarded-for`'s first hop is the client as the platform's front door saw
|
|
22
|
+
* it, which is the only address that means anything on Cloud Run — the socket
|
|
23
|
+
* peer there is Google's frontend, identical for every caller. It is trivially
|
|
24
|
+
* spoofable by anyone talking to the endpoint directly, which is why the
|
|
25
|
+
* fallback is a single shared bucket rather than something that looks
|
|
26
|
+
* per-caller and is not: a loopback endpoint has one caller, and an attacker
|
|
27
|
+
* who can already reach it can read the config file holding the token anyway.
|
|
28
|
+
*/
|
|
29
|
+
export function callerKey(request: Request): string {
|
|
30
|
+
return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'direct';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Refused for rate rather than for credential — deliberately a different status. */
|
|
34
|
+
export function tooManyAttempts(retryAfterMs: number): Response {
|
|
35
|
+
return new Response(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
error: 'too_many_requests',
|
|
38
|
+
hint: 'Too many failed authentication attempts. Wait, then retry.',
|
|
39
|
+
}),
|
|
40
|
+
{
|
|
41
|
+
status: 429,
|
|
42
|
+
headers: {
|
|
43
|
+
'content-type': 'application/json',
|
|
44
|
+
'retry-after': String(Math.max(1, Math.ceil(retryAfterMs / 1000))),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One bucket set per endpoint. Idle callers are dropped so keys do not accumulate. */
|
|
51
|
+
export function failedAuthLimiter(): RateLimiter {
|
|
52
|
+
return new RateLimiter();
|
|
53
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { MCP_PATH, serve } from './index.ts';
|
|
2
|
+
import { serveOverStdio } from './stdio.ts';
|
|
3
|
+
import { Generations, type OpenedWorkspace } from './generations.ts';
|
|
4
|
+
import type { AuthorizationSurface } from './oauth.ts';
|
|
5
|
+
import type { ProfileRuntime } from './mcp/index.ts';
|
|
6
|
+
import {
|
|
7
|
+
AuthenticatorChain,
|
|
8
|
+
IssuedTokenAuthenticator,
|
|
9
|
+
OAuthServer,
|
|
10
|
+
OAuthStore,
|
|
11
|
+
OidcAuthenticator,
|
|
12
|
+
OidcVerifier,
|
|
13
|
+
tokensMatch,
|
|
14
|
+
type Authenticator,
|
|
15
|
+
} from '#auth';
|
|
16
|
+
import type { Logger } from '#connectivity';
|
|
17
|
+
import { silentLogger } from './logging.ts';
|
|
18
|
+
import { listProfiles } from '#profile';
|
|
19
|
+
import {
|
|
20
|
+
applyReconcile,
|
|
21
|
+
formatPlan,
|
|
22
|
+
planIsNoop,
|
|
23
|
+
planReconcile,
|
|
24
|
+
toPolicyDocument,
|
|
25
|
+
} from '#registry';
|
|
26
|
+
import { ensureProfileToken, openRuntime, type GlobalFlags, type Runtime } from '#cli/runtime.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bringing the endpoint up: open a runtime per profile, reconcile, and serve.
|
|
30
|
+
*
|
|
31
|
+
* This is the bootstrap, and it exists as its own function because there are
|
|
32
|
+
* now two callers with genuinely different needs. `lanes link start` runs it on
|
|
33
|
+
* an operator's machine, prints to a terminal, and mints a profile token when
|
|
34
|
+
* none exists. The container entrypoint runs it under Cloud Run, logs to
|
|
35
|
+
* stdout, and must *not* mint anything — a token invented inside a container
|
|
36
|
+
* that scales to zero is one nobody can read back, so the endpoint would come
|
|
37
|
+
* up healthy and reject every agent.
|
|
38
|
+
*
|
|
39
|
+
* The alternative was for the image to shell out to `lanes link start`, which
|
|
40
|
+
* would have made the container's behaviour a side effect of a CLI command's
|
|
41
|
+
* presentation code. Everything below the reporter is identical on both paths,
|
|
42
|
+
* which is what makes "the same config with only the target switched" true.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface EndpointReporter {
|
|
46
|
+
/** A profile whose runtime state differed from its config, and what was applied. */
|
|
47
|
+
reconciled(input: { profile: string; plan: string; ofMany: boolean }): void;
|
|
48
|
+
/** No profile token existed and one was minted. */
|
|
49
|
+
tokenMinted(minted: { target: string }): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SILENT: EndpointReporter = { reconciled() {}, tokenMinted() {} };
|
|
53
|
+
|
|
54
|
+
export interface EndpointOptions {
|
|
55
|
+
readonly flags: GlobalFlags;
|
|
56
|
+
readonly port?: number | undefined;
|
|
57
|
+
readonly host?: string | undefined;
|
|
58
|
+
/** Serve just the resolved profile, rather than every profile in the workspace. */
|
|
59
|
+
readonly only?: boolean | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Mint a profile token when the store has none. True for `lanes link start`,
|
|
62
|
+
* false in a container — see the note above.
|
|
63
|
+
*/
|
|
64
|
+
readonly mintToken?: boolean | undefined;
|
|
65
|
+
readonly reporter?: EndpointReporter | undefined;
|
|
66
|
+
/** Operational events. Silent when absent, which is what the tests want. */
|
|
67
|
+
readonly log?: Logger | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RunningEndpoint {
|
|
71
|
+
readonly url: string;
|
|
72
|
+
readonly profiles: readonly string[];
|
|
73
|
+
stop(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Every profile this workspace serves, opened and reconciled.
|
|
78
|
+
*
|
|
79
|
+
* Shared by both transports because it is the whole of what "bring the
|
|
80
|
+
* workspace up" means — which profiles, in what state. What differs after it is
|
|
81
|
+
* only how they are reached: a port and a token, or a pipe.
|
|
82
|
+
*/
|
|
83
|
+
async function openReconciled(options: {
|
|
84
|
+
readonly flags: GlobalFlags;
|
|
85
|
+
readonly only?: boolean | undefined;
|
|
86
|
+
readonly reporter?: EndpointReporter | undefined;
|
|
87
|
+
}): Promise<{ primary: Runtime; runtimes: Map<string, Runtime> }> {
|
|
88
|
+
const reporter = options.reporter ?? SILENT;
|
|
89
|
+
const primary = await openRuntime(options.flags);
|
|
90
|
+
|
|
91
|
+
// One endpoint, every profile. The alternative — a port each — meant an agent
|
|
92
|
+
// registering N servers to reach N profiles, and the ports leaked a detail
|
|
93
|
+
// nobody wants to think about. What it costs is that a single token now
|
|
94
|
+
// reaches every profile and the caller names which, so the boundary between
|
|
95
|
+
// them is a policy decision per call rather than a separate URL.
|
|
96
|
+
const names = options.only
|
|
97
|
+
? [primary.resolution.profile]
|
|
98
|
+
: await listProfiles(primary.resolution.workspaceRoot);
|
|
99
|
+
|
|
100
|
+
const runtimes = new Map<string, Runtime>();
|
|
101
|
+
runtimes.set(primary.resolution.profile, primary);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
if (runtimes.has(name)) continue;
|
|
106
|
+
runtimes.set(name, await openRuntime({ ...options.flags, profile: name }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const [name, runtime] of runtimes) {
|
|
110
|
+
const result = await planReconcile(
|
|
111
|
+
runtime.config,
|
|
112
|
+
runtime.state,
|
|
113
|
+
runtime.credentials,
|
|
114
|
+
runtime.manifestFor,
|
|
115
|
+
);
|
|
116
|
+
if (!planIsNoop(result)) {
|
|
117
|
+
reporter.reconciled({
|
|
118
|
+
profile: name,
|
|
119
|
+
plan: formatPlan(result),
|
|
120
|
+
ofMany: runtimes.size > 1,
|
|
121
|
+
});
|
|
122
|
+
await applyReconcile(runtime.config, runtime.state, result);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { primary, runtimes };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// A runtime opened before the failure holds a state handle, and in a
|
|
129
|
+
// container a leaked pool keeps connections checked out of the pooler
|
|
130
|
+
// while the process crash-loops.
|
|
131
|
+
await closeAll(runtimes);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What the transports need from a runtime, which is less than a runtime. */
|
|
137
|
+
function profileRuntimes(runtimes: ReadonlyMap<string, Runtime>): Map<string, ProfileRuntime> {
|
|
138
|
+
return new Map(
|
|
139
|
+
[...runtimes].map(([name, runtime]) => [
|
|
140
|
+
name,
|
|
141
|
+
{
|
|
142
|
+
config: runtime.config,
|
|
143
|
+
registry: runtime.registry,
|
|
144
|
+
dispatcher: runtime.dispatcher,
|
|
145
|
+
policy: toPolicyDocument(runtime.config),
|
|
146
|
+
// So a skill written since this endpoint started — by an agent
|
|
147
|
+
// through `skills.manage.write`, or by `lanes link skills add` in another
|
|
148
|
+
// terminal — is a prompt without a restart (ADR-014).
|
|
149
|
+
refreshSkills: runtime.refreshSkills,
|
|
150
|
+
},
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function closeAll(runtimes: ReadonlyMap<string, Runtime>): Promise<unknown> {
|
|
156
|
+
return Promise.all([...runtimes.values()].map((runtime) => runtime.close()));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The remote-client gate, if this profile declares one.
|
|
161
|
+
*
|
|
162
|
+
* Endpoint-scoped rather than per profile, like the bearer token and for the
|
|
163
|
+
* same reason (ADR-009): one URL serves every profile in the workspace, so
|
|
164
|
+
* there is one place a client authorises and one set of tokens.
|
|
165
|
+
*
|
|
166
|
+
* Returns null when `auth.authorization` is absent, and everything downstream
|
|
167
|
+
* treats null as "exactly as before" — no metadata published, no pointer on the
|
|
168
|
+
* `401`, one authenticator instead of a chain.
|
|
169
|
+
*/
|
|
170
|
+
async function openAuthorization(
|
|
171
|
+
primary: Runtime,
|
|
172
|
+
): Promise<{ surface: AuthorizationSurface; authenticator: Authenticator } | null> {
|
|
173
|
+
const declared = primary.config.auth.authorization;
|
|
174
|
+
if (!declared) return null;
|
|
175
|
+
|
|
176
|
+
const profile = primary.resolution.profile;
|
|
177
|
+
|
|
178
|
+
if (declared.mode === 'oidc') {
|
|
179
|
+
const audience = await primary.credentials.get(declared.client_id_ref);
|
|
180
|
+
if (!audience) {
|
|
181
|
+
// Refuse rather than verify without an audience. A verifier that cannot
|
|
182
|
+
// check who a token was issued for accepts every token the issuer minted
|
|
183
|
+
// for anything, which is the failure this mode exists to prevent.
|
|
184
|
+
throw new Error(
|
|
185
|
+
`auth.authorization.client_id_ref names "${declared.client_id_ref}", which is not in ` +
|
|
186
|
+
`this target's credential store. Store it with: lanes link secrets set ${declared.client_id_ref}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const verifier = new OidcVerifier({
|
|
191
|
+
issuer: declared.issuer,
|
|
192
|
+
audience,
|
|
193
|
+
allowedSubjects: declared.allowed_subjects,
|
|
194
|
+
...(declared.introspection_endpoint
|
|
195
|
+
? { introspectionEndpoint: declared.introspection_endpoint }
|
|
196
|
+
: {}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
// The issuer is somebody else's origin, so it is a constant here rather
|
|
201
|
+
// than derived from the request.
|
|
202
|
+
surface: { issuer: () => declared.issuer, mcpPath: MCP_PATH, target: primary.target },
|
|
203
|
+
authenticator: new OidcAuthenticator(verifier, profile),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const store = new OAuthStore(primary.state.kv);
|
|
208
|
+
const expected = primary.config.auth.token_ref;
|
|
209
|
+
|
|
210
|
+
const server = new OAuthServer({
|
|
211
|
+
store,
|
|
212
|
+
accessTokenTtlMs: declared.access_token_ttl_minutes * 60_000,
|
|
213
|
+
// Approval is proof of holding the endpoint token, compared the same way
|
|
214
|
+
// the request path compares it. There is one person behind this endpoint
|
|
215
|
+
// and they already have exactly one credential; a second one invented for
|
|
216
|
+
// the consent screen would be a password to lose.
|
|
217
|
+
verifyOwner: async (presented) => {
|
|
218
|
+
const token = await primary.credentials.get(expected);
|
|
219
|
+
return token !== null && tokensMatch(presented, token);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
surface: { server, issuer: (origin) => origin, mcpPath: MCP_PATH, target: primary.target },
|
|
225
|
+
authenticator: new IssuedTokenAuthenticator(store, profile),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function startEndpoint(options: EndpointOptions): Promise<RunningEndpoint> {
|
|
230
|
+
const reporter = options.reporter ?? SILENT;
|
|
231
|
+
const { primary, runtimes } = await openReconciled(options);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
if (options.mintToken) {
|
|
235
|
+
const { created } = await ensureProfileToken(
|
|
236
|
+
primary.credentials,
|
|
237
|
+
primary.config.auth.token_ref,
|
|
238
|
+
);
|
|
239
|
+
if (created) reporter.tokenMinted({ target: primary.target });
|
|
240
|
+
} else {
|
|
241
|
+
// Deployed, the token is written by the operator's CLI into the target's
|
|
242
|
+
// credential store and read back here. Refusing to start beats serving an
|
|
243
|
+
// endpoint whose token nobody holds.
|
|
244
|
+
const token = await primary.credentials.get(primary.config.auth.token_ref);
|
|
245
|
+
if (!token) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`No profile token at "${primary.config.auth.token_ref}" in this target's credential store. ` +
|
|
248
|
+
'A deployed instance never mints its own — run `lanes link token rotate --target <target>` ' +
|
|
249
|
+
'from your machine, or `lanes link secrets push --from local --to cloud`, then redeploy.',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const gate = await openAuthorization(primary);
|
|
255
|
+
|
|
256
|
+
// The authenticator and the authorization gate are built once, from the
|
|
257
|
+
// runtime this endpoint booted with, and are deliberately not part of what
|
|
258
|
+
// a reload replaces (ADR-029). Retiring that generation stays safe for them
|
|
259
|
+
// because `Runtime.close()` ends connector sessions and the audit log — it
|
|
260
|
+
// does not close the credential store or the state handle these hold.
|
|
261
|
+
const generations = new Generations(
|
|
262
|
+
{ profiles: profileRuntimes(runtimes), close: () => closeAll(runtimes).then(() => {}) },
|
|
263
|
+
async (): Promise<OpenedWorkspace> => {
|
|
264
|
+
const reopened = await openReconciled(options);
|
|
265
|
+
return {
|
|
266
|
+
profiles: profileRuntimes(reopened.runtimes),
|
|
267
|
+
close: () => closeAll(reopened.runtimes).then(() => {}),
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
{ primary: primary.resolution.profile, log: { debug() {}, info() {}, warn() {}, error() {} } },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const server = serve({
|
|
274
|
+
generations,
|
|
275
|
+
primary: primary.resolution.profile,
|
|
276
|
+
authenticator: gate
|
|
277
|
+
? new AuthenticatorChain([primary.authenticator, gate.authenticator])
|
|
278
|
+
: primary.authenticator,
|
|
279
|
+
log: options.log ?? silentLogger(),
|
|
280
|
+
...(gate ? { authorization: gate.surface } : {}),
|
|
281
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
282
|
+
...(options.host !== undefined ? { host: options.host } : {}),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
url: server.url,
|
|
287
|
+
profiles: [...runtimes.keys()],
|
|
288
|
+
// `server.stop()` closes the request handler, which closes whichever
|
|
289
|
+
// generation is current — and a generation owns the runtimes it opened.
|
|
290
|
+
// Closing `runtimes` here too would reach past a reload and close a set
|
|
291
|
+
// nothing is serving from any more.
|
|
292
|
+
stop: () => server.stop(),
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
await closeAll(runtimes);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface StdioEndpointOptions {
|
|
301
|
+
readonly flags: GlobalFlags;
|
|
302
|
+
/** Serve just the resolved profile, rather than every profile in the workspace. */
|
|
303
|
+
readonly only?: boolean | undefined;
|
|
304
|
+
/**
|
|
305
|
+
* Where reconcile output goes.
|
|
306
|
+
*
|
|
307
|
+
* Not the terminal, on this path: stdout carries JSON-RPC, and a single line
|
|
308
|
+
* written to it corrupts the stream for the client. The caller reports to
|
|
309
|
+
* stderr or not at all.
|
|
310
|
+
*/
|
|
311
|
+
readonly reporter?: EndpointReporter | undefined;
|
|
312
|
+
readonly log?: Logger | undefined;
|
|
313
|
+
readonly clientLabel?: string | undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface RunningStdioEndpoint {
|
|
317
|
+
readonly profiles: readonly string[];
|
|
318
|
+
stop(): Promise<void>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The same workspace, served over this process's stdin and stdout.
|
|
323
|
+
*
|
|
324
|
+
* No port, no token, no reachability question — see `stdio.ts` for why the pipe
|
|
325
|
+
* replaces the authenticator. Used by clients that cannot be handed a URL at
|
|
326
|
+
* all, which is most desktop applications.
|
|
327
|
+
*/
|
|
328
|
+
export async function startStdioEndpoint(
|
|
329
|
+
options: StdioEndpointOptions,
|
|
330
|
+
): Promise<RunningStdioEndpoint> {
|
|
331
|
+
const { primary, runtimes } = await openReconciled(options);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const surface = serveOverStdio({
|
|
335
|
+
profiles: profileRuntimes(runtimes),
|
|
336
|
+
primary: primary.resolution.profile,
|
|
337
|
+
log: options.log ?? silentLogger(),
|
|
338
|
+
...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
profiles: [...runtimes.keys()],
|
|
343
|
+
async stop() {
|
|
344
|
+
await surface.close();
|
|
345
|
+
await closeAll(runtimes);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
} catch (error) {
|
|
349
|
+
await closeAll(runtimes);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|