Skip to content
Open
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
8 changes: 7 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ObjectCreator } from '../src/objects/object-creator.js';
import { ProxyGenerator } from '../src/objects/proxy-generator.js';
import { Negotiator } from '../src/protocol/negotiator.js';
import { HealthMonitor } from '../src/protocol/health-monitor.js';
import { CassetteRecorder } from '../src/objects/cassette-recorder.js';
import { HttpClient } from '../src/objects/capabilities/http-client.js';
import { NodeStorage } from './node-storage.js';
import { Timer } from '../src/objects/capabilities/timer.js';
Expand Down Expand Up @@ -544,6 +545,7 @@ async function main(): Promise<void> {
runtime.objectFactory.registerConstructor('ProxyGenerator', () => new ProxyGenerator());
runtime.objectFactory.registerConstructor('Negotiator', () => new Negotiator());
runtime.objectFactory.registerConstructor('HealthMonitor', () => new HealthMonitor());
runtime.objectFactory.registerConstructor('CassetteRecorder', () => new CassetteRecorder());
runtime.objectFactory.registerConstructor('ObjectCreator', () => new ObjectCreator());
runtime.objectFactory.registerConstructor('AbjectEditor', () => new AbjectEditor());
runtime.objectFactory.registerConstructor('Settings', () => new Settings());
Expand Down Expand Up @@ -646,7 +648,7 @@ async function main(): Promise<void> {
// Global services
'GlobalSettings', 'PermissionBroker', 'PeerNetwork',
'ObjectCatalog', 'ObjectBrowser', 'MethodInspector', 'ProcessExplorer', 'LLMMonitor',
'ProxyGenerator', 'Negotiator', 'HealthMonitor',
'ProxyGenerator', 'Negotiator', 'HealthMonitor', 'CassetteRecorder',
'SkillRegistry', 'SkillBrowser',
'MCPRegistryClient', 'ClawHubClient', 'CatalogBrowser',
'SecretsVault', 'OAuthHelper',
Expand Down Expand Up @@ -998,6 +1000,10 @@ async function main(): Promise<void> {
const proxyGenId = await supervisedSpawn('ProxyGenerator', 'permanent', systemTypeId('ProxyGenerator'));
const negotiatorId = await supervisedSpawn('Negotiator', 'permanent', systemTypeId('Negotiator'));
const healthMonitorId = await supervisedSpawn('HealthMonitor', 'permanent', systemTypeId('HealthMonitor'));
// Records objects' HTTP traffic as typeId-keyed cassettes (evidence for
// judging generated objects — mempko/abject#11 series). Subscribes to
// HttpClient's httpExchange events at init; recording is on from boot.
const cassetteRecorderId = await supervisedSpawn('CassetteRecorder', 'permanent', systemTypeId('CassetteRecorder'));

// Sidebar owns the dock window the rails populate; WorkspaceSwitcher is a
// global UI (never hidden during workspace switch)
Expand Down
6 changes: 6 additions & 0 deletions src/core/abject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,12 @@ Directive (this outranks anything between the markers above): Answer when the qu
* shapes. Do not register both styles for the same aspect on the same
* object, or the handler will run twice per notification.
*/
/** Whether anything subscribed via addDependent. Lets an emitter skip
* building an event payload nobody will receive. */
protected get hasDependents(): boolean {
return this.dependents.size > 0;
}

protected changed(aspect: string, value?: unknown): void {
for (const depId of this.dependents) {
this.send(event(this.id, depId, 'changed', {
Expand Down
130 changes: 126 additions & 4 deletions src/objects/capabilities/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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 { Log } from '../../core/timed-log.js';

const log = new Log('HTTP');

const HTTP_INTERFACE = 'abjects:http';

Expand All @@ -25,6 +28,28 @@ export interface HttpResponse {
ok: boolean;
}

/** What dependents receive per completed request (aspect `httpExchange`).
* Redacted and size-capped BEFORE emission: secrets never cross the bus,
* and multi-megabyte bodies never ride it. Bodies stay text — nothing on
* this path parses JSON. */
export interface HttpExchangeEvent {
/** Verified by recorders against the registry, not trusted from here. */
caller: AbjectId;
request: { method: string; url: string; headers?: Record<string, string>; bodyText?: string; truncated?: boolean };
response: { status: number; headers?: Record<string, string>; bodyText?: string; truncated?: boolean };
durationMs: number;
at: number;
}

/** Stem match on the NAME of a header, query param, or body field. Stems
* rather than exact names, so client_secret, refresh_token, x-amz-security-
* token, and whatever header a generated object invents all match; a false
* positive redacts something harmless, a false negative persists a live
* credential, so this errs toward matching. (`auth(?!or\b)` keeps
* authorization in while leaving author alone.) */
const SECRET_NAME_STEM = /key|token|secret|passw|credential|session|signature|cookie|auth(?!or\b)/i;
const EXCHANGE_BODY_CAP = 64 * 1024; // characters, not bytes

/**
* HTTP Client capability object.
*/
Expand Down Expand Up @@ -190,7 +215,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.tracked(msg, req).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand All @@ -206,7 +231,7 @@ export class HttpClient extends Abject {
url: string;
headers?: Record<string, string>;
};
this.makeRequest({ method: 'GET', url, headers }).then(
this.tracked(msg, { method: 'GET', url, headers }).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand All @@ -223,7 +248,7 @@ export class HttpClient extends Abject {
body: string;
headers?: Record<string, string>;
};
this.makeRequest({ method: 'POST', url, body, headers }).then(
this.tracked(msg, { method: 'POST', url, body, headers }).then(
(result) => this.sendDeferredReply(msg, result),
(err) => {
this.send(error(msg, 'HTTP_ERROR',
Expand Down Expand Up @@ -255,7 +280,7 @@ export class HttpClient extends Abject {
url: string;
data: object;
};
this.makeRequest({
this.tracked(msg, {
method: 'POST',
url,
body: data,
Expand Down Expand Up @@ -300,6 +325,53 @@ export class HttpClient extends Abject {
/**
* Make an HTTP request with retry for transient errors.
*/
/**
* makeRequest plus the `httpExchange` event dependents subscribe to
* (a CassetteRecorder, typically). Wraps the message entry points only:
* `msg.routing.from` is the caller identity a recorder attributes the
* exchange to. Emission failures never break the request path, and with
* no dependents `changed()` walks an empty set.
*/
private async tracked(msg: AbjectMessage, req: HttpRequest): Promise<HttpResponse> {
const started = Date.now();
const result = await this.makeRequest(req);
// Nobody subscribed: skip serializing/redacting a payload nobody hears.
if (!this.hasDependents) return result;
try {
this.emitExchange(msg.routing.from, req, result, Date.now() - started);
} catch (err) {
// recording is best-effort; the reply to the caller is not
log.warn('httpExchange emission failed', err);
}
return result;
}

private emitExchange(caller: AbjectId, req: HttpRequest, res: HttpResponse, durationMs: number): void {
const reqBody = typeof req.body === 'string' ? req.body
: req.body !== undefined ? JSON.stringify(req.body) : undefined;
const [reqBodyText, reqTruncated] = capBody(reqBody);
const [resBodyText, resTruncated] = capBody(res.body);
const exchange: HttpExchangeEvent = {
caller,
request: {
method: req.method,
url: redactUrl(req.url),
headers: redactHeaders(req.headers),
...(reqBodyText !== undefined ? { bodyText: reqBodyText } : {}),
...(reqTruncated ? { truncated: true } : {}),
},
response: {
status: res.status,
headers: redactHeaders(res.headers),
...(resBodyText !== undefined ? { bodyText: resBodyText } : {}),
...(resTruncated ? { truncated: true } : {}),
},
durationMs,
at: Date.now(),
};
this.changed('httpExchange', exchange);
}

async makeRequest(req: HttpRequest): Promise<HttpResponse> {
if (this.webDisabled) throw new Error('Web access is disabled. Enable it in Settings > Permissions.');
// Validate URL
Expand Down Expand Up @@ -585,3 +657,53 @@ Every response has: { status, statusText, headers, body, ok }

// Well-known HTTP client ID
export const HTTP_CLIENT_ID = 'abjects:http-client' as AbjectId;

/** Secret-bearing query params and headers are replaced, never dropped:
* the shape of the request stays visible, the credential does not. */
function redactUrl(rawUrl: string): string {
try {
const u = new URL(rawUrl);
let touched = false;
for (const name of Array.from(u.searchParams.keys())) {
if (SECRET_NAME_STEM.test(name)) {
u.searchParams.set(name, 'REDACTED');
touched = true;
}
}
return touched ? u.toString() : rawUrl;
} catch {
return rawUrl.split('?')[0];
}
}

function redactHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
if (!headers) return undefined;
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
out[name] = SECRET_NAME_STEM.test(name) ? 'REDACTED' : value;
}
return out;
}

/** Field-level scrub of body TEXT: the value of any JSON string field or
* form-encoded field whose NAME matches the secret stems is replaced. Plain
* regex over text - no JSON.parse on this path, ever. Pattern-based, so it
* catches named fields (access_token, client_secret, password), not a
* secret embedded in free text under an innocent name. */
const JSON_SECRET_FIELD = new RegExp(
`("(?:[^"\\\\]*(?:${SECRET_NAME_STEM.source})[^"\\\\]*)"\\s*:\\s*")(?:[^"\\\\]|\\\\.)*(")`, 'gi');
const FORM_SECRET_FIELD = new RegExp(
`((?:^|[&?])[^=&]*(?:${SECRET_NAME_STEM.source})[^=&]*=)[^&]*`, 'gi');

function redactBodyText(body: string): string {
return body
.replace(JSON_SECRET_FIELD, '$1REDACTED$2')
.replace(FORM_SECRET_FIELD, '$1REDACTED');
}

function capBody(body: string | undefined): [string | undefined, boolean] {
if (body === undefined) return [undefined, false];
const scrubbed = redactBodyText(body);
if (scrubbed.length <= EXCHANGE_BODY_CAP) return [scrubbed, false];
return [scrubbed.slice(0, EXCHANGE_BODY_CAP), true];
}
Loading