Skip to content
10 changes: 6 additions & 4 deletions packages/backend-system/ad4m/src/languageModelPort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from '@we/backend-shared';

import { readChatStream, toOpenAiMessages, toOpenAiTools } from './openaiChat';
import { rpcStatus } from './rpcStatus';

/**
* The AD4M executor's AI service, as the neutral language-model port.
Expand Down Expand Up @@ -146,10 +147,11 @@ async function defaultModelStatus(client: Ad4mClient): Promise<LanguageModelStat
}
return { state: 'ready', name, model: id, detail: '' };
} catch (err) {
// Discovery needs the AI CREATE grant. A guest without it has asked a question it may not ask,
// which says nothing about the model — so that is unchecked, not broken.
if (/capabilit|forbidden|unauthori/i.test(messageOf(err)))
return { state: 'unchecked', name, model: id, detail: '' };
// The executor refused the call itself (403): a guest without the AI CREATE grant, or a user
// session on a multi-user node, which may never call `ai.discoverModels`. That says nothing
// about the model — so it is unchecked, not broken. Read from the status, not the wording: the
// two refusals word themselves differently, and an endpoint's own "Unauthorized" is an error.
if (rpcStatus(err) === 403) return { state: 'unchecked', name, model: id, detail: '' };
return { state: 'error', name, model: id, detail: messageOf(err) };
}
}
Expand Down
7 changes: 4 additions & 3 deletions packages/backend-system/ad4m/src/lifecycleAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* `client.neighbourhood.*`, and `client.agent.*` calls the app shell's stores previously made
* directly. Datasets are perspectives; shared datasets are neighbourhoods.
*/
import { Ad4mClient, Perspective, PerspectiveHandle, type PerspectiveProxy, RpcError } from '@coasys/ad4m';
import { Ad4mClient, Perspective, PerspectiveHandle, type PerspectiveProxy } from '@coasys/ad4m';
import {
type AgentIdentity,
type AgentSessionPort,
Expand All @@ -16,6 +16,7 @@ import {
} from '@we/backend-shared';

import { ensureFileStorageLanguage } from './agentHelpers';
import { rpcStatus } from './rpcStatus';

const SCHEME = 'neighbourhood://';

Expand Down Expand Up @@ -445,14 +446,14 @@ export function createAd4mAgentSession(backendClient: unknown): AgentSessionPort
try {
await call();
} catch (err) {
if (!(err instanceof RpcError && err.status === 408)) throw err;
if (rpcStatus(err) !== 408) throw err;
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<'expired'>((resolve) => {
timer = setTimeout(() => resolve('expired'), SESSION_SETTLE_MS);
});
const outcome = await Promise.race([settled.then(() => 'settled' as const), expired]);
clearTimeout(timer);
if (outcome === 'expired') throw new SessionTimeoutError(err.message);
if (outcome === 'expired') throw new SessionTimeoutError(err instanceof Error ? err.message : String(err));
} finally {
settleWaiters.delete(settle);
}
Expand Down
14 changes: 14 additions & 0 deletions packages/backend-system/ad4m/src/rpcStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* The status an executor rejection carries — 403, 408 — or `undefined` when it carries none.
*
* Read from the error's shape, never with `instanceof RpcError`. The client the app holds comes from
* `@coasys/ad4m-connect`, which bundles its own copy of the SDK, so its `RpcError` is a different
* class from the one `@coasys/ad4m` exports. An `instanceof` check against the imported class
* compiles, passes every unit test that throws the imported class, and never matches a real
* rejection: a timed-out unlock went on reading as a wrong password, and a user session's refused
* model check as a broken model.
*/
export function rpcStatus(error: unknown): number | undefined {
const status = (error as { status?: unknown } | null | undefined)?.status;
return typeof status === 'number' ? status : undefined;
}
22 changes: 16 additions & 6 deletions packages/backend-system/ad4m/src/runtimeAdminAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,10 @@ export function createAd4mRuntimeAdmin(backendClient: unknown, options: Ad4mRunt
/**
* What belongs to this session rather than to the node, and so survives being a guest.
*
* Only consent now. Authorized apps used to live here on the reasoning that `agent.getApps()`
* "answers for whoever is authenticated" — it does not. The executor keeps them in a
* process-global map persisted to one `apps_data.json`, not scoped per user, so on a shared node
* the list is either empty or somebody else's. They have moved to the node-scoped group below.
* Authorized apps used to live here on the reasoning that `agent.getApps()` "answers for whoever
* is authenticated" — it does not. The executor keeps them in a process-global map persisted to
* one `apps_data.json`, not scoped per user, so on a shared node the list is either empty or
* somebody else's. They have moved to the node-scoped group below, and consent has followed them.
*/
const agentScoped: RuntimeAdminPort = {
/*
Expand All @@ -306,8 +306,18 @@ export function createAd4mRuntimeAdmin(backendClient: unknown, options: Ad4mRunt
*/
unsupported: () => missingExecutorMethods().map(({ method, firstSeen }) => ({ name: method, firstSeen })),
onUnsupported: (handler) => onMissingMethod(handler),
};

// ── Consent ───────────────────────────────────────────────────────────────
/**
* Answering consent requests: granting an app its capabilities, and trusting a peer.
*
* Node-scoped, because both answers change the node. A capability grant needs `AGENT PERMIT`,
* which only the node's operator holds, and the trusted-agent list is one list for the whole
* node — the executor refuses `runtime.addTrustedAgents` and `runtime.deleteTrustedAgents` to a
* user session. A guest offered these prompts saw an "untrusted agent" request (the executor
* still raises those to every session) whose Trust and Decline both failed.
*/
const consent: RuntimeAdminPort = {
/**
* One executor subscription, demultiplexed into the contract's two request kinds. AD4M raises
* these as `exception` events carrying the request in `addon` — a JSON blob for capability
Expand Down Expand Up @@ -598,7 +608,7 @@ export function createAd4mRuntimeAdmin(backendClient: unknown, options: Ad4mRunt
...agentScoped,
...(canRead(CAP_DOMAIN.ai) ? aiRead : {}),
...(canWrite(CAP_DOMAIN.ai, CAP_VERB.create) ? aiWrite : {}),
...(administersNode ? nodeScoped : {}),
...(administersNode ? { ...nodeScoped, ...consent } : {}),
};
}

Expand Down
22 changes: 22 additions & 0 deletions packages/backend-system/ad4m/tests/agentSessionTimeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ describe('AD4M agent session — unlock that outlasts the RPC timeout', () => {
expect(isSessionTimeout(await settled)).toBe(true);
});

it('waits out a timeout thrown by the SDK copy bundled in ad4m-connect', async () => {
// The app's client throws ad4m-connect's own `RpcError`, a different class from the one
// `@coasys/ad4m` exports — this stands in for it, with the same shape.
class BundledRpcError extends Error {
constructor(
public status: number,
body: string,
) {
super(`RPC error ${status}: ${body}`);
}
}
const { client, emitStatus } = fakeClient(() =>
Promise.reject(new BundledRpcError(408, "RPC call 'agent.unlock' timed out after 30000ms")),
);
const unlocking = createAd4mAgentSession(client).unlock('pw');

await vi.advanceTimersByTimeAsync(60_000);
emitStatus({ isUnlocked: true, did: 'did:key:zAgent' });

await expect(unlocking).resolves.toBeUndefined();
});

it('rethrows a refusal as it came, without waiting', async () => {
const refusal = new RpcError(500, 'aead::Error');
const { client } = fakeClient(() => Promise.reject(refusal));
Expand Down
45 changes: 45 additions & 0 deletions packages/backend-system/ad4m/tests/languageModelPort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* The model-authoring task follows the app's system prompt by replacement, never `updateTask` —
* which the executor's RPC refuses for want of a `taskId` the client does not send.
*/
import { RpcError } from '@coasys/ad4m';
import { describe, expect, it, vi } from 'vitest';

import { createAd4mLanguageModelPort } from '../src/languageModelPort';
Expand Down Expand Up @@ -85,6 +86,50 @@ describe('the default model’s status', () => {
await expect(port.status!()).resolves.toMatchObject({ state: 'error', detail: expect.stringContaining('401') });
});

it('is unchecked when the executor refuses the check itself', async () => {
// A user session on a multi-user node may not call `ai.discoverModels`; neither may a guest
// without the AI CREATE grant. Each refusal words itself differently, but both are a 403.
const refusal = new RpcError(403, 'ai.discoverModels acts on the node itself, so a user session may not call it');
const port = statusClient({
getDefaultModel: vi.fn(async () => claude),
discoverModels: vi.fn(async () => Promise.reject(refusal)),
});
await expect(port.status!()).resolves.toMatchObject({ state: 'unchecked' });
});

it('is unchecked when the refusal comes from the SDK copy bundled in ad4m-connect', async () => {
// The app's client throws ad4m-connect's own `RpcError`, a different class from the one
// `@coasys/ad4m` exports — this stands in for it, with the same shape.
class BundledRpcError extends Error {
constructor(
public status: number,
body: string,
) {
super(`RPC error ${status}: ${body}`);
}
}
const refusal = new BundledRpcError(
403,
'ai.discoverModels acts on the node itself, so a user session may not call it',
);
const port = statusClient({
getDefaultModel: vi.fn(async () => claude),
discoverModels: vi.fn(async () => Promise.reject(refusal)),
});
await expect(port.status!()).resolves.toMatchObject({ state: 'unchecked' });
});

it('is an error when the endpoint itself answers Unauthorized', async () => {
const port = statusClient({
getDefaultModel: vi.fn(async () => claude),
discoverModels: vi.fn(async () => Promise.reject(new RpcError(500, 'OpenAI API error: 401 Unauthorized'))),
});
await expect(port.status!()).resolves.toMatchObject({
state: 'error',
detail: expect.stringContaining('Unauthorized'),
});
});

it('is an error when the service does not offer that model', async () => {
const port = statusClient({
getDefaultModel: vi.fn(async () => ({ ...claude, api: { ...claude.api, model: 'claude-sonet-5' } })),
Expand Down
11 changes: 8 additions & 3 deletions packages/backend-system/ad4m/tests/runtimeAdminAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ describe('what a connection is allowed to administer', () => {
expect(port.languages).toBeDefined();
expect(port.aiModels).toBeDefined();
expect(port.authorizedApps).toBeDefined();
expect(port.onConsentRequest).toBeDefined();
expect(port.approve).toBeDefined();
});

it('does not offer a network restart the executor does not perform', () => {
Expand All @@ -60,9 +62,12 @@ describe('what a connection is allowed to administer', () => {
expect(port.restartNetwork).toBeUndefined();
expect(port.languages).toBeUndefined();

// Consent is raised at *this* session, so it survives being a guest.
expect(port.onConsentRequest).toBeDefined();
expect(port.approve).toBeDefined();
// Answering consent changes the node: granting an app needs AGENT PERMIT, and the trusted-agent
// list is the node's. The executor refuses both to a user session, so a prompt here would offer
// a guest buttons that cannot work.
expect(port.onConsentRequest).toBeUndefined();
expect(port.approve).toBeUndefined();
expect(port.deny).toBeUndefined();
});

it('withholds the app list from a guest, because it is not theirs to see', () => {
Expand Down
Loading