@skanl/brambo-session 0.1.1

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.
@@ -0,0 +1,264 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { createRequire } from 'node:module';
10
+ import { join, resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { BRAMBO_ERROR_CODES, BramboError, activateMethod, validateMethodPlugin, METHOD_CONFIG_KEY, } from '@skanl/brambo-contracts';
13
+ // The method half of brambo's own selection space (FR-28 / UJ-3).
14
+ //
15
+ // A method is NOT registry vocabulary. `packages/contracts/src/registry.ts`
16
+ // defines that vocabulary as "every word here reaches an executor ... exactly
17
+ // the two kinds the projection layer renders", and a method reaches no
18
+ // executor: brambo mounts it in its own process. So the selection lives beside
19
+ // the executor selection in `<scope>/.brambo/config.json`, and this file is what
20
+ // turns that string into something mounted.
21
+ //
22
+ // This is also the ONLY dynamic import in brambo, and it is here rather than in
23
+ // the kernel because the kernel refuses to be a loader in writing
24
+ // (`packages/kernel/src/manifest.ts`: "no fs, network, env reads, or dynamic
25
+ // imports"). Consumer tier owns loading; the kernel owns lifecycle.
26
+ /**
27
+ * What `import()` is actually given, resolved from the USER's directory.
28
+ *
29
+ * `import(specifier)` resolves relative to the module that calls it, so a
30
+ * dot-relative specifier handed straight over searched `packages/session/src/` —
31
+ * meaning no relative specifier could ever work, which is the ordinary way a
32
+ * local method is named. Found by running the binary while this package's suite
33
+ * was green, because every test passed a `file://` URL and sidestepped
34
+ * resolution.
35
+ *
36
+ * The example is DESCRIBED rather than written out, and that is not fussiness:
37
+ * `relativeSpecifiers` in `test/consumer-install.proof.ts` regexes raw source
38
+ * for a dot-relative specifier and does not strip comments, so a literal one in
39
+ * this JSDoc becomes a phantom import the packed `.d.ts` "reaches" and the
40
+ * tarball cannot contain. It failed CI on both jobs exactly that way, which is
41
+ * the same shape as the doc comment that tripped M5.A's printed-command scan.
42
+ *
43
+ * A relative path is resolved against `baseDir` and handed over as a file URL.
44
+ * A BARE specifier goes through `createRequire(baseDir)`, so a method installed
45
+ * in the user's project is found there rather than in brambo's own
46
+ * `node_modules`; if that throws — an ESM-only package with no CJS-resolvable
47
+ * entry — the bare specifier is passed through unchanged, which at least gives
48
+ * such a package a path instead of a guaranteed refusal.
49
+ */
50
+ function resolveFrom(specifier, baseDir) {
51
+ if (specifier.startsWith('.'))
52
+ return pathToFileURL(resolve(baseDir, specifier)).href;
53
+ if (specifier.startsWith('file:'))
54
+ return specifier;
55
+ try {
56
+ return pathToFileURL(createRequire(join(baseDir, 'brambo.method.js')).resolve(specifier)).href;
57
+ }
58
+ catch {
59
+ return specifier;
60
+ }
61
+ }
62
+ /** A specifier that could not be loaded, named so the message is actionable. */
63
+ function unloadable(specifier, detail, cause) {
64
+ return new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `brambo could not load the method '${specifier}': ${detail}`, cause === undefined ? undefined : { cause });
65
+ }
66
+ /**
67
+ * A module's method export, whichever way its author shipped it.
68
+ *
69
+ * `default` first, then the namespace itself: a TypeScript author writes
70
+ * `export default`, and a CommonJS interop build hangs the same object off
71
+ * `module.exports`. Unwrapping BOTH is what cordis's loader does for the same
72
+ * reason, and getting it wrong reads to the author as "brambo rejected my valid
73
+ * plugin" rather than as an interop detail.
74
+ */
75
+ function unwrap(namespace) {
76
+ if (namespace === null || typeof namespace !== 'object')
77
+ return namespace;
78
+ const withDefault = namespace;
79
+ return withDefault.default ?? namespace;
80
+ }
81
+ /**
82
+ * Loads a MethodPlugin from a module specifier and validates it against the
83
+ * published contract.
84
+ *
85
+ * The specifier is STORED verbatim — it may be a relative path or a bare package
86
+ * name, and normalising it would corrupt the second kind — but it is RESOLVED
87
+ * against `baseDir` before `import()` sees it (see {@link resolveFrom}), because
88
+ * a specifier the user wrote means what it means where the user is standing.
89
+ * `baseDir` defaults to `process.cwd()`; every caller in brambo passes the
90
+ * directory it was pointed at explicitly.
91
+ *
92
+ * Validation goes through `validateMethodPlugin` rather than a second copy of
93
+ * the rules, which is what makes M5.B's "every violation, not the first"
94
+ * guarantee reach an author here.
95
+ *
96
+ * A specifier that will not resolve is a CODED refusal, never an empty result:
97
+ * a broken selection that behaved like "no method selected" would run a
98
+ * different methodology than the one configured without saying so, which is the
99
+ * failure story 2.7c removed for `executor`.
100
+ */
101
+ export async function resolveMethod(specifier, baseDir) {
102
+ const trimmed = specifier.trim();
103
+ if (trimmed.length === 0) {
104
+ throw unloadable(specifier, 'it is blank; a method is named by a module specifier');
105
+ }
106
+ let namespace;
107
+ try {
108
+ namespace = (await import(__rewriteRelativeImportExtension(resolveFrom(trimmed, baseDir ?? process.cwd()))));
109
+ }
110
+ catch (error) {
111
+ throw unloadable(trimmed, error instanceof Error ? error.message : String(error), error);
112
+ }
113
+ // Validation failures keep the contract's OWN code and message — an author
114
+ // debugging their manifest needs `BRAMBO_METHOD_INVALID_PLUGIN` and the full
115
+ // violation list, not this file's wrapper around it.
116
+ return validateMethodPlugin(unwrap(namespace));
117
+ }
118
+ /**
119
+ * Refuses to MOUNT a selection the `project` layer decided.
120
+ *
121
+ * `brambo run` used to import and EXECUTE a module named by the
122
+ * `.brambo/config.json` of the directory it was run in. Driven against a temp
123
+ * project holding a `hostile.mjs` whose only statement is a `writeFileSync`:
124
+ * the run exited 2 and the file existed, while the same project with no
125
+ * `method` key left it unwritten. Clone a repository, run brambo inside it, and
126
+ * you have run its author's code.
127
+ *
128
+ * A module cannot be inspected without being LOADED, so neither validation nor
129
+ * reordering can prevent this — `validateMethodPlugin` already refuses the
130
+ * manifest, and the top-level statements have run by then. The deciding LAYER is
131
+ * the only fact available before the import, and it is enough to separate a
132
+ * choice from an arrival: `global` is the machine owner's own document, `agent`
133
+ * is one a host handed over programmatically and is therefore that host's own
134
+ * code, and `project` is the one that travels with a clone.
135
+ *
136
+ * SECOND LINE OF DEFENCE SINCE M30.D, AND STILL LOAD-BEARING. The ordinary path
137
+ * no longer reaches this clause: `seedExecutorConfig` drops a `method` key from
138
+ * a project document READ FROM DISK, so composition yields the next layer and
139
+ * the run says what it declined. That was the fix for a refusal wider than its
140
+ * threat — driven, a project key stopped the run whatever else was configured,
141
+ * so a clone denied service to the machine owner's own selection.
142
+ *
143
+ * But a SUPPLIED kernel owns its configuration (`run-session.ts:51`) and never
144
+ * reaches admission, so a host that seeds its own `project` layer arrives here.
145
+ * Driven, not assumed: with this clause deleted that path RESOLVES — the module
146
+ * is imported and the run returns ok. `kernel-composition.test.ts` pins it by
147
+ * the side effect, because the first version of that clause asserted only the
148
+ * error code and stayed green with the guard deleted.
149
+ *
150
+ * AND AD-5 DOES NOT SAY WHAT THIS COMMENT USED TO SAY IT SAID. It read "REFUSED
151
+ * rather than ignored, per AD-5", treating the rule as a binary. AD-5 is typed
152
+ * absence over silence — unavailable is not failed — so its opposite of IGNORED
153
+ * is TYPED AND REPORTED, not FATAL. Only the silent skip would violate it, which
154
+ * is why admission reports what it dropped instead of dropping it quietly.
155
+ *
156
+ * THE PLACEMENT IS THE GUARANTEE. This must be called between `selectMethod`
157
+ * and `resolveMethod`. After the import there is nothing left to prevent, and a
158
+ * check moved there would still pass its own test — which is why the test for
159
+ * it is falsified by moving it, not only by deleting it.
160
+ *
161
+ * WHAT THIS DOES NOT DO, AND THE REASON CHANGED AFTER IT WAS DRIVEN: honour a
162
+ * project selection brambo ITSELF wrote via `project swap method`. That was
163
+ * recorded here as merely deferred — "needs ownership tracking on config writes"
164
+ * — and the roadmap ordered it first because it "removes a restriction rather
165
+ * than adding a mechanism". Both sentences are wrong.
166
+ *
167
+ * An ownership record would prove brambo wrote the NAME. The danger is the module
168
+ * BYTES, which no record covers and which any `git pull` replaces — and AD-6's
169
+ * records authorise REMOVAL (`ownedPaths` is "what makes a record authority for a
170
+ * removal"), never EXECUTION. Reading one here would also need
171
+ * `@skanl/brambo-session -> @skanl/brambo-projection`, an edge `packages/session/test/guard.test.ts`
172
+ * pins closed. So it is a mechanism, and it is a trust store wearing an ownership
173
+ * record's clothes; the honest version of it is the deferred per-directory trust
174
+ * decision, not a rider on this guard.
175
+ *
176
+ * What IS still open, and it is smaller and realer: this refusal is fatal, so a
177
+ * cloned repository carrying a `method` key denies service to a method the
178
+ * machine's owner selected for themselves. Falling back to the next layer and
179
+ * SAYING so would fix that — it renegotiates E1's frozen exit code, which is a
180
+ * story rather than a rider. Recorded in `deferred-work.md`.
181
+ */
182
+ export function assertMethodMayMount(selected) {
183
+ if (selected.layer === 'project') {
184
+ throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `the 'project' layer selects the method '${selected.specifier}', and brambo will not import a module a project directory named: running it is running that project's code. That key is a RECOMMENDATION and adopting it is yours to do. Remove 'method' from this project's '.brambo/config.json' — this refusal stands while it is there, even for a method you selected machine-wide — then run \`brambo swap method ${selected.specifier}\` from this directory`);
185
+ }
186
+ // A RELATIVE SPECIFIER IN A MACHINE-WIDE DOCUMENT IS NOT A SELECTION.
187
+ //
188
+ // `runSession` resolves the specifier against the RUN's cwd regardless of
189
+ // which layer decided it, so `"method": "./mine.mjs"` in `~/.brambo/config.json`
190
+ // means "whatever `./mine.mjs` is in whatever directory you are standing in" —
191
+ // a wildcard over every repository on the machine.
192
+ //
193
+ // Driven, with a control: standing in a directory carrying only a `mine.mjs`
194
+ // and NO `.brambo` config at all, the module's top-level code RAN; the same
195
+ // directory with an empty HOME did not. So the selection caused it, and this is
196
+ // WIDER than the hole the clause above closes — that one needs the hostile
197
+ // repository to carry a config, this needs only a file with the right name.
198
+ //
199
+ // REFUSED rather than resolved against the home directory. Resolving would
200
+ // silently change what an existing selection means; refusing says the true
201
+ // thing, which is that the selection never named a file. An absolute path and
202
+ // a package specifier both still work, and the clause that proves this did not
203
+ // simply remove the feature asserts exactly that.
204
+ const relative = ['./', '../', '.\\', '..\\'].some((prefix) => selected.specifier.startsWith(prefix));
205
+ if (selected.layer !== 'project' && relative) {
206
+ throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `the '${selected.layer}' layer selects the method '${selected.specifier}', and a relative specifier there names no file: brambo resolves it against the directory you run in, so it would mean a different module in every project. Name it by ABSOLUTE path, or by package specifier`);
207
+ }
208
+ }
209
+ /**
210
+ * The method a composed configuration selects, with the layer that decided it —
211
+ * the same shape and the same `dump()` read `selectExecutor` uses, so the two
212
+ * selections cannot disagree about what a layer means.
213
+ *
214
+ * THIS BLOCK USED TO SIT ABOVE `assertMethodMayMount`'s OWN JSDoc, so it bound to
215
+ * nothing: measured on the emitted surface, `dist/methods.d.ts` declared
216
+ * `selectMethod` with no documentation at all, and the guard's text cited "`selectMethod`'s
217
+ * own rule right below" for a rule that documented nothing and did not ship.
218
+ *
219
+ * `undefined` is the ORDINARY state in v1 and is not a failure: PRD §6.2 places
220
+ * methodologies post-v1, so most runs select none and must cost nothing. A
221
+ * selection that is present but not a usable string IS a failure, because a
222
+ * `method: 42` silently ignored is a run using a different methodology than the
223
+ * document names.
224
+ *
225
+ * It never sees a `project` layer from a document brambo read: `seedExecutorConfig`
226
+ * drops that key before composition, which is what keeps `dump()` honest about
227
+ * the layer brambo acted on.
228
+ */
229
+ export function selectMethod(config) {
230
+ const decided = config
231
+ .dump()
232
+ .find((entry) => entry.path.length === 1 && entry.path[0] === METHOD_CONFIG_KEY);
233
+ if (decided === undefined)
234
+ return undefined;
235
+ if (typeof decided.value !== 'string' || decided.value.trim().length === 0) {
236
+ throw new BramboError(BRAMBO_ERROR_CODES.configurationUnusable, `'${METHOD_CONFIG_KEY}' must be a module specifier naming a MethodPlugin, but the '${decided.layer}' layer holds ${JSON.stringify(decided.value)}`);
237
+ }
238
+ return { specifier: decided.value.trim(), layer: decided.layer };
239
+ }
240
+ /**
241
+ * Mounts `incoming`, unmounting `outgoing` first — FR-28's ordering, and the
242
+ * only place it is provable.
243
+ *
244
+ * The outgoing teardown is AWAITED TO SETTLEMENT before the incoming hook is
245
+ * called. Not "started before": a swap that overlapped them would let a
246
+ * methodology's templates be removed while the next one's were being written,
247
+ * and the two orders are indistinguishable from the outside until they collide.
248
+ *
249
+ * A failed teardown REFUSES the swap instead of mounting the incoming anyway.
250
+ * A half-swapped environment is worse than a refused one, because nothing
251
+ * reports it: the outgoing believes it is unmounted, the incoming was never
252
+ * asked, and the next run inherits both beliefs.
253
+ *
254
+ * `outgoing` is `undefined` at a session start, which is the ordinary case.
255
+ */
256
+ export async function swapMethod(outgoing, incoming) {
257
+ // Deliberately NOT wrapped: `deactivate()` already raises
258
+ // `BRAMBO_METHOD_HOOK_FAILED` naming the method and the hook, which is exactly
259
+ // what a caller needs to know WHICH half failed. Re-coding it here would
260
+ // replace that with this function's own vocabulary and lose the half.
261
+ if (outgoing !== undefined)
262
+ await outgoing.deactivate();
263
+ return await activateMethod(incoming);
264
+ }
@@ -0,0 +1,37 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
2
+ import type { JsonObject, JsonValue } from '@skanl/brambo-contracts';
3
+ export interface StreamableHttpTransport {
4
+ request(url: string, init: Readonly<{
5
+ method: 'POST';
6
+ headers: Readonly<Record<string, string>>;
7
+ body: string;
8
+ signal: AbortSignal;
9
+ }>): Promise<Readonly<{
10
+ status: number;
11
+ headers?: Readonly<Record<string, string>>;
12
+ text(): Promise<string>;
13
+ }>>;
14
+ }
15
+ export interface RemoteMcpClientOptions {
16
+ readonly transport: StreamableHttpTransport;
17
+ readonly timeoutMs?: number;
18
+ readonly headers?: Readonly<Record<string, string>>;
19
+ }
20
+ export interface RemoteMcpResponse {
21
+ readonly jsonrpc: '2.0';
22
+ readonly id: number;
23
+ readonly result?: JsonValue;
24
+ readonly error?: Readonly<{
25
+ code: number;
26
+ message: string;
27
+ data?: JsonValue;
28
+ }>;
29
+ }
30
+ export interface RemoteMcpClient {
31
+ request(url: string, method: string, params: JsonObject, signal?: AbortSignal): Promise<RemoteMcpResponse>;
32
+ }
33
+ export declare class RemoteMcpError extends BramboError {
34
+ readonly requestId?: number;
35
+ constructor(code: typeof BRAMBO_ERROR_CODES[keyof typeof BRAMBO_ERROR_CODES], message: string, requestId?: number, options?: ErrorOptions);
36
+ }
37
+ export declare function createRemoteMcpClient(options: RemoteMcpClientOptions): RemoteMcpClient;
@@ -0,0 +1,76 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError } from '@skanl/brambo-contracts';
2
+ export class RemoteMcpError extends BramboError {
3
+ requestId;
4
+ constructor(code, message, requestId, options) {
5
+ super(code, message, options);
6
+ this.name = 'RemoteMcpError';
7
+ this.requestId = requestId;
8
+ }
9
+ }
10
+ function record(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ function jsonError(message, cause) {
14
+ return new RemoteMcpError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, `invalid remote MCP response: ${message}`, undefined, cause === undefined ? undefined : { cause });
15
+ }
16
+ function parseResponse(text, id) {
17
+ let value;
18
+ try {
19
+ value = JSON.parse(text);
20
+ }
21
+ catch (cause) {
22
+ throw jsonError('body is not valid JSON', cause);
23
+ }
24
+ if (!record(value) || value['jsonrpc'] !== '2.0' || value['id'] !== id) {
25
+ throw jsonError(`response must contain jsonrpc '2.0' and matching id ${id}`);
26
+ }
27
+ const hasResult = Object.hasOwn(value, 'result');
28
+ const hasError = Object.hasOwn(value, 'error');
29
+ if (hasResult === hasError)
30
+ throw jsonError('response must contain exactly one of result or error');
31
+ if (hasError) {
32
+ const error = value['error'];
33
+ if (!record(error) || !Number.isInteger(error['code']) || typeof error['message'] !== 'string' || error['message'].length === 0) {
34
+ throw jsonError('error must contain an integer code and non-empty message');
35
+ }
36
+ return { jsonrpc: '2.0', id, error: { code: error['code'], message: error['message'], ...(error['data'] === undefined ? {} : { data: error['data'] }) } };
37
+ }
38
+ return { jsonrpc: '2.0', id, result: value['result'] };
39
+ }
40
+ export function createRemoteMcpClient(options) {
41
+ const timeoutMs = options.timeoutMs ?? 30_000;
42
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0)
43
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, 'timeoutMs must be a positive integer');
44
+ let nextId = 1;
45
+ return Object.freeze({
46
+ async request(url, method, params, signal) {
47
+ const id = nextId++;
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
50
+ const onAbort = () => controller.abort();
51
+ signal?.addEventListener('abort', onAbort, { once: true });
52
+ try {
53
+ const response = await options.transport.request(url, { method: 'POST', headers: { accept: 'application/json, text/event-stream', 'content-type': 'application/json', ...options.headers }, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), signal: controller.signal });
54
+ if (!response || response.status < 200 || response.status >= 300)
55
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.executorRunFailed, `remote MCP returned HTTP ${response?.status ?? 'unknown'}`, id);
56
+ const parsed = parseResponse(await response.text(), id);
57
+ if (parsed.error)
58
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.executorRunFailed, `remote MCP request '${method}' failed: ${parsed.error.message}`, id);
59
+ return parsed;
60
+ }
61
+ catch (cause) {
62
+ if (cause instanceof RemoteMcpError)
63
+ throw cause;
64
+ if (signal?.aborted)
65
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.executorCancelled, `remote MCP request '${method}' was aborted`, id, { cause });
66
+ if (controller.signal.aborted)
67
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.executorCancelled, `remote MCP request '${method}' timed out`, id, { cause });
68
+ throw new RemoteMcpError(BRAMBO_ERROR_CODES.executorRunFailed, `remote MCP request '${method}' failed`, id, { cause });
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ signal?.removeEventListener('abort', onAbort);
73
+ }
74
+ },
75
+ });
76
+ }