Files
callstack__agent-device/test/integration/provider-scenarios/remote-proxy-request-diagnostics.test.ts
Michał Pierzchała 367e795ee7 fix(remote): let a plain-session client read its own failure record (#2382)
`GET /sessions/<session>/requests/<requestId>/diagnostics` applied the
`<tenant>:` prefix rule to every caller carrying a tenant, but
`scopeRequestSession` only writes that prefix under tenant isolation — which
the daemon forces exactly when the auth hook ATTESTS the tenant. A client
whose tenant is only declared (the `x-agent-device-tenant` header on a daemon
with no auth hook) therefore ran in a plain session such as `default` or
`cwd:<hash>:default` and was then refused 401 reading the record its own
failed command wrote, directly and through `agent-device proxy`.

The addressability rule now lives beside the naming rule in
`session-tenant-scope.ts`, which exists so the two cannot disagree.
`isTenantAddressableSessionName` takes the caller's session namespace and
applies the prefix rule only where the namespace is actually partitioned;
`resolveTrustedTenant` now reports whether the tenant was attested, and
`authorizeAuxiliaryHttpRequest` hands that namespace to the route.

The attested case is unchanged: an attested tenant is still refused any
session outside its own prefix, with the same typed UNAUTHORIZED error.
2026-09-07 18:26:16 +02:00

101 lines
4.3 KiB
TypeScript

/**
* A client behind `agent-device proxy` must get the SAME failure envelope a
* client on the daemon host gets (#1801), including for the plain sessions
* `scopeRequestSession` leaves unscoped.
*
* The proxy forwards `x-agent-device-tenant` verbatim, so a caller that carries
* a tenant identity reaches the diagnostics route with that identity even
* though its command ran in a plain session such as `cwd:<hash>:default`. The
* record read must not be stricter than the write that produced it: on a daemon
* with no auth hook that same caller can already run any command in any session
* over `/rpc`.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts';
import { createDaemonProxyServer } from '../../../src/remote/daemon-proxy.ts';
import { localizeRemoteDaemonError } from '../../../src/remote/remote-request-diagnostics.ts';
import { resolveSessionRequestLogPath } from '../../../src/daemon/session-artifact-paths.ts';
import { safeSessionName } from '../../../src/daemon/session-paths.ts';
import type { DaemonError } from '@agent-device/kernel/errors';
import type { DaemonResponse } from '../../../src/daemon/daemon-request.ts';
import {
closeLoopbackServer,
listenOnLoopback,
skipWhenLoopbackUnavailable,
} from '../../../src/__tests__/test-utils/loopback.ts';
import { mkdtempForTestSync } from '../../../src/__tests__/test-utils/tmp-dir.ts';
const UPSTREAM_TOKEN = 'upstream-token';
const PROXY_TOKEN = 'proxy-token';
const TENANT = 'local-proxy-tenant';
/** What `resolveEffectiveSessionName` names an implicit session outside tenant isolation. */
const PLAIN_SESSION = 'cwd:9f1:default';
const REQUEST_ID = 'abc123';
const RECORD = '{"phase":"request_start"}\n{"phase":"request_failed"}\n';
function remoteError(): DaemonError {
return {
code: 'COMMAND_FAILED',
message: 'wait timed out',
logPath: '/Users/daemon-host/.agent-device/sessions/cwd_9f1_default/requests/abc123.ndjson',
diagnosticsRecord: { session: PLAIN_SESSION, requestId: REQUEST_ID },
};
}
test('Provider-backed integration local proxy serves a plain-session diagnostics record', async (t) => {
if (await skipWhenLoopbackUnavailable(t, 'local proxy request diagnostics coverage')) return;
const stateDir = mkdtempForTestSync('agent-device-proxy-request-diagnostics-');
const sessionsDir = path.join(stateDir, 'sessions');
const recordPath = resolveSessionRequestLogPath(
path.join(sessionsDir, safeSessionName(PLAIN_SESSION)),
REQUEST_ID,
);
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
fs.writeFileSync(recordPath, RECORD);
const upstream = await createDaemonHttpServer({
token: UPSTREAM_TOKEN,
handleRequest: async (): Promise<DaemonResponse> => ({ ok: true, data: {} }),
resolveRequestDiagnosticsPath: (ref) =>
resolveSessionRequestLogPath(
path.join(sessionsDir, safeSessionName(ref.session)),
ref.requestId,
),
});
const upstreamBaseUrl = `http://127.0.0.1:${await listenOnLoopback(upstream)}`;
const proxy = createDaemonProxyServer({
upstreamBaseUrl,
upstreamToken: UPSTREAM_TOKEN,
clientToken: PROXY_TOKEN,
});
try {
const proxyBaseUrl = `http://127.0.0.1:${await listenOnLoopback(proxy)}/agent-device`;
const throughProxy = await localizeRemoteDaemonError(remoteError(), {
endpoint: { baseUrl: proxyBaseUrl, token: PROXY_TOKEN, tenantId: TENANT },
stateDir: path.join(stateDir, 'proxy-client'),
});
assert.equal(throughProxy.logPathUnavailable, undefined, 'the proxy must serve the record');
assert.notEqual(throughProxy.logPath, undefined);
assert.equal(fs.readFileSync(throughProxy.logPath!, 'utf8'), RECORD);
// Parity: the client on the daemon host localizes byte-identical content.
const direct = await localizeRemoteDaemonError(remoteError(), {
endpoint: { baseUrl: upstreamBaseUrl, token: UPSTREAM_TOKEN, tenantId: TENANT },
stateDir: path.join(stateDir, 'direct-client'),
});
assert.equal(direct.logPathUnavailable, undefined);
assert.equal(fs.readFileSync(direct.logPath!, 'utf8'), RECORD);
} finally {
await closeLoopbackServer(proxy);
await closeLoopbackServer(upstream);
fs.rmSync(stateDir, { recursive: true, force: true });
}
});