@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,192 @@
|
|
|
1
|
+
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
2
|
+
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server';
|
|
3
|
+
import { ownerPrincipal } from '#auth';
|
|
4
|
+
import type { Logger } from '#connectivity';
|
|
5
|
+
import {
|
|
6
|
+
buildMcpServer,
|
|
7
|
+
capabilityIdForToolName,
|
|
8
|
+
toolNameFor,
|
|
9
|
+
visibleCapabilities,
|
|
10
|
+
type ProfileRuntime,
|
|
11
|
+
} from './mcp/index.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The stdio surface.
|
|
15
|
+
*
|
|
16
|
+
* Same registry, same policy, same audit as the HTTP endpoint — a different
|
|
17
|
+
* boundary. Over HTTP a caller proves who they are with a bearer token, because
|
|
18
|
+
* anyone who can reach the port might be anyone. Over stdio the pipe *is* the
|
|
19
|
+
* proof: this process was spawned by the client holding the other end of it, as
|
|
20
|
+
* the operator, with no port for anyone else to reach. There is nothing left to
|
|
21
|
+
* authenticate, so this path has no authenticator and no token — it serves the
|
|
22
|
+
* owner principal directly.
|
|
23
|
+
*
|
|
24
|
+
* It exists because a client can be unable to speak HTTP at all. Claude
|
|
25
|
+
* Desktop's `claude_desktop_config.json` validates each entry against
|
|
26
|
+
* `{ command, args?, env?, extensionId? }` — a `url` is not a field it has, so
|
|
27
|
+
* "point it at the endpoint" is not available and something must speak MCP on
|
|
28
|
+
* its stdin and stdout.
|
|
29
|
+
*
|
|
30
|
+
* Two differences from HTTP are worth knowing, and neither is incidental:
|
|
31
|
+
*
|
|
32
|
+
* - **The tool list is fixed for the connection.** `createMcpHandler` builds a
|
|
33
|
+
* fresh server per request, so an HTTP caller sees a skill added a moment ago.
|
|
34
|
+
* `serveStdio` pins one instance for the life of the pipe (the era decision is
|
|
35
|
+
* made once, at the opening exchange), so a skill added mid-session appears on
|
|
36
|
+
* the next launch. A client that spawns this per session — which is how they
|
|
37
|
+
* all work — gets the current list every time it starts.
|
|
38
|
+
* - **One connection, one process.** The client owns the lifetime: it spawns the
|
|
39
|
+
* process, and closing the pipe ends it.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface StdioOptions {
|
|
43
|
+
/** Every profile this surface serves, keyed by name. */
|
|
44
|
+
readonly profiles: ReadonlyMap<string, ProfileRuntime>;
|
|
45
|
+
/** Which profile owns the connection — the one a call defaults to naming. */
|
|
46
|
+
readonly primary: string;
|
|
47
|
+
readonly log: Logger;
|
|
48
|
+
readonly version?: string | undefined;
|
|
49
|
+
readonly clientLabel?: string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Bring your own transport, rather than this process's stdin and stdout.
|
|
52
|
+
*
|
|
53
|
+
* A test drives the real surface over a pair of in-memory streams with it. It
|
|
54
|
+
* is the same seam the SDK offers for serving stdio over a socket.
|
|
55
|
+
*/
|
|
56
|
+
readonly transport?: Transport | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StdioSurface {
|
|
60
|
+
/** Tears down the connection and the server instance pinned to it. */
|
|
61
|
+
close(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function serveOverStdio(options: StdioOptions): StdioSurface {
|
|
65
|
+
if (!options.profiles.has(options.primary)) {
|
|
66
|
+
throw new Error(`Profile "${options.primary}" is not among those being served.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const principal = ownerPrincipal(options.primary);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Wire names this principal may see, and every capability id behind them.
|
|
73
|
+
*
|
|
74
|
+
* Computed once because the pinned instance's tool list is fixed for the
|
|
75
|
+
* connection anyway — the memoisation the HTTP path needs exists only because
|
|
76
|
+
* it rebuilds per request.
|
|
77
|
+
*/
|
|
78
|
+
const visible = new Set(
|
|
79
|
+
visibleCapabilities({ profiles: options.profiles, principal }).map(toolNameFor),
|
|
80
|
+
);
|
|
81
|
+
const allCapabilityIds = [
|
|
82
|
+
...new Set(
|
|
83
|
+
[...options.profiles.values()].flatMap((runtime) =>
|
|
84
|
+
runtime.registry.capabilities().map(({ id }) => id),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const handle = serveStdio(
|
|
90
|
+
() =>
|
|
91
|
+
buildMcpServer({
|
|
92
|
+
profiles: options.profiles,
|
|
93
|
+
principal,
|
|
94
|
+
...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
|
|
95
|
+
...(options.version ? { version: options.version } : {}),
|
|
96
|
+
}),
|
|
97
|
+
{
|
|
98
|
+
transport: auditRefusals(options.transport ?? new StdioServerTransport(), (message) =>
|
|
99
|
+
recordRefusal(message, options, visible, allCapabilityIds),
|
|
100
|
+
),
|
|
101
|
+
onerror: (error: Error) => options.log.error('mcp stdio error', { message: error.message }),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return { close: () => handle.close() };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A call naming a tool policy filtering hid, recorded as a refusal.
|
|
110
|
+
*
|
|
111
|
+
* The same trace the HTTP edge writes, from the only place this transport can
|
|
112
|
+
* write it. Policy-filtered discovery means an unpermitted tool is never
|
|
113
|
+
* advertised, so the protocol layer answers a call naming one before any of our
|
|
114
|
+
* code runs — and it would otherwise leave no trace at all, which is what
|
|
115
|
+
* `audit.every-invocation` in `docs/detailed/security.md` promises it does not.
|
|
116
|
+
*
|
|
117
|
+
* HTTP reads the method and target from the 2026-07-28 headers, which is exact
|
|
118
|
+
* for an envelope client and blind to a 2025-era one — a documented gap over
|
|
119
|
+
* there, and not one here. There are no headers on a pipe, so this reads the
|
|
120
|
+
* body unconditionally, which is cheap because the transport has already parsed
|
|
121
|
+
* it into a message.
|
|
122
|
+
*/
|
|
123
|
+
function recordRefusal(
|
|
124
|
+
message: JSONRPCMessage,
|
|
125
|
+
options: StdioOptions,
|
|
126
|
+
visible: ReadonlySet<string>,
|
|
127
|
+
allCapabilityIds: readonly string[],
|
|
128
|
+
): void {
|
|
129
|
+
if (!('method' in message) || !('id' in message)) return;
|
|
130
|
+
if (message.method !== 'tools/call' && message.method !== 'prompts/get') return;
|
|
131
|
+
|
|
132
|
+
const params = (message as { params?: { name?: unknown } }).params;
|
|
133
|
+
const toolName = typeof params?.name === 'string' ? params.name : undefined;
|
|
134
|
+
if (!toolName || visible.has(toolName)) return;
|
|
135
|
+
|
|
136
|
+
// Not awaited: `onmessage` is synchronous, and holding the message back to
|
|
137
|
+
// wait on a store would delay the refusal the caller is owed. The row lands
|
|
138
|
+
// just after the error does.
|
|
139
|
+
//
|
|
140
|
+
// `resources/read` is absent for the same reason it is absent over HTTP: a
|
|
141
|
+
// concrete URI does not match a wire name, so recovering the capability id
|
|
142
|
+
// means matching it against every registered template. `resources.test.ts`
|
|
143
|
+
// asserts that gap rather than hiding it.
|
|
144
|
+
void options.profiles
|
|
145
|
+
.get(options.primary)!
|
|
146
|
+
.dispatcher.recordRefusal({
|
|
147
|
+
principal: ownerPrincipal(options.primary),
|
|
148
|
+
capabilityId: capabilityIdForToolName(toolName, allCapabilityIds),
|
|
149
|
+
...(options.clientLabel ? { clientLabel: options.clientLabel } : {}),
|
|
150
|
+
})
|
|
151
|
+
.catch((error: Error) =>
|
|
152
|
+
options.log.warn('could not record refusal', { message: error.message }),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The transport, with a look at every inbound message on its way past.
|
|
158
|
+
*
|
|
159
|
+
* A wrapper rather than a subclass because the SDK owns the transport it is
|
|
160
|
+
* handed: it installs its own `onmessage`, starts it, and closes it. Assigning
|
|
161
|
+
* the inner transport's callbacks here and forwarding them keeps that ownership
|
|
162
|
+
* intact — the entry sees exactly what the pipe delivered, in the same order.
|
|
163
|
+
*/
|
|
164
|
+
function auditRefusals(inner: Transport, inspect: (message: JSONRPCMessage) => void): Transport {
|
|
165
|
+
const outer: Transport = {
|
|
166
|
+
start: () => inner.start(),
|
|
167
|
+
send: (message, sendOptions) => inner.send(message, sendOptions),
|
|
168
|
+
close: () => inner.close(),
|
|
169
|
+
...(inner.setProtocolVersion
|
|
170
|
+
? { setProtocolVersion: (version: string) => inner.setProtocolVersion!(version) }
|
|
171
|
+
: {}),
|
|
172
|
+
...(inner.setSupportedProtocolVersions
|
|
173
|
+
? {
|
|
174
|
+
setSupportedProtocolVersions: (versions: string[]) =>
|
|
175
|
+
inner.setSupportedProtocolVersions!(versions),
|
|
176
|
+
}
|
|
177
|
+
: {}),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
inner.onmessage = (message, extra) => {
|
|
181
|
+
try {
|
|
182
|
+
inspect(message);
|
|
183
|
+
} catch {
|
|
184
|
+
// An audit attempt must never cost the caller their message.
|
|
185
|
+
}
|
|
186
|
+
outer.onmessage?.(message, extra);
|
|
187
|
+
};
|
|
188
|
+
inner.onclose = () => outer.onclose?.();
|
|
189
|
+
inner.onerror = (error) => outer.onerror?.(error);
|
|
190
|
+
|
|
191
|
+
return outer;
|
|
192
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { scopeBlobStore, type BlobStore } from './index.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `BlobStore` contract, once, run against every adapter.
|
|
6
|
+
*
|
|
7
|
+
* `docs/detailed/init.md` promises that no application-layer code differs between the
|
|
8
|
+
* local and deployed targets. For blobs that promise now has a consumer —
|
|
9
|
+
* `providers/owner/src/memory.ts` stores entry bodies here — and it is only
|
|
10
|
+
* worth something if the adapters actually behave identically. A per-adapter
|
|
11
|
+
* test file cannot say so; it can only say each one passes the tests someone
|
|
12
|
+
* remembered to write for it. This suite is the assertion itself: filesystem,
|
|
13
|
+
* S3, and the in-memory store are held to one set of behaviours, and an
|
|
14
|
+
* adapter that diverges fails here rather than in production on whichever
|
|
15
|
+
* target the operator happened to deploy.
|
|
16
|
+
*
|
|
17
|
+
* Adapter-specific mechanism — write-then-rename, `.meta` sidecars,
|
|
18
|
+
* `ListObjectsV2` pagination — stays in the adapter's own test file. What
|
|
19
|
+
* lives here is behaviour every caller can rely on regardless of where it is
|
|
20
|
+
* running.
|
|
21
|
+
*
|
|
22
|
+
* Not exported from `package.json`: this imports `bun:test`, so it is
|
|
23
|
+
* reachable only by relative path from a test file, the same way `testing.ts`
|
|
24
|
+
* is kept away from application code.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ContractBlobStore {
|
|
28
|
+
/** A handle on this store. */
|
|
29
|
+
open(): BlobStore;
|
|
30
|
+
dispose(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
34
|
+
const text = (data: Uint8Array | null) => (data ? new TextDecoder().decode(data) : null);
|
|
35
|
+
|
|
36
|
+
export function describeBlobStoreContract(
|
|
37
|
+
name: string,
|
|
38
|
+
createStore: () => Promise<ContractBlobStore>,
|
|
39
|
+
): void {
|
|
40
|
+
/** Open a fresh store, run against it, and clean up either way. */
|
|
41
|
+
const use = async (run: (store: BlobStore) => Promise<void>): Promise<void> => {
|
|
42
|
+
const handle = await createStore();
|
|
43
|
+
try {
|
|
44
|
+
await run(handle.open());
|
|
45
|
+
} finally {
|
|
46
|
+
await handle.dispose();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe(`${name}: round trip`, () => {
|
|
51
|
+
test('stores, reads, and deletes', async () => {
|
|
52
|
+
await use(async (store) => {
|
|
53
|
+
expect(await store.get('note.txt')).toBeNull();
|
|
54
|
+
expect(await store.has('note.txt')).toBe(false);
|
|
55
|
+
|
|
56
|
+
await store.put('note.txt', bytes('hello'));
|
|
57
|
+
expect(text(await store.get('note.txt'))).toBe('hello');
|
|
58
|
+
expect(await store.has('note.txt')).toBe(true);
|
|
59
|
+
|
|
60
|
+
await store.delete('note.txt');
|
|
61
|
+
expect(await store.get('note.txt')).toBeNull();
|
|
62
|
+
expect(await store.has('note.txt')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('accepts a nested key without the caller creating anything first', async () => {
|
|
67
|
+
await use(async (store) => {
|
|
68
|
+
await store.put('a/b/c/deep.txt', bytes('deep'));
|
|
69
|
+
expect(text(await store.get('a/b/c/deep.txt'))).toBe('deep');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('overwrites in place', async () => {
|
|
74
|
+
await use(async (store) => {
|
|
75
|
+
await store.put('one.txt', bytes('1'));
|
|
76
|
+
await store.put('one.txt', bytes('overwritten'));
|
|
77
|
+
|
|
78
|
+
expect(text(await store.get('one.txt'))).toBe('overwritten');
|
|
79
|
+
expect((await store.list()).map((entry) => entry.key)).toEqual(['one.txt']);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('deleting something absent is not an error', async () => {
|
|
84
|
+
await use(async (store) => {
|
|
85
|
+
await store.delete('never-existed.txt');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('round-trips binary data unchanged', async () => {
|
|
90
|
+
await use(async (store) => {
|
|
91
|
+
const data = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
|
92
|
+
|
|
93
|
+
await store.put('raw.bin', data);
|
|
94
|
+
expect(await store.get('raw.bin')).toEqual(data);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('round-trips an empty blob rather than reporting it absent', async () => {
|
|
99
|
+
await use(async (store) => {
|
|
100
|
+
await store.put('empty.bin', new Uint8Array([]));
|
|
101
|
+
|
|
102
|
+
expect(await store.has('empty.bin')).toBe(true);
|
|
103
|
+
expect(await store.get('empty.bin')).toEqual(new Uint8Array([]));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe(`${name}: listing`, () => {
|
|
109
|
+
test('lists recursively, sorted, with forward-slash keys', async () => {
|
|
110
|
+
await use(async (store) => {
|
|
111
|
+
await store.put('one.txt', bytes('1'));
|
|
112
|
+
await store.put('nested/two.txt', bytes('22'));
|
|
113
|
+
await store.put('nested/deeper/three.txt', bytes('333'));
|
|
114
|
+
|
|
115
|
+
expect((await store.list()).map((entry) => entry.key)).toEqual([
|
|
116
|
+
'nested/deeper/three.txt',
|
|
117
|
+
'nested/two.txt',
|
|
118
|
+
'one.txt',
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('filters by prefix and reports sizes', async () => {
|
|
124
|
+
await use(async (store) => {
|
|
125
|
+
await store.put('a/one.txt', bytes('1'));
|
|
126
|
+
await store.put('b/two.txt', bytes('22'));
|
|
127
|
+
|
|
128
|
+
const listed = await store.list('a/');
|
|
129
|
+
expect(listed.map((entry) => entry.key)).toEqual(['a/one.txt']);
|
|
130
|
+
expect(listed[0]?.size).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('reports the content type it was given', async () => {
|
|
135
|
+
await use(async (store) => {
|
|
136
|
+
await store.put('doc.txt', bytes('x'), { contentType: 'text/plain' });
|
|
137
|
+
|
|
138
|
+
const listed = await store.list();
|
|
139
|
+
expect(listed.map((entry) => entry.key)).toEqual(['doc.txt']);
|
|
140
|
+
expect(listed[0]?.contentType).toContain('text/plain');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('listing an empty store returns nothing rather than throwing', async () => {
|
|
145
|
+
await use(async (store) => {
|
|
146
|
+
expect(await store.list()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a deleted blob leaves the key space', async () => {
|
|
151
|
+
await use(async (store) => {
|
|
152
|
+
await store.put('gone.txt', bytes('x'));
|
|
153
|
+
await store.delete('gone.txt');
|
|
154
|
+
|
|
155
|
+
expect(await store.list()).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe(`${name}: containment`, () => {
|
|
161
|
+
test('refuses a key that resolves outside the store root', async () => {
|
|
162
|
+
await use(async (store) => {
|
|
163
|
+
// `scopeBlobStore` rejects these earlier, but an adapter is usable
|
|
164
|
+
// directly and containment belongs where a key becomes an address.
|
|
165
|
+
await expect(store.get('../outside.txt')).rejects.toThrow(/outside the store root/);
|
|
166
|
+
await expect(store.put('a/../../outside.txt', bytes('x'))).rejects.toThrow(
|
|
167
|
+
/outside the store root/,
|
|
168
|
+
);
|
|
169
|
+
await expect(store.delete('../../etc/passwd')).rejects.toThrow(/outside the store root/);
|
|
170
|
+
await expect(store.has('../outside.txt')).rejects.toThrow(/outside the store root/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('refuses a key that resolves to the root itself', async () => {
|
|
175
|
+
await use(async (store) => {
|
|
176
|
+
await expect(store.get('.')).rejects.toThrow(/outside the store root/);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('allows a traversal that stays inside, normalised', async () => {
|
|
181
|
+
await use(async (store) => {
|
|
182
|
+
// Weaker than `scopeBlobStore`'s rule on purpose: the namespace
|
|
183
|
+
// boundary refuses a `..` segment outright, an adapter only has to
|
|
184
|
+
// know where the key lands.
|
|
185
|
+
await store.put('a/../b.txt', bytes('inside'));
|
|
186
|
+
|
|
187
|
+
expect(text(await store.get('b.txt'))).toBe('inside');
|
|
188
|
+
expect((await store.list()).map((entry) => entry.key)).toEqual(['b.txt']);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe(`${name}: composed with namespace scoping`, () => {
|
|
194
|
+
test('two connections on one store cannot see each other', async () => {
|
|
195
|
+
await use(async (base) => {
|
|
196
|
+
const a = scopeBlobStore(base, 'example/a');
|
|
197
|
+
const b = scopeBlobStore(base, 'example/b');
|
|
198
|
+
|
|
199
|
+
await a.put('note.txt', bytes('from a'));
|
|
200
|
+
await b.put('note.txt', bytes('from b'));
|
|
201
|
+
|
|
202
|
+
expect(text(await a.get('note.txt'))).toBe('from a');
|
|
203
|
+
expect(text(await b.get('note.txt'))).toBe('from b');
|
|
204
|
+
expect((await a.list()).map((entry) => entry.key)).toEqual(['note.txt']);
|
|
205
|
+
expect(await a.has('note.txt')).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('deleting through one namespace leaves the other alone', async () => {
|
|
210
|
+
await use(async (base) => {
|
|
211
|
+
const a = scopeBlobStore(base, 'example/a');
|
|
212
|
+
const b = scopeBlobStore(base, 'example/b');
|
|
213
|
+
|
|
214
|
+
await a.put('note.txt', bytes('from a'));
|
|
215
|
+
await b.put('note.txt', bytes('from b'));
|
|
216
|
+
await a.delete('note.txt');
|
|
217
|
+
|
|
218
|
+
expect(await a.get('note.txt')).toBeNull();
|
|
219
|
+
expect(text(await b.get('note.txt'))).toBe('from b');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blob storage.
|
|
5
|
+
*
|
|
6
|
+
* No provider consumes this in M1 or M2 — the example and Gmail read providers
|
|
7
|
+
* store no bytes. The interface and the filesystem adapter exist now because
|
|
8
|
+
* the owner layer (M3: memory attachments, vault documents) is the workload it
|
|
9
|
+
* was defined for, and because retrofitting a storage interface after
|
|
10
|
+
* providers exist is more expensive than defining it before.
|
|
11
|
+
*
|
|
12
|
+
* Do not add S3, Azure, or GCS adapters until a provider actually writes bytes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A blob key. Always relative to whatever namespace the caller was scoped to. */
|
|
16
|
+
export type BlobKey = string;
|
|
17
|
+
|
|
18
|
+
export interface BlobMetadata {
|
|
19
|
+
readonly key: BlobKey;
|
|
20
|
+
readonly size: number;
|
|
21
|
+
readonly contentType?: string;
|
|
22
|
+
readonly modifiedAt: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BlobStore {
|
|
26
|
+
put(key: BlobKey, data: Uint8Array, options?: { contentType?: string }): Promise<void>;
|
|
27
|
+
get(key: BlobKey): Promise<Uint8Array | null>;
|
|
28
|
+
has(key: BlobKey): Promise<boolean>;
|
|
29
|
+
delete(key: BlobKey): Promise<void>;
|
|
30
|
+
list(prefix?: string): Promise<BlobMetadata[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Restrict a store to a key namespace.
|
|
35
|
+
*
|
|
36
|
+
* Providers never receive a raw `BlobStore`; they receive one scoped to
|
|
37
|
+
* `<provider>/<connection>`, so one connection's bytes are not addressable from
|
|
38
|
+
* another. The scoping is enforced here rather than trusted to the provider.
|
|
39
|
+
*/
|
|
40
|
+
export function scopeBlobStore(base: BlobStore, namespace: string): BlobStore {
|
|
41
|
+
const prefix = normaliseNamespace(namespace);
|
|
42
|
+
const scoped = (key: BlobKey): BlobKey => {
|
|
43
|
+
assertSafeKey(key);
|
|
44
|
+
return `${prefix}${key}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Every method is `async` on purpose. A key-validation failure must surface
|
|
48
|
+
// as a rejected promise, matching the declared interface — if these threw
|
|
49
|
+
// synchronously, callers would have to both try/catch and .catch() the same
|
|
50
|
+
// call, and one of the two would inevitably be forgotten.
|
|
51
|
+
return {
|
|
52
|
+
async put(key, data, options) {
|
|
53
|
+
return base.put(scoped(key), data, options);
|
|
54
|
+
},
|
|
55
|
+
async get(key) {
|
|
56
|
+
return base.get(scoped(key));
|
|
57
|
+
},
|
|
58
|
+
async has(key) {
|
|
59
|
+
return base.has(scoped(key));
|
|
60
|
+
},
|
|
61
|
+
async delete(key) {
|
|
62
|
+
return base.delete(scoped(key));
|
|
63
|
+
},
|
|
64
|
+
async list(innerPrefix) {
|
|
65
|
+
const entries = await base.list(`${prefix}${innerPrefix ?? ''}`);
|
|
66
|
+
return entries.map((entry) => ({ ...entry, key: entry.key.slice(prefix.length) }));
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normaliseNamespace(namespace: string): string {
|
|
72
|
+
if (namespace.length === 0) throw new Error('Blob namespace must not be empty');
|
|
73
|
+
return namespace.endsWith('/') ? namespace : `${namespace}/`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A virtual root deep enough that `..` can actually escape it.
|
|
78
|
+
*
|
|
79
|
+
* `posix.resolve('/', '../x')` clamps to `/x` — the traversal disappears
|
|
80
|
+
* instead of being caught — so resolving against `/` would silently accept
|
|
81
|
+
* every key a real store rejects.
|
|
82
|
+
*/
|
|
83
|
+
const CONTAINMENT_ROOT = '/blobs';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a key to its canonical form, refusing anything that escapes the
|
|
87
|
+
* store.
|
|
88
|
+
*
|
|
89
|
+
* This is the *adapter's* containment rule, and it is deliberately not
|
|
90
|
+
* `assertSafeKey` above. That one guards the namespace boundary and so refuses
|
|
91
|
+
* a `..` segment outright, because a provider writing one has a bug worth
|
|
92
|
+
* surfacing. This one asks the weaker question an adapter has to ask — does
|
|
93
|
+
* the key, once resolved, still land inside the store — so `a/../b` normalises
|
|
94
|
+
* to `b` rather than failing.
|
|
95
|
+
*
|
|
96
|
+
* It lives here so every adapter answers identically. An operator who switches
|
|
97
|
+
* targets must not find that a key their local store refused is one their
|
|
98
|
+
* deployed store happily writes; `conformance.ts` asserts exactly that, and it
|
|
99
|
+
* can only do so if there is one rule rather than one per adapter.
|
|
100
|
+
*/
|
|
101
|
+
export function containedKey(key: BlobKey): BlobKey {
|
|
102
|
+
if (key.includes('\0')) throw new Error('Blob key must not contain a NUL byte');
|
|
103
|
+
|
|
104
|
+
const resolved = posix.resolve(CONTAINMENT_ROOT, key);
|
|
105
|
+
const relative = posix.relative(CONTAINMENT_ROOT, resolved);
|
|
106
|
+
|
|
107
|
+
if (relative === '' || relative === '..' || relative.startsWith('../')) {
|
|
108
|
+
throw new Error(`Blob key resolves outside the store root: ${key}`);
|
|
109
|
+
}
|
|
110
|
+
return relative;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A provider must not be able to escape its namespace with `../`, an absolute
|
|
115
|
+
* path, or a NUL byte. This is a boundary, so it throws rather than sanitising
|
|
116
|
+
* — silently rewriting a traversal attempt hides a provider bug.
|
|
117
|
+
*/
|
|
118
|
+
function assertSafeKey(key: BlobKey): void {
|
|
119
|
+
if (key.length === 0) throw new Error('Blob key must not be empty');
|
|
120
|
+
if (key.startsWith('/')) throw new Error(`Blob key must be relative: ${key}`);
|
|
121
|
+
if (key.includes('\0')) throw new Error('Blob key must not contain a NUL byte');
|
|
122
|
+
if (key.split('/').includes('..')) {
|
|
123
|
+
throw new Error(`Blob key must not traverse outside its namespace: ${key}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory `BlobStore` for tests.
|
|
3
|
+
*
|
|
4
|
+
* Test-only, behind a separate entry point so application code cannot reach it
|
|
5
|
+
* by accident. The filesystem and S3 adapters are the real ones.
|
|
6
|
+
*
|
|
7
|
+
* It goes through `containedKey` like they do. A Map cannot be escaped, so
|
|
8
|
+
* containment buys it no safety — but a provider exercised against this store
|
|
9
|
+
* and shipped against a real one must not discover that a key it relies on is
|
|
10
|
+
* refused in production. `conformance.ts` holds all three to the one rule.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { containedKey, type BlobMetadata, type BlobStore } from './index.ts';
|
|
14
|
+
|
|
15
|
+
export function createMemoryBlobStore(): BlobStore {
|
|
16
|
+
const blobs = new Map<string, { data: Uint8Array; contentType?: string; modifiedAt: Date }>();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
async put(key, data, options) {
|
|
20
|
+
blobs.set(containedKey(key), {
|
|
21
|
+
data,
|
|
22
|
+
...(options?.contentType ? { contentType: options.contentType } : {}),
|
|
23
|
+
modifiedAt: new Date(),
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
async get(key) {
|
|
27
|
+
return blobs.get(containedKey(key))?.data ?? null;
|
|
28
|
+
},
|
|
29
|
+
async has(key) {
|
|
30
|
+
return blobs.has(containedKey(key));
|
|
31
|
+
},
|
|
32
|
+
async delete(key) {
|
|
33
|
+
blobs.delete(containedKey(key));
|
|
34
|
+
},
|
|
35
|
+
async list(prefix) {
|
|
36
|
+
const out: BlobMetadata[] = [];
|
|
37
|
+
for (const [key, blob] of blobs) {
|
|
38
|
+
if (prefix && !key.startsWith(prefix)) continue;
|
|
39
|
+
out.push({
|
|
40
|
+
key,
|
|
41
|
+
size: blob.data.byteLength,
|
|
42
|
+
modifiedAt: blob.modifiedAt,
|
|
43
|
+
...(blob.contentType ? { contentType: blob.contentType } : {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|