Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@
/**
* C2 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, MethodEffect } from './types.js';

test('a legacy method declaration without contract fields is valid', () => {
const m: MethodDeclaration = {
name: 'listEvents', description: 'list', parameters: [],
};
assert.equal(m.effects, 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 effects: MethodEffect = 'read';
const m: MethodDeclaration = {
name: 'listEvents', description: 'list', parameters: [],
effects,
outputSchema: { type: 'array', items: { type: 'object' } },
relations,
knownEntity: 'Weekly Standup',
};
assert.equal(m.effects, 'read');
assert.equal(m.relations?.length, 3);
assert.equal(m.knownEntity, 'Weekly Standup');
});
21 changes: 21 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,32 @@ export type ErrorMessage = AbjectMessage<AbjectError>;
// Interface Declaration
// =============================================================================

/** C2 contract: 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 MethodEffect = 'read' | 'act';

/** C2 contract: 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;
/** C2 contract fields — all optional; legacy manifests are untouched. */
effects?: MethodEffect;
/** 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). */
knownEntity?: 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);
});
37 changes: 32 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 });
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,17 @@ export class HttpClient extends Abject {
// Read body
const body = await response.text();

// Record seam: JSON-parse the body when possible so the cassette
// carries a matchable structure; fall back to the raw text.
let parsedBody: unknown = body;
try {
parsedBody = JSON.parse(body);
} catch {
// not JSON — keep parsedBody as the raw text
}
afterResponse(callerId, { method: req.method, url: req.url, headers: req.headers },
{ status: response.status, body: parsedBody, rawBody: body });

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