Skip to content
Merged
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
66 changes: 58 additions & 8 deletions src/modules/identity-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
createPublicKey,
generateKeyPairSync,
sign as cryptoSign,
createHash,
type KeyObject,
} from 'node:crypto';
import type {
Expand All @@ -41,6 +42,7 @@ import type {
ToolCall,
ToolResult,
ToolDefinition,
WorkspaceModule,
} from '@animalabs/agent-framework';

export interface IdentityModuleConfig {
Expand Down Expand Up @@ -72,6 +74,7 @@ const DEFAULT_SERVICES: Record<string, string> = {

const REQUEST_BODY_MAX = 256 * 1024;
const RESPONSE_INLINE_MAX = 24 * 1024;
const RESPONSE_BODY_MAX = 64 * 1024 * 1024;

/** Persisted beside the key after a successful registration. */
interface IdentityRecord {
Expand All @@ -96,8 +99,10 @@ export class IdentityModule implements Module {
this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
}

async start(_ctx: ModuleContext): Promise<void> {}
async stop(): Promise<void> {}
private ctx: ModuleContext | null = null;

async start(ctx: ModuleContext): Promise<void> { this.ctx = ctx; }
async stop(): Promise<void> { this.ctx = null; }

getTools(): ToolDefinition[] {
return []; // utilities-only, by design — see module header
Expand All @@ -118,14 +123,16 @@ export class IdentityModule implements Module {
description:
'Call a connected service’s API (e.g. "orrery") with your standing access ' +
'attached by the host — nothing for you to obtain, renew, or handle; renewal ' +
'is automatic. Give the service name and a path; returns {status, body}.',
'is automatic. Give the service name and a path; returns {status, body}. ' +
'For binary responses, pass saveAs with a workspace path (e.g. files/artifacts/image.png).',
inputSchema: {
type: 'object',
properties: {
service: { type: 'string', description: 'Service name, e.g. "orrery". Unknown names list what is available.' },
path: { type: 'string', description: 'API path starting with "/", e.g. "/api/ops".' },
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'Default GET.' },
body: { type: 'object', description: 'JSON body for POST/PUT.' },
saveAs: { type: 'string', description: 'Optional workspace path for the raw response bytes, e.g. files/artifacts/image.png. Required to retrieve binary bodies without loss.' },
},
required: ['service', 'path'],
},
Expand Down Expand Up @@ -156,7 +163,7 @@ export class IdentityModule implements Module {
case 'accept_invite':
return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
case 'request':
return await this.request(call.input as { service?: unknown; path?: unknown; method?: unknown; body?: unknown });
return await this.request(call.input as { service?: unknown; path?: unknown; method?: unknown; body?: unknown; saveAs?: unknown });
default:
return fail(`Unknown identity utility: ${call.name}`);
}
Expand Down Expand Up @@ -285,6 +292,7 @@ export class IdentityModule implements Module {
path?: unknown;
method?: unknown;
body?: unknown;
saveAs?: unknown;
}): Promise<ToolResult> {
const services = { ...DEFAULT_SERVICES, ...this.config.services };
const service = typeof input.service === 'string' ? input.service : '';
Expand Down Expand Up @@ -319,13 +327,55 @@ export class IdentityModule implements Module {
},
...(bodyStr !== undefined ? { body: bodyStr } : {}),
});
const text = await res.text();
let body: unknown = text;
const bytes = Buffer.from(await res.arrayBuffer());
if (bytes.byteLength > RESPONSE_BODY_MAX) {
return fail(`response too large (${bytes.byteLength} > ${RESPONSE_BODY_MAX})`);
}
const declaredType = res.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || '';
const contentType = declaredType || 'application/octet-stream';
const sha256 = createHash('sha256').update(bytes).digest('hex');

if (typeof input.saveAs === 'string' && input.saveAs.length > 0) {
const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
if (!workspace) return fail('identity request: workspace module is not available for saveAs');
const written = await workspace.writeBinary(input.saveAs, bytes, contentType);
if (!written.success) return fail(`identity request: could not save response: ${written.error ?? 'unknown'}`);
return ok({
status: res.status,
saved: { path: input.saveAs, size: bytes.byteLength, contentType, sha256 },
});
}

const text = bytes.toString('utf8');
let parsed: unknown;
let parsedJson = false;
try {
body = JSON.parse(text);
parsed = JSON.parse(text);
parsedJson = true;
} catch {
/* not JSON — return as text */
/* not JSON */
}
const textual = declaredType.startsWith('text/')
|| declaredType === 'application/json'
|| declaredType.endsWith('+json')
|| declaredType === 'application/xml'
|| declaredType.endsWith('+xml')
// Some tiny internal/fake services omit content-type on JSON. A full
// successful parse is a safer fallback than treating valid JSON as
// opaque bytes; arbitrary binary almost never parses as one JSON value.
|| (!declaredType && parsedJson);
if (!textual) {
return ok({
status: res.status,
body: null,
binary: {
size: bytes.byteLength, contentType, sha256,
note: 'Binary response omitted from text context; repeat the request with saveAs to write it byte-exactly to a workspace mount.',
},
});
}

let body: unknown = parsedJson ? parsed : text;
const raw = typeof body === 'string' ? body : JSON.stringify(body);
if (raw.length > RESPONSE_INLINE_MAX) {
body = `${raw.slice(0, RESPONSE_INLINE_MAX)}… [truncated ${raw.length - RESPONSE_INLINE_MAX} chars]`;
Expand Down
46 changes: 46 additions & 0 deletions test/identity-and-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,52 @@ describe('identity module', () => {
expect(badPath.success).toBe(false);
});

it('request: binary responses are described safely or saved byte-exactly to workspace', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ident-'));
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00, 0x7f]);
let saved: { path: string; data: Buffer; mime: string } | null = null;
const mod = new IdentityModule({
keyPath: join(dir, 'k.pem'),
home: 'id.test',
services: { orrery: 'https://orrery.test' },
fetchImpl: (async (url: any) => {
const u = String(url);
if (u.includes('/enroll')) return new Response(JSON.stringify({ sub: 'agent:a@guest' }), { status: 200 });
if (u.includes('/token')) return new Response(JSON.stringify({ token: 'aid1.fresh.secret' }), { status: 200 });
if (u === 'https://orrery.test/api/assets/img/file') {
return new Response(png, { status: 200, headers: { 'content-type': 'image/png' } });
}
return new Response('{}', { status: 404, headers: { 'content-type': 'application/json' } });
}) as typeof fetch,
});
await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));

const described = await mod.handleToolCall(call('request', { service: 'orrery', path: '/api/assets/img/file' }));
expect(described.success).toBe(true);
expect((described.data as any).body).toBe(null);
expect((described.data as any).binary).toMatchObject({ size: png.length, contentType: 'image/png' });
expect(JSON.stringify(described.data)).not.toContain('�PNG');

await mod.start({
getModule: (name: string) => name === 'workspace' ? {
writeBinary: async (path: string, data: Buffer, mime: string) => {
saved = { path, data: Buffer.from(data), mime };
return { success: true, data: { path, size: data.length, mimeType: mime } };
},
} : null,
} as any);
const written = await mod.handleToolCall(call('request', {
service: 'orrery', path: '/api/assets/img/file', saveAs: 'files/artifacts/candidate.png',
}));
expect(written.success).toBe(true);
expect((written.data as any).saved).toMatchObject({
path: 'files/artifacts/candidate.png', size: png.length, contentType: 'image/png',
});
expect(saved?.path).toBe('files/artifacts/candidate.png');
expect(saved?.mime).toBe('image/png');
expect(saved?.data.equals(png)).toBe(true);
});

it('host-facing accessFor: requires registration, then exchanges per call', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ident-'));
let mints = 0;
Expand Down
Loading