Skip to content

Commit 6bcd696

Browse files
committed
refactor(remote): use shared capabilities and explicit script delivery
1 parent 4a4cba4 commit 6bcd696

31 files changed

Lines changed: 270 additions & 97 deletions

‎packages/contracts/src/daemon-http.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export type DaemonHealthPayload = {
154154
instanceId?: string;
155155
hostArch?: string;
156156
/** The daemon can publish session scripts as downloadable artifacts. */
157-
activeScriptArtifacts?: boolean;
157+
capabilities?: readonly string[];
158158
/** The lease backends this daemon admits; a host checks it before relying on one. */
159159
leaseBackends?: readonly string[];
160160
upstream?: unknown;
@@ -167,7 +167,7 @@ export function buildDaemonHealthPayload(
167167
upstream?: unknown;
168168
instanceId?: string;
169169
hostArch?: string;
170-
activeScriptArtifacts?: boolean;
170+
capabilities?: readonly string[];
171171
leaseBackends?: readonly string[];
172172
} = {},
173173
): DaemonHealthPayload {
@@ -178,7 +178,7 @@ export function buildDaemonHealthPayload(
178178
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
179179
...(options.instanceId !== undefined ? { instanceId: options.instanceId } : {}),
180180
...(options.hostArch !== undefined ? { hostArch: options.hostArch } : {}),
181-
...(options.activeScriptArtifacts === true ? { activeScriptArtifacts: true } : {}),
181+
...(options.capabilities !== undefined ? { capabilities: options.capabilities } : {}),
182182
...(options.leaseBackends !== undefined ? { leaseBackends: options.leaseBackends } : {}),
183183
...(options.upstream !== undefined ? { upstream: options.upstream } : {}),
184184
};

‎packages/daemon-client/src/__tests__/index-script-capability.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ test.each([
88
{ name: 'legacy daemon', health: {}, supported: false },
99
{
1010
name: 'upgraded proxy with legacy daemon',
11-
health: { activeScriptArtifacts: true, upstream: {} },
11+
health: { capabilities: ['active-script-artifacts'], upstream: {} },
1212
supported: false,
1313
},
14-
{ name: 'upgraded daemon', health: { activeScriptArtifacts: true }, supported: true },
14+
{
15+
name: 'upgraded daemon',
16+
health: { capabilities: ['active-script-artifacts'] },
17+
supported: true,
18+
},
1519
{
1620
name: 'forwarding proxy with upgraded daemon',
17-
health: { upstream: { activeScriptArtifacts: true } },
21+
health: { upstream: { capabilities: ['active-script-artifacts'] } },
1822
supported: true,
1923
},
2024
])('remote recording negotiates $name before dispatch', async ({ health, supported }) => {

‎packages/daemon-client/src/daemon-client-lifecycle.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,7 @@ async function ensureRemoteDaemon(settings: DaemonClientSettings): Promise<Ensur
254254
};
255255
const health = await cachedRemoteDaemonHealth(remoteInfo);
256256
if (health.reachable) {
257-
remoteInfo.remoteActiveScriptArtifacts =
258-
(health.upstream ?? health).activeScriptArtifacts === true;
257+
remoteInfo.capabilities = (health.upstream ?? health).capabilities;
259258
remoteInfo.remoteInstanceId = health.instanceId;
260259
remoteInfo.remoteUpstreamInstanceId = health.upstream?.instanceId;
261260
return { info: remoteInfo, startedByClient: false };

‎packages/daemon-client/src/daemon-client-metadata.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ export type DaemonInfo = {
3737
/** The provider plugin inventory as published; `readPluginInventory` validates it where it is read. */
3838
plugins?: unknown;
3939
baseUrl?: string;
40-
remoteActiveScriptArtifacts?: boolean;
4140
remoteInstanceId?: string;
4241
remoteUpstreamInstanceId?: string;
4342
};

‎packages/daemon-client/src/daemon-client-rpc.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog';
12
import type { HostEnvironment } from '@agent-device/kernel/source-value';
23
import type { DaemonWireRequest } from '@agent-device/contracts/command';
34
import type { DaemonResponse } from '@agent-device/kernel/contracts';
@@ -212,16 +213,16 @@ export function refuseOversizedDaemonRequest(payload: string, requestId: string
212213
export function assertRemoteActiveScriptArtifacts(info: DaemonInfo, req: DaemonWireRequest): void {
213214
if (
214215
info.baseUrl &&
215-
info.remoteActiveScriptArtifacts !== true &&
216-
(req.flags?.saveScript || req.command === 'session_save_script')
216+
!info.capabilities?.includes('active-script-artifacts') &&
217+
(req.flags?.saveScript || req.command === INTERNAL_COMMANDS.sessionSaveScript)
217218
) {
218219
throw new AppError(
219220
'INVALID_ARGS',
220221
'The remote daemon does not support downloadable session scripts.',
221222
{
222223
reason: 'remote_active_script_artifacts_unsupported',
223224
daemonRequestSent: false,
224-
hint: 'Use a daemon and proxy that advertise activeScriptArtifacts in the upstream health response. Keep the owning integration in control of upgrades.',
225+
hint: 'Use a daemon and proxy that advertise the active-script-artifacts capability in the upstream health response. Keep the owning integration in control of upgrades.',
225226
},
226227
);
227228
}

‎packages/daemon-client/src/daemon-client-transport.ts‎

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog';
12
import type { DaemonWireRequest } from '@agent-device/contracts/command';
23
import type { DaemonResponse } from '@agent-device/kernel/contracts';
34
import type { RequestProgressSink } from '@agent-device/contracts/progress';
45
import net from 'node:net';
56
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
67
import { loadNodeHttpRequester, readNodeHttpResponseBody } from '@agent-device/host-kit/transport';
78
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
8-
import type {
9-
DaemonPaths,
10-
DaemonTransportPreference,
9+
import {
10+
readDaemonCapabilities,
11+
type DaemonCapability,
12+
type DaemonPaths,
13+
type DaemonTransportPreference,
1114
} from '@agent-device/daemon-contracts/daemon-resolution';
1215
import {
1316
readDaemonHttpProgressResponse,
@@ -18,6 +21,8 @@ import {
1821
buildDaemonHttpAuthHeaders,
1922
buildDaemonHttpUrl,
2023
DAEMON_HTTP_INSTANCE_HEADER,
24+
DAEMON_HTTP_NETWORK_ACCESS_HEADER,
25+
DAEMON_HTTP_PUBLIC_NETWORK_ACCESS,
2126
DAEMON_HTTP_INSTANCE_MISMATCH_HEADER,
2227
DAEMON_HTTP_UPSTREAM_INSTANCE_HEADER,
2328
DAEMON_RPC_PROTOCOL_VERSION,
@@ -106,7 +111,6 @@ const NO_REQUEST_GUARD: RequestGuard = {
106111
refuseIfAborted: () => {},
107112
guard: async <T>(send: () => Promise<T>) => await send(),
108113
};
109-
110114
export function createRequestGuard(params: {
111115
signal: AbortSignal | undefined;
112116
requestId?: string;
@@ -143,7 +147,6 @@ export function createRequestGuard(params: {
143147
const LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS = 500;
144148
const REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS = 3000;
145149
const DAEMON_ENDPOINT_UNAVAILABLE_REASON = 'daemon_endpoint_unavailable';
146-
147150
export function isDaemonTransportUnavailableError(error: unknown): boolean {
148151
return (
149152
error instanceof AppError &&
@@ -161,7 +164,6 @@ function daemonEndpointUnavailableError(transport: ResolvedDaemonTransport): App
161164
{ reason: DAEMON_ENDPOINT_UNAVAILABLE_REASON, transport },
162165
);
163166
}
164-
165167
export type RemoteDaemonHealth = {
166168
reachable: boolean;
167169
statusCode?: number;
@@ -170,7 +172,7 @@ export type RemoteDaemonHealth = {
170172
rpcProtocolVersion?: number;
171173
instanceId?: string;
172174
hostArch?: string;
173-
activeScriptArtifacts?: boolean;
175+
capabilities?: readonly DaemonCapability[];
174176
/** The daemon behind a proxy, as the proxy's health reported it. */
175177
upstream?: RemoteDaemonHealthLink;
176178
/** The probe ran out of its time budget before an answer, rather than failing outright. */
@@ -179,9 +181,8 @@ export type RemoteDaemonHealth = {
179181

180182
type RemoteDaemonHealthLink = Pick<
181183
RemoteDaemonHealth,
182-
'service' | 'version' | 'rpcProtocolVersion' | 'instanceId' | 'hostArch' | 'activeScriptArtifacts'
184+
'service' | 'version' | 'rpcProtocolVersion' | 'instanceId' | 'hostArch' | 'capabilities'
183185
>;
184-
185186
export async function canConnect(
186187
info: DaemonInfo,
187188
preference: DaemonTransportPreference,
@@ -197,7 +198,6 @@ export async function canConnect(
197198
Date.now() < deadline
198199
: false;
199200
}
200-
201201
async function canConnectWithTransport(
202202
info: DaemonInfo,
203203
transport: ResolvedDaemonTransport,
@@ -207,7 +207,6 @@ async function canConnectWithTransport(
207207
? await canConnectHttp(info, timeoutMs)
208208
: await canConnectSocket(info.port, timeoutMs);
209209
}
210-
211210
export function canConnectSocket(
212211
port: number | undefined,
213212
timeoutMs = LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS,
@@ -237,7 +236,6 @@ export function canConnectSocket(
237236
function canConnectHttp(info: DaemonInfo, timeoutMs: number): Promise<boolean> {
238237
return readDaemonHttpHealth(info, timeoutMs).then((health) => health.reachable);
239238
}
240-
241239
export async function readRemoteDaemonHealth(
242240
info: DaemonInfo,
243241
probeTimeoutMs?: number,
@@ -277,7 +275,6 @@ function daemonHttpEndpoint(info: DaemonInfo, route: 'health' | 'rpc'): string |
277275
? `http://127.0.0.1:${info.httpPort}/${route}`
278276
: null;
279277
}
280-
281278
async function readDaemonHttpHealth(
282279
info: DaemonInfo,
283280
probeTimeoutMs?: number,
@@ -378,10 +375,9 @@ function readHealthLink(parsed: Record<string, unknown>): RemoteDaemonHealthLink
378375
typeof parsed.rpcProtocolVersion === 'number' ? parsed.rpcProtocolVersion : undefined,
379376
...(typeof parsed.instanceId === 'string' ? { instanceId: parsed.instanceId } : {}),
380377
...(typeof parsed.hostArch === 'string' ? { hostArch: parsed.hostArch } : {}),
381-
...(parsed.activeScriptArtifacts === true ? { activeScriptArtifacts: true } : {}),
378+
capabilities: readDaemonCapabilities(parsed.capabilities),
382379
};
383380
}
384-
385381
export async function sendRequest(
386382
info: DaemonInfo,
387383
req: DaemonWireRequest,
@@ -401,7 +397,6 @@ export async function sendRequest(
401397
throw discloseRequestFailure(transport.failure, req, transport.sent);
402398
}
403399
}
404-
405400
async function recoverFromTimeout(
406401
error: unknown,
407402
info: DaemonInfo,
@@ -443,7 +438,6 @@ function classifyTransportFailure(
443438
return undefined;
444439
}
445440
}
446-
447441
async function sendRequestWithFallback(
448442
info: DaemonInfo,
449443
req: DaemonWireRequest,
@@ -477,7 +471,6 @@ async function sendRequestWithFallback(
477471
return await sendRequestWithTransport(info, req, statePaths, timeoutMs, fallback, options);
478472
}
479473
}
480-
481474
async function retryAfterRemoteInstanceMismatch(
482475
info: DaemonInfo,
483476
req: DaemonWireRequest,
@@ -502,7 +495,7 @@ async function retryAfterRemoteInstanceMismatch(
502495
daemonBaseUrl: info.baseUrl,
503496
});
504497
}
505-
info.remoteActiveScriptArtifacts = (health.upstream ?? health).activeScriptArtifacts === true;
498+
info.capabilities = (health.upstream ?? health).capabilities;
506499
info.remoteInstanceId = health.instanceId;
507500
info.remoteUpstreamInstanceId = health.upstream?.instanceId;
508501
cacheRemoteDaemonHealth(info, health);
@@ -543,7 +536,6 @@ function isRemoteTransportFailure(error: unknown): boolean {
543536
error.details?.reason === 'daemon_response_read_failure')
544537
);
545538
}
546-
547539
async function sendRequestWithTransport(
548540
info: DaemonInfo,
549541
req: DaemonWireRequest,
@@ -649,7 +641,6 @@ function handleTransportError(
649641
err instanceof Error ? err : undefined,
650642
);
651643
}
652-
653644
async function sendSocketRequest(
654645
info: DaemonInfo,
655646
req: DaemonWireRequest,
@@ -788,7 +779,6 @@ function isRemoteInstanceMismatchResponse(
788779
): boolean {
789780
return statusCode === 409 && headers[DAEMON_HTTP_INSTANCE_MISMATCH_HEADER] === 'true';
790781
}
791-
792782
async function sendHttpRequest(
793783
info: DaemonInfo,
794784
req: DaemonWireRequest,
@@ -807,6 +797,9 @@ async function sendHttpRequest(
807797
};
808798
if (info.baseUrl) {
809799
Object.assign(headers, buildDaemonHttpAuthHeaders(info.token));
800+
if (req.flags?.saveScript || req.command === INTERNAL_COMMANDS.sessionSaveScript) {
801+
headers[DAEMON_HTTP_NETWORK_ACCESS_HEADER] = DAEMON_HTTP_PUBLIC_NETWORK_ACCESS;
802+
}
810803
Object.assign(headers, buildRemoteInstancePreconditionHeaders(info));
811804
}
812805
const transport = await loadNodeHttpRequester(rpcUrl.protocol);
@@ -970,7 +963,6 @@ function invalidateRemoteDaemonHealth(info: DaemonInfo): void {
970963
remoteHealthCache = undefined;
971964
}
972965
}
973-
974966
export async function cachedRemoteDaemonHealth(info: DaemonInfo): Promise<RemoteDaemonHealth> {
975967
if (remoteHealthCache && matchesRemoteIdentity(remoteHealthCache, info)) {
976968
return await remoteHealthCache.health;

‎packages/daemon-contracts/src/daemon-resolution.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export function resolveDaemonTransportPreference(
148148
}
149149

150150
/** What a daemon can do beyond the base RPC contract, published in its `daemon.json`. */
151-
export const DAEMON_CAPABILITIES = ['quiesce'] as const;
151+
export const DAEMON_CAPABILITIES = ['quiesce', 'active-script-artifacts'] as const;
152152
export type DaemonCapability = (typeof DAEMON_CAPABILITIES)[number];
153153

154154
/**

‎packages/daemon/src/core/__tests__/session-script-writer.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,12 @@ test('teardown discards an unpublished caller-owned recording without writing on
968968
scriptPublication: {
969969
kind: 'authoring',
970970
status: 'armed',
971-
target: { kind: 'explicit', path: target, clientPath: '/caller/journey.ad', force: false },
971+
target: {
972+
kind: 'explicit',
973+
path: target,
974+
remote: true,
975+
force: false,
976+
},
972977
},
973978
actions: [action({ command: 'open', positionals: ['Demo'] })],
974979
});

‎packages/daemon/src/core/daemon-request.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type DaemonRequestInternal = ReplayDispatchOptions & {
3333
*/
3434
webSession?: WebSessionIdentity;
3535
publicNetworkOnly?: true;
36+
remoteScriptDelivery?: true;
3637
/**
3738
* The steps a batch still has ahead of this one. The open seam derives platform readiness
3839
* policy (runner demand) from it; the transport strips `internal`, so it never arrives from a

‎packages/daemon/src/core/session-action-recorder.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { TargetEvidenceMode } from '@agent-device/selectors/target-evidence
2626
export type RecordActionEntry = {
2727
command: string;
2828
clientScriptPath?: string;
29+
remoteScriptDelivery?: boolean;
2930
positionals: string[];
3031
flags: CommandFlags;
3132
runtime?: SessionRuntimeHints;
@@ -67,7 +68,13 @@ export function recordActionEntry(
6768
if (isSensitiveFillText(entry.flags) && entry.flags.recordAs === undefined) return undefined;
6869
if (isExcludedRepairSegmentObservation(session, entry)) return undefined;
6970
if (entry.flags)
70-
applyRecordedSaveScriptFlags(session, entry.flags, hostCwd, entry.clientScriptPath);
71+
applyRecordedSaveScriptFlags(
72+
session,
73+
entry.flags,
74+
hostCwd,
75+
entry.clientScriptPath,
76+
entry.remoteScriptDelivery,
77+
);
7178
const fillLiteral = readRecordedFillLiteral(entry);
7279
// #1398: register the literal only AFTER protecting this SAME entry, so a
7380
// fill's own recording is never passed through the session-wide substring

0 commit comments

Comments
 (0)