Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8823f58
Declare what a method does, and what its answers must obey
andreBurnt Aug 23, 2026
f39e4c2
Remember what the world said, so a fix can be held to it
andreBurnt Aug 23, 2026
26e3fc9
Judge a candidate by replaying what the world already said
andreBurnt Aug 23, 2026
eba7ab9
Fail the schema check when nothing could be validated
andreBurnt Aug 23, 2026
09d13ee
Check the properties no single assertion can
andreBurnt Aug 23, 2026
c970780
Hold relation re-invocations to the same discipline
andreBurnt Aug 23, 2026
c5327cf
Test the tests by breaking the candidate on purpose
andreBurnt Aug 23, 2026
19f11c0
Prove the kill loop kills
andreBurnt Aug 23, 2026
c885d12
Refuse to deploy what the gate has not passed
andreBurnt Aug 23, 2026
11c68dd
Let an object's own traffic become its evidence
andreBurnt Aug 23, 2026
208bd1f
Hand candidates the response shape objects are taught
andreBurnt Aug 23, 2026
dc2031a
Let the gate say what it does not know
andreBurnt Aug 23, 2026
25b6185
Give a judged handler the `this` the runtime gives it
andreBurnt Aug 23, 2026
8034b87
Bind a verdict to the object and the contract it judged
andreBurnt Aug 23, 2026
f4392b7
Cover the weak-evidence and real-dialect cases, and say where the gat…
andreBurnt Aug 23, 2026
fd54b06
Say which checks judged nothing, instead of claiming they held
andreBurnt Aug 24, 2026
6572add
Name things for someone who was not in the conversation
andreBurnt Aug 24, 2026
85fc3a1
Bind the verdict digest to the target it was earned against
andreBurnt Aug 25, 2026
0dcee01
Parse a response only when a recorder is listening
andreBurnt Aug 25, 2026
5ee6cfa
Gate mutation testing on what the checks actually verified
andreBurnt Aug 25, 2026
03c304e
Judge every invocation on a worker it can terminate
andreBurnt Aug 25, 2026
8e6eb2f
Match cassettes by body, fail closed, and redact secrets at record time
andreBurnt Aug 25, 2026
5377f3d
Route every spawn through one gate with an explicit policy
andreBurnt Aug 25, 2026
2b402a5
Rename manifest contract fields to what they say
andreBurnt Aug 25, 2026
7afa345
Teach the mutator off-by-one and sign mistakes
andreBurnt Aug 25, 2026
d238313
Do not mistake string concatenation for arithmetic
andreBurnt Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/core/manifest-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Manifest contract fields are optional and preserved.
* Run: pnpm tsx --test src/core/manifest-contract.test.ts
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MethodDeclaration, RelationDeclaration, SideEffect } from './types.js';

test('a legacy method declaration without contract fields is valid', () => {
const m: MethodDeclaration = {
name: 'listEvents', description: 'list', parameters: [],
};
assert.equal(m.sideEffects, undefined);
});

test('contract fields round-trip', () => {
const relations: RelationDeclaration[] = [
{ kind: 'subset-on-tighter-filter', field: 'from' },
{ kind: 'sorted-by', field: 'startsAt' },
{ kind: 'non-empty-for-known-entity' },
];
const sideEffects: SideEffect = 'read-only';
const m: MethodDeclaration = {
name: 'listEvents', description: 'list', parameters: [],
sideEffects,
outputSchema: { type: 'array', items: { type: 'object' } },
relations,
entityRef: 'Weekly Standup',
};
assert.equal(m.sideEffects, 'read-only');
assert.equal(m.relations?.length, 3);
assert.equal(m.entityRef, 'Weekly Standup');
});
4 changes: 4 additions & 0 deletions src/core/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export const BLOCKED_CODE_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: stri
{ pattern: /\bfetch\s*\(/, label: 'fetch()' },
{ pattern: /\bXMLHttpRequest\b/, label: 'XMLHttpRequest' },
{ pattern: /\bWebSocket\b/, label: 'WebSocket' },
// Realm intrinsics, not injected globals: Atomics.wait blocks the thread
// past V8's interrupt check, so no vm timeout can recover from it.
{ pattern: /\bAtomics\b/, label: 'Atomics' },
{ pattern: /\bSharedArrayBuffer\b/, label: 'SharedArrayBuffer' },
];

/**
Expand Down
26 changes: 26 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,37 @@ export type ErrorMessage = AbjectMessage<AbjectError>;
// Interface Declaration
// =============================================================================

/** What calling a method does to the world. Reads are safe to
* generate and heal autonomously; acts must cross a hand-written gate. */
export type SideEffect = 'read-only' | 'mutating';

/** A metamorphic relation the method's outputs must satisfy.
* Checked by the fitness gate (src/protocol/fitness.ts) — never by an LLM. */
export interface RelationDeclaration {
kind: 'subset-on-tighter-filter' | 'idempotent' | 'no-duplicates'
| 'sorted-by' | 'non-empty-for-known-entity';
/** 'sorted-by': output field to be non-decreasing on.
* 'subset-on-tighter-filter': the argument that narrows the result. */
field?: string;
}

export interface MethodDeclaration {
name: string;
description: string;
parameters: ParameterDeclaration[];
returns?: TypeDeclaration;
/** Contract fields the fitness gate judges against. All optional, so a
* manifest written before they existed stays valid. */
sideEffects?: SideEffect;
/** JSON Schema the method's return value must validate against. */
outputSchema?: Record<string, unknown>;
relations?: RelationDeclaration[];
/** A value that must appear somewhere in a healthy output (known-entity probe). */
entityRef?: string;
/** Dot-paths masked in this method's recorded response bodies before they
* reach cassette storage (e.g. 'user.ssn'). For payload fields the header
* and query redaction cannot know about. */
redactPaths?: string[];
}

export interface ParameterDeclaration {
Expand Down
72 changes: 72 additions & 0 deletions src/objects/capabilities/http-client-recorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** Run: pnpm tsx --test src/objects/capabilities/http-client-recorder.test.ts
*
* The wiring between HttpClient and the cassette seam. Hermetic: the global
* fetch is replaced for the duration of each test and restored afterwards —
* the replay test proves the network is never touched by making fetch throw.
*/
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { HttpClient } from './http-client.js';
import { CassetteStore, type Cassette } from '../../protocol/cassette.js';
import { setRecorder, clearRecorder } from '../../protocol/cassette-recorder.js';

const CALLER = 'obj-under-test';
const realFetch = globalThis.fetch;

afterEach(() => {
clearRecorder(CALLER);
globalThis.fetch = realFetch;
});

const recorded: Cassette = {
method: '_http', args: {},
request: { method: 'GET', url: 'https://example.test/events' },
response: { status: 200, body: [{ id: 1 }] },
rawBody: '[{"id":1}]',
parsedOutput: [{ id: 1 }],
recordedAt: 1,
};

test('replay mode returns the full recorded HttpResponse without calling fetch', async () => {
globalThis.fetch = (() => { throw new Error('fetch must not be called in replay mode'); }) as typeof fetch;
setRecorder(CALLER, { mode: 'replay', store: new CassetteStore([recorded]) });

const res = await new HttpClient().makeRequest(
{ method: 'GET', url: 'https://example.test/events' }, CALLER);

assert.equal(res.status, 200);
assert.equal(res.ok, true);
assert.equal(res.statusText, '');
assert.deepEqual(res.headers, {});
assert.equal(res.body, '[{"id":1}]'); // raw text, verbatim
assert.deepEqual(JSON.parse(res.body), [{ id: 1 }]);
});

test('record mode lands one cassette carrying the raw body', async () => {
const store = new CassetteStore();
let persisted = 0;
setRecorder(CALLER, { mode: 'record', store, onRecord: () => persisted++ });
globalThis.fetch = (async () => new Response('{"hello":"world"}', {
status: 200, statusText: 'OK', headers: { 'content-type': 'application/json' },
})) as typeof fetch;

const res = await new HttpClient().makeRequest(
{ method: 'GET', url: 'https://example.test/hello' }, CALLER);

assert.equal(res.body, '{"hello":"world"}');
assert.equal(persisted, 1);
assert.equal(store.all().length, 1);
const [c] = store.all();
assert.equal(c.request.url, 'https://example.test/hello');
assert.equal(c.rawBody, '{"hello":"world"}');
assert.deepEqual(c.parsedOutput, { hello: 'world' });
});

test('an unregistered caller neither replays nor records', async () => {
const store = new CassetteStore();
setRecorder(CALLER, { mode: 'record', store });
globalThis.fetch = (async () => new Response('ok', { status: 200 })) as typeof fetch;

await new HttpClient().makeRequest({ method: 'GET', url: 'https://example.test/x' }, 'someone-else');
assert.equal(store.all().length, 0);
});
29 changes: 24 additions & 5 deletions src/objects/capabilities/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AbjectId, AbjectMessage } from '../../core/types.js';
import { Abject, DEFERRED_REPLY } from '../../core/abject.js';
import { error } from '../../core/message.js';
import { Capabilities } from '../../core/capability.js';
import { beforeRequest, afterResponse } from '../../protocol/cassette-recorder.js';

const HTTP_INTERFACE = 'abjects:http';

Expand Down Expand Up @@ -190,7 +191,7 @@ export class HttpClient extends Abject {
// for health pings during long-running fetches (e.g. LLM API calls).
this.on('request', async (msg: AbjectMessage) => {
const req = msg.payload as HttpRequest;
this.makeRequest(req).then(
this.makeRequest(req, msg.routing.from).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand All @@ -206,7 +207,7 @@ export class HttpClient extends Abject {
url: string;
headers?: Record<string, string>;
};
this.makeRequest({ method: 'GET', url, headers }).then(
this.makeRequest({ method: 'GET', url, headers }, msg.routing.from).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand All @@ -223,7 +224,7 @@ export class HttpClient extends Abject {
body: string;
headers?: Record<string, string>;
};
this.makeRequest({ method: 'POST', url, body, headers }).then(
this.makeRequest({ method: 'POST', url, body, headers }, msg.routing.from).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand Down Expand Up @@ -260,7 +261,7 @@ export class HttpClient extends Abject {
url,
body: data,
headers: { 'Content-Type': 'application/json' },
}).then(
}, msg.routing.from).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand Down Expand Up @@ -300,13 +301,28 @@ export class HttpClient extends Abject {
/**
* Make an HTTP request with retry for transient errors.
*/
async makeRequest(req: HttpRequest): Promise<HttpResponse> {
async makeRequest(req: HttpRequest, callerId?: string): Promise<HttpResponse> {
if (this.webDisabled) throw new Error('Web access is disabled. Enable it in Settings > Permissions.');
// Validate URL
const url = new URL(req.url);
this.validateScheme(url.protocol);
this.validateDomain(url.hostname);

// Replay seam: a registered replay-mode caller is served from its
// cassette store and never touches the network. A miss throws.
const replayed = beforeRequest(callerId, { method: req.method, url: req.url, headers: req.headers, body: req.body });
if (replayed) {
// A full HttpResponse, with the recorded body text verbatim — the
// caller must not be able to tell replay from the live network.
return {
status: replayed.status,
statusText: '',
headers: replayed.headers,
body: replayed.rawBody,
ok: replayed.status >= 200 && replayed.status < 300,
};
}

// Build fetch options
const options: RequestInit = {
method: req.method,
Expand Down Expand Up @@ -345,6 +361,9 @@ export class HttpClient extends Abject {
// Read body
const body = await response.text();

afterResponse(callerId, { method: req.method, url: req.url, headers: req.headers, body: req.body },
{ status: response.status, rawBody: body });

return {
status: response.status,
statusText: response.statusText,
Expand Down
Loading