From f6b26e27a9d37aa9881c8874ea99ab376e7ac48a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:09:47 +0000 Subject: [PATCH 1/6] feat(sdk-java): project Runtime tool replies onto the broker contract HttpRuntimeTransport now implements RuntimeTransport. Status and cancel answers keep only state and a settled result, and session verbs that the v2 tool contract does not define fail closed. Co-authored-by: jinye --- .../runtimebroker/HttpRuntimeTransport.java | 67 +++++++++++++++++-- .../HttpRuntimeTransportTest.java | 24 ++++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java index 5fb534987dd..654b128cbd2 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java @@ -25,10 +25,11 @@ * HTTP client for the private Managed Runtime v2 routes. * *

Attestation plus the tool operations execute, status, and cancel, keyed - * by the original call reference. Prepare, acquire, control, and release stay - * on a later transport slice. + * by the original call reference. Status and cancel answers are projected to + * the Broker's closed state and result. Acquire, control, and release are + * not part of the v2 tool contract and fail closed. */ -public final class HttpRuntimeTransport { +public final class HttpRuntimeTransport implements RuntimeTransport { static final int BODY_LIMIT_BYTES = 16 * 1024; static final int TOOL_REQUEST_LIMIT_BYTES = 256 * 1024; static final int TOOL_RESULT_LIMIT_BYTES = 1024 * 1024; @@ -194,7 +195,8 @@ public CompletionStage> status(RuntimeLease lease, body.put("afterSequence", afterSequence); byte[] encoded = encodeToolRequest(body, BODY_LIMIT_BYTES); return post(lease, STATUS_PATH, encoded, TOOL_RESULT_LIMIT_BYTES) - .thenApply(bytes -> parseToolResponse(bytes, "status")); + .thenApply(bytes -> projectClosedStatus( + parseToolResponse(bytes, "status"), "status")); } /** Asks the Runtime to cancel one call by its original reference. */ @@ -209,7 +211,62 @@ public CompletionStage> cancel(RuntimeLease lease, body.put("reference", referenceIdentity(reference)); byte[] encoded = encodeToolRequest(body, BODY_LIMIT_BYTES); return post(lease, CANCEL_PATH, encoded, TOOL_RESULT_LIMIT_BYTES) - .thenApply(bytes -> parseToolResponse(bytes, "cancel")); + .thenApply(bytes -> projectClosedStatus( + parseToolResponse(bytes, "cancel"), "cancel")); + } + + /** + * Session verbs are not on the v2 tool contract. Fail closed instead of + * inventing a route the worker does not serve. + */ + @Override + public CompletionStage acquire(RuntimeLease lease, + RuntimeSession session) { + return unsupportedSessionVerb(); + } + + @Override + public CompletionStage control(RuntimeLease lease, + RuntimeSession session, Map operation) { + return unsupportedSessionVerb(); + } + + @Override + public CompletionStage release(RuntimeLease lease, + RuntimeSession session) { + return unsupportedSessionVerb(); + } + + private static CompletionStage unsupportedSessionVerb() { + return CompletableFuture.failedFuture(new RuntimeBrokerException(501, + "runtime_session_verb_unsupported", + "Runtime transport does not support session verbs.", false)); + } + + /** + * The Broker accepts only {@code state}, plus {@code result} when the + * state is {@code settled}. Wire fields such as {@code protocolVersion} + * and {@code lastSequence} stay on the HTTP response and are validated + * before this projection. + */ + private static Map projectClosedStatus( + Map response, String operation) { + Object state = response.get("state"); + if (!(state instanceof String text) || !TOOL_STATES.contains(text)) { + throw protocol("Managed Runtime " + operation + + " returned an invalid state."); + } + boolean settled = "settled".equals(text); + if (settled != response.containsKey("result")) { + throw protocol("Managed Runtime " + operation + + " result does not match its state."); + } + Map projected = new LinkedHashMap<>(); + projected.put("state", text); + if (settled) { + projected.put("result", response.get("result")); + } + return Map.copyOf(projected); } private static Map referenceIdentity( diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java index b21a17d670f..4d108ed4f1e 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; @@ -397,9 +398,23 @@ void statusAnswersUnknownFromTheSharedFixture() throws Exception { assertEquals(statusSuite.required("canonicalRequest") .required("body"), sent); assertEquals("unknown", answer.get("state")); + assertEquals(Set.of("state"), answer.keySet()); assertNull(answer.get("result")); } + @Test + void sessionVerbsFailClosed() { + assertTrue(transport instanceof RuntimeTransport); + CompletionException thrown = assertThrows(CompletionException.class, + () -> transport.acquire(toolLease(1), toolSession()) + .toCompletableFuture().join()); + RuntimeBrokerException failure = + (RuntimeBrokerException) thrown.getCause(); + assertEquals(501, failure.getStatusCode()); + assertEquals("runtime_session_verb_unsupported", failure.getCode()); + assertFalse(failure.isRetryable()); + } + @Test void cancelSettlesAPreparedExecutionFromTheSharedFixture() throws Exception { @@ -714,7 +729,10 @@ void acceptsLastSequenceOnlyOnStatus() throws Exception { toolLease(server.getAddress().getPort()), toolSession(), toolReference(), 0).toCompletableFuture() .get(2, TimeUnit.SECONDS); - assertEquals(0, ((Number) status.get("lastSequence")).longValue()); + assertFalse(status.containsKey("lastSequence")); + assertFalse(status.containsKey("protocolVersion")); + assertEquals("settled", status.get("state")); + assertTrue(status.containsKey("result")); } @Test @@ -736,8 +754,8 @@ void validatesSequenceNumbersWithoutRoundingOrLongTruncation() toolLease(server.getAddress().getPort()), toolSession(), toolReference(), 0).toCompletableFuture() .get(2, TimeUnit.SECONDS); - assertEquals(0, new BigDecimal(valid).compareTo(new BigDecimal( - answer.get("lastSequence").toString())), valid); + assertEquals(Set.of("state"), answer.keySet(), valid); + assertEquals("unknown", answer.get("state"), valid); } reply.set(json(200, ("{\"protocolVersion\":2.000000000000000001," + "\"state\":\"unknown\"}").getBytes(StandardCharsets.UTF_8))); From 66cf4940f2384999f68b643ca3837a989851f4bf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:13:29 +0000 Subject: [PATCH 2/6] feat(sdk-java): adopt local Runtime processes by ready record The local-process provisioner now reports a durable kind, boots from the persisted seed, and reconciles a restored lease by attesting it again. A process that no longer answers is not found instead of guessed ready. Co-authored-by: jinye --- .../LocalProcessRuntimeProvisioner.java | 124 +++++++++++++++--- .../LocalProcessRuntimeProvisionerTest.java | 43 ++++++ 2 files changed, 150 insertions(+), 17 deletions(-) diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java index a55fec10bbe..02c97c124c7 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java @@ -29,6 +29,7 @@ */ public final class LocalProcessRuntimeProvisioner implements RuntimeProvisioner { + static final String KIND = "local-process"; private static final Duration READY_TIMEOUT = Duration.ofSeconds(30); private static final int READY_RECORD_LIMIT = 32 * 1024; private static final Pattern CAPABILITY_DIGEST = @@ -59,10 +60,55 @@ public LocalProcessRuntimeProvisioner(List command, this.transport = transport; } + @Override + public String kind() { + return KIND; + } + @Override public CompletionStage provision( RuntimeProvisionRequest request) { - return CompletableFuture.supplyAsync(() -> start(request), executor); + return CompletableFuture.supplyAsync(() -> start(request, null), + executor); + } + + @Override + public CompletionStage provision( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + if (seed == null) { + throw new IllegalArgumentException("seed is required"); + } + return CompletableFuture.supplyAsync(() -> start(request, seed), + executor); + } + + @Override + public CompletionStage ensureResource( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed, + RuntimeResourceHandle knownHandle) { + if (knownHandle != null) { + if (!KIND.equals(knownHandle.getKind()) + || knownHandle.getVersion() != 1) { + CompletableFuture failed = + new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeBrokerException(409, + "runtime_broker_resource_conflict", + "Managed Runtime resource identity conflicts.", + false)); + return failed; + } + return CompletableFuture.completedFuture(knownHandle); + } + return CompletableFuture.completedFuture(new RuntimeResourceHandle( + KIND, 1, Map.of("provider", KIND))); + } + + @Override + public CompletionStage reconcile( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed, + RuntimeResourceHandle handle, RuntimeLease lastLease) { + return CompletableFuture.supplyAsync( + () -> observe(request, seed, handle, lastLease), executor); } @Override @@ -101,18 +147,19 @@ public void close() { executor.shutdownNow(); } - private RuntimeLease start(RuntimeProvisionRequest request) { + private RuntimeLease start(RuntimeProvisionRequest request, + RuntimeProvisionSeed provided) { OwnedProcess ownedProcess = null; boolean adopted = false; try { - String runtimeInstanceId = UUID.randomUUID().toString(); - String runtimeIncarnation = UUID.randomUUID().toString(); - String leaseId = UUID.randomUUID().toString(); - String provisionRequestId = UUID.randomUUID().toString(); - byte[] tokenBytes = new byte[32]; - RANDOM.nextBytes(tokenBytes); - String token = Base64.getUrlEncoder().withoutPadding() - .encodeToString(tokenBytes); + RuntimeProvisionSeed seed = provided != null + ? provided : newSeed(); + String runtimeInstanceId = seed.getProvisionalRuntimeId(); + String runtimeIncarnation = seed.getGatewayIncarnation(); + String leaseId = seed.getLeaseId(); + String provisionRequestId = seed.getProvisionRequestId(); + String token = seed.getToken(); + long epoch = seed.getEpoch(); RuntimeScope scope = request.getScope(); if (!CAPABILITY_DIGEST.matcher(scope.getCapabilityDigest()) .matches()) { @@ -122,7 +169,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { } JSONObject boot = new JSONObject(); boot.put("capabilityDigest", scope.getCapabilityDigest()); - boot.put("epoch", 1); + boot.put("epoch", epoch); boot.put("isolationClass", scope.getIsolationClass()); boot.put("leaseId", leaseId); boot.put("provisionRequestId", provisionRequestId); @@ -139,10 +186,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { .directory(workingDirectory.toFile()) .redirectError(ProcessBuilder.Redirect.DISCARD) .start(); - ownedProcess = new OwnedProcess(process, - new RuntimeProvisionSeed(provisionRequestId, - runtimeInstanceId, runtimeIncarnation, leaseId, - 1, token)); + ownedProcess = new OwnedProcess(process, seed); process.getOutputStream().write(boot.toJSONString() .getBytes(StandardCharsets.UTF_8)); process.getOutputStream().close(); @@ -157,7 +201,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { || !runtimeIncarnation.equals( ready.get("runtimeIncarnation")) || !leaseId.equals(ready.get("leaseId")) - || !Long.valueOf(1L).equals(number(ready.get("epoch")))) { + || !Long.valueOf(epoch).equals(number(ready.get("epoch")))) { throw failed("Managed Runtime ready record is invalid."); } URI endpoint = URI.create(String.valueOf(ready.get("url"))); @@ -166,7 +210,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { throw failed("Managed Runtime ready record is invalid."); } RuntimeLease lease = new RuntimeLease(runtimeInstanceId, - endpoint, token, leaseId, 1); + endpoint, token, leaseId, epoch); attest(request, ownedProcess.seed, lease); owned.put(runtimeInstanceId, ownedProcess); adopted = true; @@ -181,6 +225,52 @@ private RuntimeLease start(RuntimeProvisionRequest request) { } } + private RuntimeObservation observe(RuntimeProvisionRequest request, + RuntimeProvisionSeed seed, RuntimeResourceHandle handle, + RuntimeLease lastLease) { + if (handle != null && !KIND.equals(handle.getKind())) { + return RuntimeObservation.conflict(handle); + } + if (lastLease == null || seed == null) { + return RuntimeObservation.unknown(handle); + } + OwnedProcess process = owned.get(lastLease.getRuntimeInstanceId()); + if (process != null && !process.process.isAlive()) { + return RuntimeObservation.notFound(); + } + try { + attest(request, seed, lastLease); + } catch (RuntimeBrokerException failure) { + if ("managed_runtime_identity_conflict".equals(failure.getCode()) + || "managed_runtime_unauthorized".equals( + failure.getCode())) { + return RuntimeObservation.conflict(handle); + } + if (process == null) { + return RuntimeObservation.notFound(); + } + return RuntimeObservation.unknown(handle); + } + RuntimeResourceHandle readyHandle = handle != null ? handle + : new RuntimeResourceHandle(KIND, 1, Map.of("provider", KIND)); + return RuntimeObservation.ready(readyHandle, lastLease.getEndpoint(), + lastLease.getRuntimeInstanceId(), lastLease.getLeaseId(), + lastLease.getEpoch()); + } + + private static RuntimeProvisionSeed newSeed() { + String runtimeInstanceId = UUID.randomUUID().toString(); + String runtimeIncarnation = UUID.randomUUID().toString(); + String leaseId = UUID.randomUUID().toString(); + String provisionRequestId = UUID.randomUUID().toString(); + byte[] tokenBytes = new byte[32]; + RANDOM.nextBytes(tokenBytes); + String token = Base64.getUrlEncoder().withoutPadding() + .encodeToString(tokenBytes); + return new RuntimeProvisionSeed(provisionRequestId, runtimeInstanceId, + runtimeIncarnation, leaseId, 1, token); + } + private void attestOwned(RuntimeProvisionRequest request, RuntimeLease lease) { OwnedProcess process = owned.get(lease.getRuntimeInstanceId()); diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java index 419164bede3..96118e7b881 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; @@ -20,6 +21,37 @@ class LocalProcessRuntimeProvisionerTest { private static final String DIGEST = "sha256:" + "a".repeat(64); + @Test + void reportsADurableKindAndLosesAMissingProcess() throws Exception { + HttpRuntimeTransport transport = new HttpRuntimeTransport(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner(List.of("node"), + Path.of("").toAbsolutePath(), transport)) { + assertEquals(LocalProcessRuntimeProvisioner.KIND, + provisioner.kind()); + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", + "7", "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, provisioner.kind()); + RuntimeProvisionSeed seed = RuntimeProvisionSeed.create( + "binding-1", 1); + RuntimeResourceHandle handle = provisioner + .ensureResource(request, seed, null) + .toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(LocalProcessRuntimeProvisioner.KIND, + handle.getKind()); + RuntimeLease missing = new RuntimeLease( + seed.getProvisionalRuntimeId(), + URI.create("http://127.0.0.1:1"), seed.getToken(), + seed.getLeaseId(), seed.getEpoch()); + RuntimeObservation observation = provisioner + .reconcile(request, seed, handle, missing) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(RuntimeObservation.Outcome.NOT_FOUND, + observation.getOutcome()); + } + } + @Test void adoptsAWorkerOnlyAfterAttestationAndRejectsToolRoutes() throws Exception { @@ -414,6 +446,17 @@ public RuntimeBindingRecord releaseOperation(String bindingId, } private static final class AcceptingTransport implements RuntimeTransport { + @Override + public java.util.concurrent.CompletionStage attest( + RuntimeLease lease, RuntimeProvisionRequest request, + RuntimeProvisionSeed seed) { + return java.util.concurrent.CompletableFuture.completedFuture( + new RuntimeAttestation(lease.getRuntimeInstanceId(), + seed.getGatewayIncarnation(), lease.getLeaseId(), + lease.getEpoch(), request.getScope(), + seed.getProvisionRequestId())); + } + @Override public java.util.concurrent.CompletionStage acquire( RuntimeLease lease, RuntimeSession session) { From b638dd28c30db1e8a79c1cff4b0d505bf4797e2b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 25 Sep 2026 10:50:06 +0000 Subject: [PATCH 3/6] feat(serve): add the hosted harness profile and broker tool path qwen serve --profile hosted-harness now requires a loopback bind, a harness token, and the Runtime Broker origin, token, and capability digest. The broker client speaks the merged execution routes, and the local-process provisioner adopts workers by ready record. Co-authored-by: jinye --- packages/acp-bridge/src/bridgeTypes.ts | 211 +- packages/acp-bridge/src/ndJsonStream.ts | 2 +- .../managed-tool-media.test.ts | 179 ++ .../src/acp-integration/managed-tool-media.ts | 93 + packages/cli/src/commands/serve.ts | 88 + .../broker-managed-runtime-provider.test.ts | 531 +++++ .../serve/broker-managed-runtime-provider.ts | 1060 ++++++++++ .../src/serve/hosted-harness-contract.test.ts | 41 +- .../cli/src/serve/hosted-harness-contract.ts | 26 +- .../cli/src/serve/hosted-harness-profile.ts | 83 + .../local-process-runtime-activator.test.ts | 237 +++ .../serve/local-process-runtime-activator.ts | 449 ++++ .../src/serve/managed-runtime-activator.ts | 43 + .../cli/src/serve/managed-runtime-protocol.ts | 224 ++ .../serve/managed-runtime-provider.test.ts | 1880 +++++++++++++++++ .../cli/src/serve/managed-runtime-provider.ts | 1568 ++++++++++++++ packages/cli/src/serve/run-qwen-serve.ts | 29 + packages/cli/src/serve/types.ts | 29 + packages/core/src/index.ts | 9 + ...managed-tool-file-history-protocol.test.ts | 311 +++ .../managed-tool-file-history-protocol.ts | 328 +++ .../tools/managed-tool-file-history.test.ts | 274 +++ .../src/tools/managed-tool-file-history.ts | 131 ++ .../src/tools/managed-tool-protocol.test.ts | 367 ++++ .../core/src/tools/managed-tool-protocol.ts | 447 ++++ .../src/tools/managed-tool-runtime.test.ts | 845 ++++++++ .../core/src/tools/managed-tool-runtime.ts | 961 +++++++++ .../RuntimeBrokerHttpServer.java | 518 +++++ .../StaticRuntimeProvisioner.java | 33 + 29 files changed, 10962 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/acp-integration/managed-tool-media.test.ts create mode 100644 packages/cli/src/acp-integration/managed-tool-media.ts create mode 100644 packages/cli/src/serve/broker-managed-runtime-provider.test.ts create mode 100644 packages/cli/src/serve/broker-managed-runtime-provider.ts create mode 100644 packages/cli/src/serve/hosted-harness-profile.ts create mode 100644 packages/cli/src/serve/local-process-runtime-activator.test.ts create mode 100644 packages/cli/src/serve/local-process-runtime-activator.ts create mode 100644 packages/cli/src/serve/managed-runtime-activator.ts create mode 100644 packages/cli/src/serve/managed-runtime-protocol.ts create mode 100644 packages/cli/src/serve/managed-runtime-provider.test.ts create mode 100644 packages/cli/src/serve/managed-runtime-provider.ts create mode 100644 packages/core/src/tools/managed-tool-file-history-protocol.test.ts create mode 100644 packages/core/src/tools/managed-tool-file-history-protocol.ts create mode 100644 packages/core/src/tools/managed-tool-file-history.test.ts create mode 100644 packages/core/src/tools/managed-tool-file-history.ts create mode 100644 packages/core/src/tools/managed-tool-protocol.test.ts create mode 100644 packages/core/src/tools/managed-tool-protocol.ts create mode 100644 packages/core/src/tools/managed-tool-runtime.test.ts create mode 100644 packages/core/src/tools/managed-tool-runtime.ts create mode 100644 packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java create mode 100644 packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 5e2329e8f42..b45977a58c4 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -7,6 +7,7 @@ import type { ApprovalMode, BackgroundNotificationTurn, + ManagedToolV2Client, GoalControlRequest, GoalSnapshotV2, GoalStateResponse, @@ -122,6 +123,124 @@ export type BridgePromptRequest = Omit & { eventDetailMode?: LiveReplayMode; }; +export interface BridgeManagedRuntimeToolManifest { + capabilityDigest: string; + tools: unknown[]; +} + +export interface BridgeManagedRuntimeToolExecuteRequest { + executionId: string; + turnId: string; + toolCallId: string; + capabilityDigest: string; + toolName: string; + input: Record; +} + +export interface BridgeManagedRuntimeToolExecuteResult { + responseParts: unknown[]; + executionStatus?: 'not_started' | 'success' | 'error' | 'cancelled'; + error?: { message: string; type?: string }; +} + +/** Private Hosted Harness capability for one Session's durable store. */ +export interface BridgeManagedSessionStore { + baseUrl: string; + tenantId: string; + workspaceId: string; + writerId: string; + leaseDurationMs: number; +} + +const MANAGED_SESSION_STORE_FIELDS = new Set([ + 'baseUrl', + 'tenantId', + 'workspaceId', + 'writerId', + 'leaseDurationMs', +]); +const MANAGED_SESSION_STORE_TENANT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; + +export function parseBridgeManagedSessionStore( + value: unknown, +): BridgeManagedSessionStore { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('managedSessionStore must be an object'); + } + const record = value as Record; + if ( + Object.keys(record).some((key) => !MANAGED_SESSION_STORE_FIELDS.has(key)) + ) { + throw new TypeError('managedSessionStore contains an unsupported field'); + } + const baseUrl = requireManagedSessionStoreString(record, 'baseUrl', 2048); + const tenantId = requireManagedSessionStoreString(record, 'tenantId', 128); + const workspaceId = requireManagedSessionStoreString( + record, + 'workspaceId', + 512, + ); + const writerId = requireManagedSessionStoreString(record, 'writerId', 512); + if (!MANAGED_SESSION_STORE_TENANT_PATTERN.test(tenantId)) { + throw new TypeError('managedSessionStore.tenantId is invalid'); + } + const parsedBaseUrl = new URL(baseUrl); + if ( + (parsedBaseUrl.protocol !== 'http:' && + parsedBaseUrl.protocol !== 'https:') || + parsedBaseUrl.username !== '' || + parsedBaseUrl.password !== '' || + parsedBaseUrl.search !== '' || + parsedBaseUrl.hash !== '' + ) { + throw new TypeError( + 'managedSessionStore.baseUrl must be an HTTP(S) URL without credentials, query, or fragment', + ); + } + const leaseDurationMs = record['leaseDurationMs']; + if ( + typeof leaseDurationMs !== 'number' || + !Number.isInteger(leaseDurationMs) || + leaseDurationMs < 1_000 || + leaseDurationMs > 300_000 + ) { + throw new TypeError( + 'managedSessionStore.leaseDurationMs must be an integer from 1000 through 300000', + ); + } + return Object.freeze({ + baseUrl: parsedBaseUrl.toString().replace(/\/$/u, ''), + tenantId, + workspaceId, + writerId, + leaseDurationMs, + }); +} + +function requireManagedSessionStoreString( + record: Readonly>, + field: string, + maxBytes: number, +): string { + const value = record[field]; + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + [...value].some((character) => { + const codePoint = character.codePointAt(0); + return ( + codePoint !== undefined && (codePoint < 0x20 || codePoint === 0x7f) + ); + }) + ) { + throw new TypeError(`managedSessionStore.${field} is invalid`); + } + return value; +} + +export type { ManagedToolV2Client } from '@qwen-code/qwen-code-core'; + export interface RewindRequest { promptId: string; rewindFiles?: boolean; @@ -176,6 +295,8 @@ export interface BridgeSpawnRequest { * sessionId field. */ sessionId?: string; + /** Trusted Hosted Harness route only; forwarded through private ACP metadata. */ + managedSessionStore?: BridgeManagedSessionStore; } /** Internal daemon-only creation surface for a managed standalone session. */ @@ -191,7 +312,7 @@ export interface BridgeStandaloneSpawnRequest { approvalMode?: ApprovalMode; } -export type { BackgroundNotificationTurn } from '@qwen-code/qwen-code-core'; +export type { BackgroundNotificationTurn }; export function parseBackgroundNotificationTurn( value: unknown, @@ -314,6 +435,8 @@ export interface BridgeRestoreSessionRequest { /** Keep inherited fork records as model context without replaying them. */ hideInheritedHistory?: boolean; approvalMode?: ApprovalMode; + /** Trusted Hosted Harness load only; forwarded through private ACP metadata. */ + managedSessionStore?: BridgeManagedSessionStore; /** * Persisted parent lineage recovered from the transcript by the caller (the * serve layer reads it before restore). Re-seeds the restored live entry so a @@ -349,6 +472,7 @@ export const LOAD_REPLAY_MAX_BYTES = 32 * 1024 * 1024; export const LOAD_REPLAY_MAX_UPDATES = 10_000; export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId'; +export const MANAGED_SESSION_STORE_META_KEY = 'qwen.managedSessionStore.v1'; export const SESSION_INITIALIZATION_DEADLINE_META_KEY = 'qwen.daemon.sessionInitializationDeadlineMs'; export const SESSION_INITIALIZATION_TIMEOUT_ERROR_KIND = @@ -448,7 +572,8 @@ export type ActiveWorkHoldCategory = | 'notification' | 'shell' | 'session' - | 'workflow'; + | 'workflow' + | 'tool'; /** Categories understood by active-work v1 before category negotiation was * added to the daemon's initialize request. */ @@ -461,6 +586,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'shell', 'session', 'workflow', + 'tool', ]; export interface ActiveWorkHeartbeatCapabilityV1 { @@ -970,6 +1096,12 @@ export interface BridgeSessionSummary { prs?: SessionPrInfo[]; } +/** Original event-bus cursor captured when a prompt id was first admitted. */ +export interface BridgePromptAdmissionWatermark { + lastEventId: number; + eventEpoch: string; +} + /** * In-memory equality token for daemon-observed session-catalog changes. * `generation` is unique to a bridge instance; `revision` increases @@ -1114,6 +1246,15 @@ export interface BridgeClientRequestContext { * smuggle a continuation through the prompt path. */ continue?: boolean; + /** + * Internal recovery identity accepted only by the Hosted Harness private + * continuation route. The ACP child verifies it against the current durable + * `results_ready` checkpoint before the bridge admits a model continuation. + */ + managedRuntimeContinuation?: { + checkpointId: string; + activationId: string; + }; /** * Internal: set ONLY after load/resume when the child hinted that a trailing * ask_user_question should be re-hung. HTTP routes never populate this from @@ -1133,6 +1274,10 @@ export interface BridgeClientRequestContext { export const DAEMON_MODEL_PROMPT_META_KEY = 'qwen.daemon.modelPrompt'; export const DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY = 'qwen.daemon.restoreAskUserQuestion'; +export const DAEMON_RESTORE_MANAGED_APPROVAL_META_KEY = + 'qwen.daemon.restoreManagedApproval'; +export const DAEMON_MANAGED_RUNTIME_RECOVERY_META_KEY = + 'qwen.daemon.managedRuntimeRecovery'; /** * Response `_meta` key on `session/request_permission` cancellations telling * the child WHY the bridge resolved a cancel (`timeout` / `agent_cancelled` @@ -1154,6 +1299,8 @@ export const DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY = 'qwen.daemon.suppressRestoreAskUserQuestion'; export const DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY = 'qwen.daemon.suppressWorktreeContextRestore'; +export const DAEMON_PASSIVE_MANAGED_RUNTIME_RECOVERY_META_KEY = + 'qwen.daemon.passiveManagedRuntimeRecovery'; export const DAEMON_ATTACHMENT_REFERENCES_META_KEY = 'qwen.daemon.attachmentReferences'; export const MAX_TRUSTED_MODEL_PROMPT_CHARS = 64 * 1024; @@ -1771,10 +1918,12 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { * session FIFO-serialize through a per-session queue. * * Admission contract: implementations must not be `async`. Admission - * failures such as `InvalidClientIdError`, `PromptQueueFullError`, and - * pre-aborted signals throw synchronously so HTTP routes can reject before - * returning 202. Deferred failures such as `SessionNotFoundError` may be - * returned as rejected promises. + * failures such as `InvalidClientIdError`, `PromptQueueFullError`, + * `PromptIdConflictError`, and pre-aborted signals throw synchronously so + * HTTP routes can reject before returning 202. A retry with the same + * `promptId` and payload returns the original promise and must not abort + * the admitted turn. Deferred failures such as `SessionNotFoundError` may + * be returned as rejected promises. */ sendPrompt( sessionId: string, @@ -1783,6 +1932,33 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { context?: BridgeClientRequestContext, ): Promise; + /** Private owned-worker transport; the caller must hold the Runtime client id. */ + getManagedToolV2Client( + sessionId: string, + context: BridgeClientRequestContext, + ): ManagedToolV2Client; + + /** Read the safe Tool-only capability set pinned by a Managed Runtime. */ + getManagedRuntimeToolManifest( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + + /** Execute one safe Tool Call in the Runtime without invoking its model. */ + executeManagedRuntimeTool( + sessionId: string, + request: BridgeManagedRuntimeToolExecuteRequest, + signal: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise; + + /** Cancel one matching Tool-only Runtime execution best-effort. */ + cancelManagedRuntimeTool( + sessionId: string, + executionId: string, + context?: BridgeClientRequestContext, + ): Promise<{ readonly cancelled: boolean }>; + /** * Return the pending prompt queue for a session. Includes the currently * running prompt (state `'running'`) and any prompts waiting in the FIFO @@ -1849,6 +2025,16 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { */ getSessionEventEpoch(sessionId: string): string; + /** + * Return the original event cursor for an admitted prompt. Retries must use + * this cursor instead of the bus tail observed at retry time, otherwise + * events emitted after the first admission can be skipped. + */ + getPromptAdmissionWatermark?( + sessionId: string, + promptId: string, + ): BridgePromptAdmissionWatermark | undefined; + /** * Return the daemon's current effective cwd for a live session without * exposing it through public session summaries. @@ -1886,6 +2072,13 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { context?: BridgeClientRequestContext, ): SessionMetadataUpdate; + /** Persist a Hosted Managed title before acknowledging the control plane. */ + commitSessionTitle?( + sessionId: string, + title: string, + context?: BridgeClientRequestContext, + ): Promise; + /** * Re-hydrate the in-memory PR binding list of a live session from the * persisted sidecar after the entry was re-created empty (daemon @@ -2656,6 +2849,12 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { /** Test/inspection hook: number of live sessions. */ readonly sessionCount: number; + /** + * User-facing live sessions. Internal Tool Runtime (`managed-gateway`) + * workers are omitted so they do not occupy `maxSessions`. + */ + readonly userFacingSessionCount: number; + /** * Whether an ACP channel is currently live (spawned and not dying). * Distinct from `sessionCount > 0`: a channel can be live with zero diff --git a/packages/acp-bridge/src/ndJsonStream.ts b/packages/acp-bridge/src/ndJsonStream.ts index 7543459fb0c..3d166cd8a71 100644 --- a/packages/acp-bridge/src/ndJsonStream.ts +++ b/packages/acp-bridge/src/ndJsonStream.ts @@ -658,7 +658,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function hasBoundedJsonStructure(value: unknown): boolean { +export function hasBoundedJsonStructure(value: unknown): boolean { const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 1 }]; let nodes = 0; while (stack.length > 0) { diff --git a/packages/cli/src/acp-integration/managed-tool-media.test.ts b/packages/cli/src/acp-integration/managed-tool-media.test.ts new file mode 100644 index 00000000000..51d4934929b --- /dev/null +++ b/packages/cli/src/acp-integration/managed-tool-media.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + managedToolResponseMediaBytes, + MAX_MANAGED_MEDIA_RESPONSE_BYTES, +} from './managed-tool-media.js'; + +function execution(data = 'AQ==', mimeType = 'image/png') { + return { + executionStatus: 'success', + result: { + llmContent: [{ inlineData: { data, mimeType } }], + returnDisplay: 'image', + }, + }; +} + +describe('managed tool media wire validation', () => { + it.each(['execute', 'status', 'cancel'] as const)( + 'accounts only direct inline media in %s results', + (operation) => { + const result = execution(); + const wrapped = + operation === 'execute' + ? result + : { state: 'settled', result, progress: [] }; + expect(managedToolResponseMediaBytes(wrapped, operation)).toBe(4); + expect( + managedToolResponseMediaBytes( + { ...wrapped, postHook: { inlineData: { data: 'AQ==' } } }, + operation, + ), + ).toBe(4); + }, + ); + + it('does not exempt progress, display, nested contents, or unsettled results', () => { + expect( + managedToolResponseMediaBytes( + { state: 'executing', result: execution() }, + 'status', + ), + ).toBe(0); + const result = execution(); + expect( + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { + llmContent: { functionResponse: result }, + returnDisplay: result, + }, + progress: [result], + }, + 'execute', + ), + ).toBe(0); + }); + + it.each([ + 'AR==', + 'AQ=', + 'AQ===', + 'A===', + 'AQ==\n', + 'AQ==AQ==', + '-_==', + 'ABCD=', + 'A', + ])('rejects invalid or noncanonical base64 %j', (data) => { + expect(() => + managedToolResponseMediaBytes(execution(data), 'execute'), + ).toThrow('invalid inline media'); + }); + + it.each(['execute', 'status', 'cancel'] as const)( + 'preserves native empty media in %s results with zero media bytes', + (operation) => { + for (const mime of ['audio/wav', 'video/mp4', 'application/pdf']) { + const result = execution('', mime); + const wrapped = + operation === 'execute' + ? result + : { state: 'settled', result, progress: [] }; + expect(managedToolResponseMediaBytes(wrapped, operation)).toBe(0); + } + }, + ); + + it.each([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'audio/mpeg', + 'video/mp4', + 'application/pdf', + ])('permits native media MIME %s', (mime) => { + expect( + managedToolResponseMediaBytes(execution('AQID', mime), 'execute'), + ).toBe(4); + }); + + it('rejects malformed inline parts and non-media MIME', () => { + for (const inlineData of [ + null, + { data: 1, mimeType: 'image/png' }, + { data: 'AQ==' }, + { data: 'AQ==', mimeType: 'text/plain' }, + ]) { + expect(() => + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { llmContent: { inlineData } }, + }, + 'execute', + ), + ).toThrow('invalid inline media'); + } + }); + + it('preserves ordinary JSON omission and local ACP text capacity', () => { + expect( + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { llmContent: 'x'.repeat(9 * 1024 * 1024), error: undefined }, + postHook: undefined, + }, + 'execute', + ), + ).toBe(0); + }); + + it('bounds combined wire bytes including a first PDF page larger than the normal page budget', () => { + const result = execution('AAAA'.repeat(7 * 1024 * 1024)); + expect(managedToolResponseMediaBytes(result, 'execute')).toBe( + 28 * 1024 * 1024, + ); + const overhead = Buffer.byteLength( + JSON.stringify({ + ...result, + result: { ...result.result, llmContent: '' }, + }), + ); + const boundary = { + ...result, + result: { + ...result.result, + llmContent: 'x'.repeat(MAX_MANAGED_MEDIA_RESPONSE_BYTES - overhead), + }, + }; + expect(managedToolResponseMediaBytes(boundary, 'execute')).toBe(0); + boundary.result.llmContent += 'x'; + expect(() => managedToolResponseMediaBytes(boundary, 'execute')).toThrow( + 'size limit', + ); + }); + + it('refuses unsafe ACP structure before a large result can poison the pipe', () => { + const result = execution(); + expect(() => + managedToolResponseMediaBytes( + { ...result, progress: Array(4097).fill(null) }, + 'execute', + ), + ).toThrow('structure limits'); + let nested: unknown = 'leaf'; + for (let index = 0; index < 64; index++) nested = { nested }; + expect(() => + managedToolResponseMediaBytes({ ...result, nested }, 'execute'), + ).toThrow('structure limits'); + }); +}); diff --git a/packages/cli/src/acp-integration/managed-tool-media.ts b/packages/cli/src/acp-integration/managed-tool-media.ts new file mode 100644 index 00000000000..836e55cbdcd --- /dev/null +++ b/packages/cli/src/acp-integration/managed-tool-media.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + DAEMON_ACP_NDJSON_LIMITS, + hasBoundedJsonStructure, +} from '@qwen-code/acp-bridge/ndJsonStream'; + +// Leave room for the JSON-RPC/HTTP envelopes without changing ACP capacity. +export const MAX_MANAGED_MEDIA_RESPONSE_BYTES = + DAEMON_ACP_NDJSON_LIMITS.maxFrameBytes - 64 * 1024; + +export function isManagedMediaOperation( + operation: string, +): operation is 'execute' | 'status' | 'cancel' { + return ( + operation === 'execute' || operation === 'status' || operation === 'cancel' + ); +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function isBase64(data: string): boolean { + if (data.length % 4 !== 0) return false; + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0; + const body = data.slice(0, data.length - padding); + if (/[^A-Za-z0-9+/]/.test(body)) return false; + if (padding === 0) return true; + const last = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.indexOf( + body.at(-1)!, + ); + return padding === 2 ? (last & 15) === 0 : (last & 3) === 0; +} + +/** Validate the actual wire projection before sending it to the bounded ACP pipe. */ +export function managedToolResponseMediaBytes( + result: unknown, + operation: 'execute' | 'status' | 'cancel', +): number { + const json = JSON.stringify(result); + if ( + json === undefined || + Buffer.byteLength(json) > MAX_MANAGED_MEDIA_RESPONSE_BYTES + ) { + throw new Error('Managed Runtime media response exceeded its size limit.'); + } + // Native results may contain undefined optional fields; JSON omits them. + const wire: unknown = JSON.parse(json); + if (!hasBoundedJsonStructure({ jsonrpc: '2.0', id: '0', result: wire })) { + throw new Error( + 'Managed Runtime media response exceeded ACP structure limits.', + ); + } + const status = record(wire); + if (operation !== 'execute' && status?.['state'] !== 'settled') return 0; + const execution = + operation === 'execute' ? status : record(status?.['result']); + if ( + !['not_started', 'success', 'error', 'cancelled'].includes( + String(execution?.['executionStatus']), + ) + ) + return 0; + const content = record(execution?.['result'])?.['llmContent']; + const parts = Array.isArray(content) ? content : [content]; + let mediaBytes = 0; + for (const part of parts) { + const object = record(part); + if (!object || !Object.hasOwn(object, 'inlineData')) continue; + const inline = record(object['inlineData']); + if ( + !inline || + typeof inline['data'] !== 'string' || + typeof inline['mimeType'] !== 'string' || + !/^(?:(?:image|audio|video)\/[a-z0-9.+-]+|application\/pdf)$/i.test( + inline['mimeType'], + ) || + !isBase64(inline['data']) + ) { + throw new Error('Managed Runtime returned invalid inline media.'); + } + mediaBytes += inline['data'].length; + } + return mediaBytes; +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index eb1255d1356..e0ce017ab89 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -195,6 +195,7 @@ export async function maybeOpenWebShellBrowser( interface ServeArgs { port: number; hostname: string; + profile: 'default' | 'hosted-harness'; token?: string; 'max-sessions': number; 'max-total-sessions'?: number; @@ -228,6 +229,13 @@ interface ServeArgs { 'allow-origin'?: string[]; 'allow-private-auth-base-url': boolean; 'prompt-deadline-ms'?: number; + 'experimental-managed-agents': boolean; + 'experimental-managed-runtime-worker': boolean; + 'experimental-managed-runtime-auto-local': boolean; + 'experimental-managed-runtime-url'?: string; + 'experimental-managed-runtime-token'?: string; + 'managed-runtime-broker-url'?: string; + 'managed-runtime-broker-token'?: string; 'writer-idle-timeout-ms'?: number; 'channel-idle-timeout-ms'?: number; 'initialize-timeout-ms'?: number; @@ -273,6 +281,12 @@ export const serveCommand: CommandModule = { description: 'Interface to bind. Loopback (127.0.0.0/8, localhost, ::1, [::1]) is auth-free; anything else requires a token (one is generated and printed when neither --token nor QWEN_SERVER_TOKEN supplies one). A localhost bind that resolves off-loopback never generates, and still refuses when no token source resolved; an empty value is rejected as operator error.', }) + .option('profile', { + choices: ['default', 'hosted-harness'] as const, + default: 'default' as const, + description: + 'Deployment profile. hosted-harness runs the resident model loop and delegates every Managed Tool operation through a Java Runtime Broker.', + }) .option('token', { type: 'string', description: @@ -587,6 +601,48 @@ export const serveCommand: CommandModule = { 'Server-side wallclock cap on POST /session/:id/prompt (ms). ' + 'Falls back to QWEN_SERVE_PROMPT_DEADLINE_MS. Positive integer.', }) + .option('experimental-managed-agents', { + type: 'boolean', + default: false, + description: + 'Enable the experimental resident Managed Gateway with durable admission and Tool-only workspace Runtimes.', + }) + .option('experimental-managed-runtime-worker', { + type: 'boolean', + default: false, + description: + 'Expose the private authenticated Managed Runtime worker protocol. Requires a daemon bearer token.', + }) + .option('experimental-managed-runtime-auto-local', { + type: 'boolean', + default: false, + description: + 'Automatically start and manage local Tool-only Runtimes. Requires --experimental-managed-agents.', + }) + .option('experimental-managed-runtime-url', { + type: 'string', + requiresArg: true, + description: + 'Use a separate Managed Runtime worker at this HTTP(S) origin. Requires --experimental-managed-agents.', + }) + .option('experimental-managed-runtime-token', { + type: 'string', + requiresArg: true, + description: + 'Bearer token for the separate Managed Runtime worker. Falls back to QWEN_MANAGED_RUNTIME_TOKEN, then the daemon token.', + }) + .option('managed-runtime-broker-url', { + type: 'string', + requiresArg: true, + description: + 'Java Runtime Broker HTTP(S) origin for --profile hosted-harness. Falls back to QWEN_RUNTIME_BROKER_URL.', + }) + .option('managed-runtime-broker-token', { + type: 'string', + requiresArg: true, + description: + 'Harness-to-Broker bearer credential. Falls back to QWEN_RUNTIME_BROKER_TOKEN.', + }) .option('writer-idle-timeout-ms', { type: 'number', description: @@ -869,6 +925,7 @@ export const serveCommand: CommandModule = { const serveOptions = { port: argv.port, hostname: argv.hostname, + profile: argv.profile, token: argv.token, mode: 'http-bridge', maxSessions: argv['max-sessions'], @@ -913,6 +970,37 @@ export const serveCommand: CommandModule = { ...(argv['prompt-deadline-ms'] !== undefined ? { promptDeadlineMs: argv['prompt-deadline-ms'] } : {}), + ...(argv['experimental-managed-agents'] + ? { experimentalManagedAgents: true } + : {}), + ...(argv['experimental-managed-runtime-auto-local'] + ? { experimentalManagedRuntimeAutoLocal: true } + : {}), + ...(argv['experimental-managed-runtime-worker'] + ? { experimentalManagedRuntimeWorker: true } + : {}), + ...(argv['experimental-managed-runtime-url'] !== undefined + ? { + experimentalManagedRuntimeUrl: + argv['experimental-managed-runtime-url'], + } + : {}), + ...(argv['experimental-managed-runtime-token'] !== undefined + ? { + experimentalManagedRuntimeToken: + argv['experimental-managed-runtime-token'], + } + : {}), + ...(argv['managed-runtime-broker-url'] !== undefined + ? { + managedRuntimeBrokerUrl: argv['managed-runtime-broker-url'], + } + : {}), + ...(argv['managed-runtime-broker-token'] !== undefined + ? { + managedRuntimeBrokerToken: argv['managed-runtime-broker-token'], + } + : {}), ...(argv['writer-idle-timeout-ms'] !== undefined ? { writerIdleTimeoutMs: argv['writer-idle-timeout-ms'] } : {}), diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.test.ts b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts new file mode 100644 index 00000000000..23a15a9e049 --- /dev/null +++ b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts @@ -0,0 +1,531 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { ManagedToolInvocationReference } from '@qwen-code/qwen-code-core'; +import { + BrokerManagedRuntimeProvider, + MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, +} from './broker-managed-runtime-provider.js'; +import type { ManagedRuntimePrepareRequest } from './managed-runtime-protocol.js'; + +const harnessSessionId = '550e8400-e29b-41d4-a716-446655440301'; +const runtimeSessionId = '550e8400-e29b-41d4-a716-446655440302'; + +function request(): ManagedRuntimePrepareRequest { + return { + protocolVersion: 1, + tenantId: 'tenant-must-not-cross-the-broker-boundary', + workspaceId: 'workspace-must-not-cross-the-broker-boundary', + workspaceCwd: '/workspace/must-not-cross-the-broker-boundary', + sessionId: runtimeSessionId, + turnKind: 'bootstrap', + }; +} + +function reference(): ManagedToolInvocationReference { + return { + sessionId: runtimeSessionId, + promptId: 'turn-1', + callId: 'tool-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', + invocationId: 'invocation-1', + argsDigest: 'b'.repeat(64), + }; +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function envelope(fields: Record) { + return { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + harnessSessionId, + runtimeSessionId, + ...fields, + }; +} + +describe('BrokerManagedRuntimeProvider', () => { + it('requires a credential and HTTPS for a remote Broker', () => { + expect( + () => + new BrokerManagedRuntimeProvider({ + baseUrl: 'http://broker.example.com', + token: 'secret', + }), + ).toThrow('must use HTTPS'); + expect( + () => + new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: ' ', + }), + ).toThrow('token is required'); + }); + + it('acquires by Harness identity without forwarding tenant or workspace claims', async () => { + const bodies: unknown[] = []; + const fetchImpl = vi.fn(async (_input, init) => { + bodies.push(JSON.parse(String(init?.body)) as unknown); + return json(envelope({ acquired: true })); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await provider.getToolV2Client(request(), { harnessSessionId }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(String(fetchImpl.mock.calls[0][0])).toBe( + 'http://127.0.0.1:8080/internal/runtime-broker/v1/tool-sessions:acquire', + ); + expect(bodies[0]).toMatchObject({ + protocolVersion: 1, + harnessSessionId, + runtimeSessionId, + turnKind: 'bootstrap', + requestId: expect.any(String), + }); + expect(bodies[0]).not.toHaveProperty('tenantId'); + expect(bodies[0]).not.toHaveProperty('workspaceId'); + expect(bodies[0]).not.toHaveProperty('workspaceCwd'); + }); + + it('reserves a durable execution identity before starting it', async () => { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + let droppedExecutionResponse = false; + const settled = { + state: 'settled', + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + result: { + executionStatus: 'success', + result: { llmContent: 'ok', returnDisplay: 'ok' }, + }, + }; + const executing = { + ...settled, + state: 'executing', + result: undefined, + }; + const preparedStatus = { + ...executing, + state: 'prepared', + }; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body + ? (JSON.parse(String(init.body)) as unknown) + : undefined; + calls.push({ url, method, ...(body === undefined ? {} : { body }) }); + if (url.endsWith('tool-sessions:acquire')) { + return json(envelope({ acquired: true })); + } + if (url.endsWith(`/tool-sessions/${runtimeSessionId}/control`)) { + return json( + envelope({ + result: { + tools: [], + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', + }, + }), + ); + } + if (url.endsWith('/executions:prepare')) { + if (!droppedExecutionResponse) { + droppedExecutionResponse = true; + throw new TypeError('response connection closed'); + } + return json( + envelope({ executionCallId: 'execution-1', status: preparedStatus }), + ); + } + if (url.endsWith('/executions/execution-1:start')) { + return json( + envelope({ executionCallId: 'execution-1', status: executing }), + ); + } + if (url.includes('/executions/execution-1?')) { + return json( + envelope({ executionCallId: 'execution-1', status: settled }), + ); + } + if (url.endsWith(`/tool-sessions/${runtimeSessionId}:release`)) { + return json(envelope({ released: true })); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + const prepared = request(); + const client = await provider.getToolV2Client(prepared, { + harnessSessionId, + }); + + await expect(client.manifest()).resolves.toMatchObject({ + policyRevision: 'policy-1', + }); + const reservation = await client.prepareExecution!(reference()); + expect(reservation).toEqual({ + executionCallId: 'execution-1', + invocationBindingId: runtimeSessionId, + }); + expect( + calls.some((call) => call.url.endsWith('/executions/execution-1:start')), + ).toBe(false); + await expect( + client.startExecution!(reference(), reservation.executionCallId), + ).resolves.toMatchObject({ + executionStatus: 'success', + }); + await expect(client.execute(reference())).resolves.toMatchObject({ + executionStatus: 'success', + }); + await expect( + provider.release(runtimeSessionId, prepared, { terminal: true }), + ).resolves.toBe(true); + + const control = calls.find((call) => call.url.endsWith('/control')); + expect(control?.body).toMatchObject({ + harnessSessionId, + operation: { kind: 'manifest' }, + }); + const executions = calls.filter((call) => + call.url.endsWith('/executions:prepare'), + ); + expect(executions).toHaveLength(2); + expect(executions[0].body).toMatchObject({ + harnessSessionId, + runtimeSessionId, + turnId: 'turn-1', + toolCallId: 'tool-1', + requestDigest: 'b'.repeat(64), + idempotencyKey: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(executions[1].body).toEqual(executions[0].body); + expect( + calls.filter((call) => + call.url.endsWith('/executions/execution-1:start'), + ), + ).toHaveLength(1); + const status = calls.find((call) => + call.url.includes('/executions/execution-1?'), + ); + expect(status?.url).toContain( + `harnessSessionId=${encodeURIComponent(harnessSessionId)}`, + ); + expect(status?.url).toContain( + `runtimeSessionId=${encodeURIComponent(runtimeSessionId)}`, + ); + }); + + it('fails closed when Broker response identity changes', async () => { + const fetchImpl = vi.fn(async () => + json({ + protocolVersion: 1, + harnessSessionId: 'another-session', + runtimeSessionId, + acquired: true, + }), + ); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('response identity changed'); + }); + + it('inspects a durable execution without recreating a process-local Runtime entry', async () => { + let unknown = false; + const status = { + state: 'executing', + cancelRequested: false, + lastSeq: 4, + firstAvailableSeq: 5, + progressGap: false, + progress: [], + }; + const fetchImpl = vi.fn(async (input, init) => { + expect(init?.method).toBe('GET'); + const url = String(input); + expect(url).toContain('/executions/execution-recovery?'); + expect(url).toContain(`harnessSessionId=${harnessSessionId}`); + expect(url).toContain(`runtimeSessionId=${runtimeSessionId}`); + expect(url).toContain('afterSeq=3'); + if (unknown) { + return new Response( + JSON.stringify({ + error: 'Tool execution outcome is unknown.', + code: 'runtime_broker_execution_unknown', + retryable: true, + }), + { + status: 503, + headers: { 'content-type': 'application/json' }, + }, + ); + } + return json(envelope({ executionCallId: 'execution-recovery', status })); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + const identity = { + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 3, + }; + + await expect(provider.inspectExecution(identity)).resolves.toEqual({ + outcome: 'known', + status, + }); + unknown = true; + await expect(provider.inspectExecution(identity)).resolves.toEqual({ + outcome: 'unknown', + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('cancels and waits for the original execution without starting or acquiring', async () => { + const pending = { + state: 'prepared' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + }; + const cancelling = { + ...pending, + state: 'cancel_requested' as const, + cancelRequested: true, + }; + const settled = { + ...cancelling, + state: 'settled' as const, + result: { executionStatus: 'cancelled' as const }, + }; + let reads = 0; + const calls: Array<{ method: string; url: string }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + calls.push({ method, url }); + if (url.includes('/executions/execution-recovery?')) { + reads++; + return json( + envelope({ + executionCallId: 'execution-recovery', + status: reads === 1 ? pending : settled, + }), + ); + } + if (url.endsWith('/executions/execution-recovery:cancel')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: cancelling, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.cancelExecution({ + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 0, + }), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([ + expect.objectContaining({ method: 'GET' }), + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/executions/execution-recovery:cancel'), + }), + expect.objectContaining({ method: 'GET' }), + ]); + expect(calls.some((call) => call.url.includes(':start'))).toBe(false); + expect( + calls.some((call) => call.url.includes('tool-sessions:acquire')), + ).toBe(false); + }); + + it('reconciles the original execution to settlement without acquiring a new Runtime', async () => { + const result = { + executionStatus: 'success' as const, + result: { + llmContent: 'recovered output', + returnDisplay: 'recovered output', + }, + }; + const prepared = { + state: 'prepared' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + }; + const executing = { ...prepared, state: 'executing' as const }; + const settled = { + ...executing, + state: 'settled' as const, + result, + }; + let reads = 0; + const calls: Array<{ method: string; url: string }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + calls.push({ method, url }); + if (url.includes('/executions/execution-recovery?')) { + reads++; + return json( + envelope({ + executionCallId: 'execution-recovery', + status: reads === 1 ? prepared : settled, + }), + ); + } + if (url.endsWith('/executions/execution-recovery:start')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: executing, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.reconcileExecution({ + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 0, + }), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls).toEqual([ + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/executions/execution-recovery?'), + }), + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/executions/execution-recovery:start'), + }), + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/executions/execution-recovery?'), + }), + ]); + expect( + calls.some((call) => call.url.includes('tool-sessions:acquire')), + ).toBe(false); + }); + + it('resolves an unknown execution through the broker without re-executing it', async () => { + const settled = { + state: 'settled' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + result: { + executionStatus: 'not_started', + resolution: 'confirmed_not_executed', + }, + }; + const calls: Array<{ method: string; url: string; body?: unknown }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body + ? (JSON.parse(String(init.body)) as unknown) + : undefined; + calls.push({ method, url, ...(body === undefined ? {} : { body }) }); + if (url.endsWith('/executions/execution-recovery:resolve')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: settled, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.resolveExecution( + { + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + }, + 'confirmed_not_executed', + ), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe( + 'http://127.0.0.1:8080/internal/runtime-broker/v1/executions/execution-recovery:resolve', + ); + expect(calls[0].body).toMatchObject({ + protocolVersion: 1, + harnessSessionId, + runtimeSessionId, + resolution: 'confirmed_not_executed', + requestId: expect.any(String), + }); + }); +}); diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.ts b/packages/cli/src/serve/broker-managed-runtime-provider.ts new file mode 100644 index 00000000000..1673d1e26f2 --- /dev/null +++ b/packages/cli/src/serve/broker-managed-runtime-provider.ts @@ -0,0 +1,1060 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +// Type-only: under verbatimModuleSyntax an `import { type X }` still loads the +// module, and managed-tool-runtime pulls the whole Config graph into the +// serve fast path (scripts/check-serve-fast-path-bundle.js). +import type { + ManagedToolExecutionResult, + ManagedToolInvocationStatus, + ManagedToolV2Client, +} from '@qwen-code/qwen-code-core/tools/managed-tool-runtime.js'; +import { + managedToolDigest, + type ManagedToolInvocationReference, +} from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; +import { isLoopbackBind } from './loopback-binds.js'; +import { + ManagedRuntimeProviderError, + type ManagedRuntimeExecutionIdentity, + type ManagedRuntimeExecutionInspection, + type ManagedRuntimeHandle, + type ManagedRuntimeProvider, + type ManagedRuntimeReleaseOptions, + type ManagedRuntimeToolClientContext, + type ManagedRuntimeUnknownResolution, +} from './managed-runtime-provider.js'; +import { + sameManagedRuntimeIdentity, + type ManagedRuntimePrepareRequest, +} from './managed-runtime-protocol.js'; + +export const MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION = 1 as const; +export const MANAGED_RUNTIME_BROKER_ROUTE_PREFIX = + '/internal/runtime-broker/v1' as const; + +const MAX_BROKER_RESPONSE_BYTES = 8 * 1024 * 1024; +const BROKER_REQUEST_TIMEOUT_MS = 10 * 60_000; +const EXECUTION_POLL_DELAY_MS = 50; + +interface BrokerEnvelope { + readonly protocolVersion: typeof MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION; + readonly harnessSessionId: string; + readonly runtimeSessionId: string; +} + +interface BrokerAcquireResponse extends BrokerEnvelope { + readonly acquired: true; +} + +interface BrokerControlResponse extends BrokerEnvelope { + readonly result: unknown; +} + +interface BrokerExecutionResponse extends BrokerEnvelope { + readonly executionCallId: string; + readonly status: ManagedToolInvocationStatus; +} + +interface BrokerReleaseResponse extends BrokerEnvelope { + readonly released: true; +} + +interface BrokerEntry { + readonly request: ManagedRuntimePrepareRequest; + readonly harnessSessionId: string; + readonly acquisition: Promise; + readonly executions: Map; + client?: ManagedToolV2Client; + release?: Promise; + releasing?: boolean; +} + +interface BrokerExecution { + readonly referenceDigest: string; + readonly reserved: Promise<{ + executionCallId: string; + status: ManagedToolInvocationStatus; + }>; + started?: Promise; +} + +class BrokerResponseError extends Error { + constructor( + readonly status: number, + readonly code?: string, + readonly retryable?: boolean, + ) { + super(`Managed Runtime Broker returned HTTP ${status}.`); + this.name = 'BrokerResponseError'; + } +} + +export interface ManagedRuntimeBrokerClientOptions { + readonly baseUrl: string; + readonly token: string; + readonly fetch?: typeof fetch; +} + +export function resolveManagedRuntimeBrokerBaseUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Managed Runtime Broker URL is invalid.'); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + throw new Error('Managed Runtime Broker URL must be an HTTP(S) origin.'); + } + if (url.protocol === 'http:' && !isLoopbackBind(url.hostname)) { + throw new Error( + 'Managed Runtime Broker URL must use HTTPS outside the loopback interface.', + ); + } + url.pathname = '/'; + return url; +} + +async function readBoundedResponseText( + response: Response, + maxResponseBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + totalBytes += chunk.value.byteLength; + if (totalBytes > maxResponseBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('Managed Runtime Broker response exceeded its limit.'); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed Runtime Broker returned an invalid response.'); + } + return value as Record; +} + +function boundedId(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.includes('\0') + ) { + throw new Error(`Managed Runtime Broker returned an invalid ${label}.`); + } + return value; +} + +function parseExecutionResult(value: unknown): ManagedToolExecutionResult { + const input = record(value); + if ( + !['not_started', 'success', 'error', 'cancelled'].includes( + String(input['executionStatus']), + ) + ) { + throw new Error( + 'Managed Runtime Broker returned an invalid execution result.', + ); + } + return structuredClone(value) as ManagedToolExecutionResult; +} + +function parseInvocationStatus(value: unknown): ManagedToolInvocationStatus { + const input = record(value); + if ( + !['prepared', 'executing', 'cancel_requested', 'settled'].includes( + String(input['state']), + ) || + typeof input['cancelRequested'] !== 'boolean' || + !Number.isSafeInteger(input['lastSeq']) || + Number(input['lastSeq']) < 0 || + !Number.isSafeInteger(input['firstAvailableSeq']) || + Number(input['firstAvailableSeq']) < 0 || + typeof input['progressGap'] !== 'boolean' || + !Array.isArray(input['progress']) + ) { + throw new Error( + 'Managed Runtime Broker returned an invalid execution status.', + ); + } + for (const event of input['progress']) { + const progress = record(event); + if ( + !Number.isSafeInteger(progress['seq']) || + Number(progress['seq']) < 0 || + !('output' in progress) + ) { + throw new Error( + 'Managed Runtime Broker returned invalid execution progress.', + ); + } + } + if (input['state'] === 'settled') { + parseExecutionResult(input['result']); + } else if (input['result'] !== undefined) { + throw new Error( + 'Managed Runtime Broker returned a premature execution result.', + ); + } + return structuredClone(value) as ManagedToolInvocationStatus; +} + +function executionIdempotencyKey( + harnessSessionId: string, + reference: ManagedToolInvocationReference, +): string { + return createHash('sha256') + .update(harnessSessionId) + .update('\0') + .update(reference.promptId) + .update('\0') + .update(reference.callId) + .update('\0') + .update(reference.argsDigest) + .digest('hex'); +} + +export class ManagedRuntimeBrokerClient { + private readonly baseUrl: URL; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: ManagedRuntimeBrokerClientOptions) { + this.baseUrl = resolveManagedRuntimeBrokerBaseUrl(options.baseUrl); + this.token = options.token.trim(); + if (!this.token) + throw new Error('Managed Runtime Broker token is required.'); + this.fetchImpl = options.fetch ?? fetch; + } + + async acquire( + request: ManagedRuntimePrepareRequest, + harnessSessionId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + 'tool-sessions:acquire', + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId: request.sessionId, + turnKind: request.turnKind, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + request.sessionId, + ) as BrokerAcquireResponse; + if (envelope.acquired !== true) { + throw new Error('Managed Runtime Broker did not acquire the Session.'); + } + } + + async control( + runtimeSessionId: string, + harnessSessionId: string, + operation: Record, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `tool-sessions/${encodeURIComponent(runtimeSessionId)}/control`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + operation, + }, + signal, + ); + return ( + this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerControlResponse + ).result; + } + + async createExecution( + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + return this.submitExecution( + 'executions', + runtimeSessionId, + harnessSessionId, + reference, + signal, + ); + } + + async prepareExecution( + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + return this.submitExecution( + 'executions:prepare', + runtimeSessionId, + harnessSessionId, + reference, + signal, + ); + } + + async startExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:start`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async getExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + afterSeq: number | undefined, + signal: AbortSignal, + ): Promise { + const query = new URLSearchParams({ + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }); + if (afterSeq !== undefined) query.set('afterSeq', String(afterSeq)); + const response = await this.requestJson( + 'GET', + `executions/${encodeURIComponent(executionCallId)}?${query}`, + undefined, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async cancelExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:cancel`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async resolveUnknownExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + resolution: 'confirmed_not_executed' | 'accepted_unknown', + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:resolve`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + resolution, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async release( + runtimeSessionId: string, + harnessSessionId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `tool-sessions/${encodeURIComponent(runtimeSessionId)}:release`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerReleaseResponse; + return envelope.released === true; + } + + private async submitExecution( + path: 'executions' | 'executions:prepare', + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + const body = { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + idempotencyKey: executionIdempotencyKey(harnessSessionId, reference), + harnessSessionId, + runtimeSessionId, + turnId: reference.promptId, + toolCallId: reference.callId, + requestDigest: reference.argsDigest, + reference, + }; + let response: unknown; + try { + response = await this.requestJson('POST', path, body, signal); + } catch (error) { + if ( + signal.aborted || + (error instanceof BrokerResponseError && error.status < 500) + ) { + throw error; + } + response = await this.requestJson('POST', path, body, signal); + } + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + return { + executionCallId: boundedId(envelope.executionCallId, 'executionCallId'), + status: parseInvocationStatus(envelope.status), + }; + } + + private parseEnvelope( + value: unknown, + harnessSessionId: string, + runtimeSessionId: string, + ): BrokerEnvelope { + const input = record(value); + if ( + input['protocolVersion'] !== MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION || + input['harnessSessionId'] !== harnessSessionId || + input['runtimeSessionId'] !== runtimeSessionId + ) { + throw new Error('Managed Runtime Broker response identity changed.'); + } + return input as unknown as BrokerEnvelope; + } + + private async requestJson( + method: 'GET' | 'POST', + path: string, + body: unknown, + signal: AbortSignal, + ): Promise { + const response = await this.fetchImpl( + new URL( + `${MANAGED_RUNTIME_BROKER_ROUTE_PREFIX.slice(1)}/${path}`, + this.baseUrl, + ), + { + method, + headers: { + authorization: `Bearer ${this.token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal, + }, + ); + if (!response.ok) { + let code: string | undefined; + let retryable: boolean | undefined; + try { + const text = await readBoundedResponseText( + response, + MAX_BROKER_RESPONSE_BYTES, + ); + const body = record(JSON.parse(text) as unknown); + if ( + typeof body['code'] === 'string' && + body['code'].length > 0 && + body['code'].length <= 512 && + !body['code'].includes('\0') + ) { + code = body['code']; + } + if (typeof body['retryable'] === 'boolean') { + retryable = body['retryable']; + } + } catch { + await response.body?.cancel().catch(() => undefined); + } + throw new BrokerResponseError(response.status, code, retryable); + } + const contentLength = Number(response.headers.get('content-length')); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_BROKER_RESPONSE_BYTES + ) { + await response.body?.cancel().catch(() => undefined); + throw new Error('Managed Runtime Broker response exceeded its limit.'); + } + const text = await readBoundedResponseText( + response, + MAX_BROKER_RESPONSE_BYTES, + ); + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error('Managed Runtime Broker returned invalid JSON.'); + } + } +} + +export type BrokerManagedRuntimeProviderOptions = + ManagedRuntimeBrokerClientOptions; + +export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly client: ManagedRuntimeBrokerClient; + private readonly entries = new Map(); + private readonly closedSessions = new Map< + string, + ManagedRuntimePrepareRequest + >(); + private readonly lifetime = new AbortController(); + + constructor(options: BrokerManagedRuntimeProviderOptions) { + this.client = new ManagedRuntimeBrokerClient(options); + } + + prepare(_request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Hosted Harness supports only the Managed Tool v2 Broker path.', + false, + ); + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + context?: ManagedRuntimeToolClientContext, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const harnessSessionId = context?.harnessSessionId.trim(); + if (!harnessSessionId) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Hosted Harness Session identity is required.', + false, + ); + } + const closed = this.closedSessions.get(request.sessionId); + if (closed) { + throw new ManagedRuntimeProviderError( + sameManagedRuntimeIdentity(closed, request) + ? 'managed_runtime_unavailable' + : 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session is permanently closed.', + false, + ); + } + let entry = this.entries.get(request.sessionId); + if ( + entry && + (!sameManagedRuntimeIdentity(entry.request, request) || + entry.harnessSessionId !== harnessSessionId) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + if (entry?.releasing) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Broker Session is being released.', + false, + ); + } + if (!entry) { + const immutableRequest = structuredClone(request); + const acquisition = this.client.acquire( + immutableRequest, + harnessSessionId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + entry = { + request: immutableRequest, + harnessSessionId, + acquisition, + executions: new Map(), + }; + this.entries.set(request.sessionId, entry); + } + await entry.acquisition; + this.lifetime.signal.throwIfAborted(); + entry.client ??= this.createToolClient(entry); + return entry.client; + } + + async inspectExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + try { + const status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async reconcileExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const signal = AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]); + try { + let status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + signal, + ); + if (status.state === 'prepared') { + status = await this.client.startExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + signal, + ); + } + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { signal }); + status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + undefined, + signal, + ); + } + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async cancelExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const signal = AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]); + try { + let status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + signal, + ); + if (status.state !== 'settled') { + status = await this.client.cancelExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + signal, + ); + } + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { signal }); + status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + undefined, + signal, + ); + } + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async resolveExecution( + identity: ManagedRuntimeExecutionIdentity, + resolution: ManagedRuntimeUnknownResolution, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const status = await this.client.resolveUnknownExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + resolution, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + return { outcome: 'known', status }; + } + + async cancel( + _sessionId: string, + _executionId: string, + _expected?: ManagedRuntimePrepareRequest, + ): Promise { + return false; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + const entry = this.entries.get(sessionId); + const closed = this.closedSessions.get(sessionId); + if (closed) { + if (expected && !sameManagedRuntimeIdentity(closed, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + return true; + } + if (!entry) return false; + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + entry.releasing = true; + if (entry.release) return entry.release; + const release = (async () => { + await entry.acquisition.catch(() => {}); + this.lifetime.signal.throwIfAborted(); + const released = await this.client.release( + sessionId, + entry.harnessSessionId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + if (released) { + this.entries.delete(sessionId); + if (options?.terminal === true) { + this.closedSessions.set(sessionId, entry.request); + } + } + return released; + })().finally(() => { + if (entry.release === release) entry.release = undefined; + }); + entry.release = release; + return release; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime Broker provider disposed.')); + this.entries.clear(); + this.closedSessions.clear(); + } + + private createToolClient(entry: BrokerEntry): ManagedToolV2Client { + const control = (operation: Record) => + this.client.control( + entry.request.sessionId, + entry.harnessSessionId, + operation, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + const ensureExecution = ( + reference: ManagedToolInvocationReference, + ): BrokerExecution => { + this.assertReference(entry, reference); + const referenceDigest = managedToolDigest(reference); + let execution = entry.executions.get(reference.invocationId); + if (execution && execution.referenceDigest !== referenceDigest) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker invocation identity changed.', + false, + ); + } + if (!execution) { + execution = { + referenceDigest, + reserved: this.client.prepareExecution( + entry.request.sessionId, + entry.harnessSessionId, + reference, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ), + }; + entry.executions.set(reference.invocationId, execution); + } + return execution; + }; + const readExecution = async ( + reference: ManagedToolInvocationReference, + afterSeq?: number, + ) => { + const execution = ensureExecution(reference); + const reserved = await execution.reserved; + if (reserved.status.state === 'settled' && afterSeq === undefined) { + return reserved.status; + } + return this.client.getExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + afterSeq, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + }; + const startExecution = async ( + reference: ManagedToolInvocationReference, + executionCallId?: string, + ): Promise => { + const execution = ensureExecution(reference); + const reserved = await execution.reserved; + if ( + executionCallId !== undefined && + reserved.executionCallId !== executionCallId + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker execution identity changed.', + false, + ); + } + execution.started ??= (async () => { + let status = await this.client.startExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { + signal: this.lifetime.signal, + }); + status = await readExecution(reference); + } + return parseExecutionResult(status.result); + })(); + return execution.started; + }; + return { + fileHistory: { + bind: (binding) => control({ kind: 'bind-history', binding }), + checkpoint: (promptId) => control({ kind: 'checkpoint', promptId }), + snapshot: () => control({ kind: 'history' }), + }, + manifest: () => control({ kind: 'manifest' }), + beginTurn: async (identity) => { + await control({ kind: 'begin-turn', identity }); + }, + prepare: (identity, toolName, input, modification, mediaContext) => + control({ + kind: 'prepare', + identity, + toolName, + input, + ...(modification === undefined ? {} : { modification }), + ...(mediaContext === undefined ? {} : { mediaContext }), + }), + confirmation: (reference) => control({ kind: 'confirmation', reference }), + confirm: async (reference, outcome, payload, phase) => { + await control({ + kind: 'confirm', + reference, + outcome, + ...(payload === undefined ? {} : { payload }), + ...(phase === undefined ? {} : { phase }), + }); + }, + preflight: (reference) => control({ kind: 'preflight', reference }), + prepareExecution: async (reference) => { + const reserved = await ensureExecution(reference).reserved; + return { + executionCallId: reserved.executionCallId, + invocationBindingId: entry.request.sessionId, + }; + }, + startExecution: (reference, executionCallId) => + startExecution(reference, executionCallId), + execute: (reference) => startExecution(reference), + status: (reference, afterSeq) => readExecution(reference, afterSeq), + cancel: async (reference) => { + const reserved = await ensureExecution(reference).reserved; + return this.client.cancelExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + }, + } as ManagedToolV2Client; + } + + private assertReference( + entry: BrokerEntry, + reference: ManagedToolInvocationReference, + ): void { + if (reference.sessionId !== entry.request.sessionId) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker invocation belongs to another Session.', + false, + ); + } + } +} diff --git a/packages/cli/src/serve/hosted-harness-contract.test.ts b/packages/cli/src/serve/hosted-harness-contract.test.ts index c767ae68a10..8c334dc6bb5 100644 --- a/packages/cli/src/serve/hosted-harness-contract.test.ts +++ b/packages/cli/src/serve/hosted-harness-contract.test.ts @@ -10,9 +10,10 @@ import { describe, expect, it } from 'vitest'; import { createHostedHarnessContract, HOSTED_HARNESS_BOOT_ID_HEADER, + HOSTED_HARNESS_CAPABILITY_DIGEST_ENV, HOSTED_HARNESS_PROTOCOL_HEADER, HOSTED_HARNESS_UPGRADE, - hostedHarnessContractMiddleware, + installHostedHarnessContractMiddleware, isHostedHarnessCapabilityDigest, } from './hosted-harness-contract.js'; @@ -21,11 +22,9 @@ const BOOT_ID = '11111111-1111-4111-8111-111111111111'; function createApp() { const app = express(); - app.use( - '/session', - hostedHarnessContractMiddleware( - createHostedHarnessContract(DIGEST, BOOT_ID), - ), + installHostedHarnessContractMiddleware( + app, + createHostedHarnessContract(DIGEST, BOOT_ID), ); app.get('/session/:id', (req, res) => { res.status(200).json({ sessionId: req.params['id'] }); @@ -69,7 +68,7 @@ describe('Hosted Harness private contract', () => { false, ); expect(() => createHostedHarnessContract('sha256:not-a-digest')).toThrow( - 'Hosted Harness capability digest must be sha256:<64 lowercase hex characters>.', + `${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV} must be sha256:<64 lowercase hex characters>.`, ); }); @@ -102,7 +101,7 @@ describe('Hosted Harness private contract', () => { expect(response.body.code).toBe('hosted_harness_protocol_required'); }); - it('rejects missing and malformed boot ids', async () => { + it('rejects malformed and stale boot ids', async () => { const missing = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1'); @@ -115,23 +114,20 @@ describe('Hosted Harness private contract', () => { .set(HOSTED_HARNESS_BOOT_ID_HEADER, 'not-a-uuid'); expect(malformed.status).toBe(400); expect(malformed.body.code).toBe('invalid_hosted_harness_boot_id'); - }); - it('rejects a stale process generation', async () => { - const response = await supertest(createApp()) + const stale = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1') .set( HOSTED_HARNESS_BOOT_ID_HEADER, '22222222-2222-4222-8222-222222222222', ); - - expect(response.status).toBe(409); - expect(response.headers['x-qwen-harness-boot-id']).toBe(BOOT_ID); - expect(response.body.code).toBe('hosted_harness_generation_mismatch'); + expect(stale.status).toBe(409); + expect(stale.headers['x-qwen-harness-boot-id']).toBe(BOOT_ID); + expect(stale.body.code).toBe('hosted_harness_generation_mismatch'); }); - it('passes a matching process generation', async () => { + it('passes a matching generation to the session route', async () => { const response = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1') @@ -148,4 +144,17 @@ describe('Hosted Harness private contract', () => { expect(response.status).toBe(200); expect(response.headers['x-qwen-harness-boot-id']).toBeUndefined(); }); + + it('leaves ordinary session routes unchanged when no contract is installed', async () => { + const app = express(); + installHostedHarnessContractMiddleware(app, undefined); + app.get('/session/:id', (req, res) => { + res.status(200).json({ sessionId: req.params['id'] }); + }); + + const response = await supertest(app).get('/session/example'); + + expect(response.status).toBe(200); + expect(response.headers['x-qwen-harness-boot-id']).toBeUndefined(); + }); }); diff --git a/packages/cli/src/serve/hosted-harness-contract.ts b/packages/cli/src/serve/hosted-harness-contract.ts index 8a04b832633..e7ccbf3c5f2 100644 --- a/packages/cli/src/serve/hosted-harness-contract.ts +++ b/packages/cli/src/serve/hosted-harness-contract.ts @@ -5,8 +5,11 @@ */ import { randomUUID } from 'node:crypto'; -import type { RequestHandler } from 'express'; +import type { Application, RequestHandler } from 'express'; +import type { HostedHarnessCapabilities } from './types.js'; +export const HOSTED_HARNESS_CAPABILITY_DIGEST_ENV = + 'QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST'; export const HOSTED_HARNESS_PROTOCOL_VERSION = 1 as const; export const HOSTED_HARNESS_PROTOCOL_HEADER = 'X-Qwen-Harness-Protocol-Version'; export const HOSTED_HARNESS_BOOT_ID_HEADER = 'X-Qwen-Harness-Boot-Id'; @@ -16,14 +19,7 @@ const CAPABILITY_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; -export interface HostedHarnessContract { - readonly protocolVersions: { - readonly current: typeof HOSTED_HARNESS_PROTOCOL_VERSION; - readonly supported: readonly [typeof HOSTED_HARNESS_PROTOCOL_VERSION]; - }; - readonly bootId: string; - readonly capabilityDigest: string; -} +export type HostedHarnessContract = HostedHarnessCapabilities; export function isHostedHarnessCapabilityDigest(value: string): boolean { return CAPABILITY_DIGEST_PATTERN.test(value); @@ -35,13 +31,12 @@ export function createHostedHarnessContract( ): HostedHarnessContract { if (!isHostedHarnessCapabilityDigest(capabilityDigest)) { throw new Error( - 'Hosted Harness capability digest must be sha256:<64 lowercase hex characters>.', + `${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV} must be sha256:<64 lowercase hex characters>.`, ); } if (!UUID_PATTERN.test(bootId)) { throw new Error('Hosted Harness bootId must be an RFC UUID v1-v5.'); } - return Object.freeze({ protocolVersions: Object.freeze({ current: HOSTED_HARNESS_PROTOCOL_VERSION, @@ -85,7 +80,14 @@ export function hostedHarnessContractMiddleware( }); return; } - next(); }; } + +export function installHostedHarnessContractMiddleware( + app: Application, + contract: HostedHarnessContract | undefined, +): void { + if (!contract) return; + app.use('/session', hostedHarnessContractMiddleware(contract)); +} diff --git a/packages/cli/src/serve/hosted-harness-profile.ts b/packages/cli/src/serve/hosted-harness-profile.ts new file mode 100644 index 00000000000..b39510042cf --- /dev/null +++ b/packages/cli/src/serve/hosted-harness-profile.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ServeOptions } from './types.js'; +import { resolveManagedRuntimeBrokerBaseUrl } from './broker-managed-runtime-provider.js'; +import { isHostedHarnessCapabilityDigest } from './hosted-harness-contract.js'; +import { isLoopbackBind } from './loopback-binds.js'; + +export function validateHostedHarnessProfile( + opts: ServeOptions, + environment: { + readonly serverToken: string; + readonly brokerUrl: string; + readonly brokerToken: string; + readonly capabilityDigest: string; + }, +): void { + if (opts.profile !== 'hosted-harness') return; + if (!isLoopbackBind(opts.hostname)) { + throw new Error('--profile hosted-harness requires a loopback --hostname.'); + } + if (!opts.token?.trim()) { + throw new Error( + `--profile hosted-harness requires a Harness bearer token. Set ` + + `${environment.serverToken} or pass --token.`, + ); + } + if (opts.serveWebShell !== false) { + throw new Error('--profile hosted-harness requires --no-web.'); + } + if (opts.enableSessionShell === true) { + throw new Error( + '--profile hosted-harness conflicts with --enable-session-shell.', + ); + } + if (opts.allowOrigins && opts.allowOrigins.length > 0) { + throw new Error( + '--profile hosted-harness does not accept browser origins.', + ); + } + if ( + opts.clientMcpOverWs === true || + opts.cdpTunnelOverWs === true || + opts.channelSelection !== undefined + ) { + throw new Error( + '--profile hosted-harness conflicts with client MCP, CDP tunnel, and channel hosting.', + ); + } + if ( + opts.experimentalManagedAgents || + opts.experimentalManagedRuntimeWorker || + opts.experimentalManagedRuntimeAutoLocal || + opts.experimentalManagedRuntimeUrl !== undefined || + opts.experimentalManagedRuntimeToken !== undefined + ) { + throw new Error( + '--profile hosted-harness conflicts with the experimental Managed Gateway and Runtime worker options.', + ); + } + if (!opts.managedRuntimeBrokerUrl?.trim()) { + throw new Error( + `--profile hosted-harness requires --managed-runtime-broker-url or ${environment.brokerUrl}.`, + ); + } + if (!opts.managedRuntimeBrokerToken?.trim()) { + throw new Error( + `--profile hosted-harness requires --managed-runtime-broker-token or ${environment.brokerToken}.`, + ); + } + resolveManagedRuntimeBrokerBaseUrl(opts.managedRuntimeBrokerUrl); + if ( + !opts.hostedHarnessCapabilityDigest || + !isHostedHarnessCapabilityDigest(opts.hostedHarnessCapabilityDigest) + ) { + throw new Error( + `--profile hosted-harness requires ${environment.capabilityDigest}=sha256:<64 lowercase hex characters>.`, + ); + } +} diff --git a/packages/cli/src/serve/local-process-runtime-activator.test.ts b/packages/cli/src/serve/local-process-runtime-activator.test.ts new file mode 100644 index 00000000000..c04f485aeaa --- /dev/null +++ b/packages/cli/src/serve/local-process-runtime-activator.test.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment node +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + LocalProcessRuntimeActivator, + managedWorkerEnvironment, +} from './local-process-runtime-activator.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +const fixture = ` +process.on('message', b => { + if (b.type === 'shutdown') process.exit(0); + if (b.type === 'boot') setTimeout(() => process.send({ ...b, token: undefined, type: 'ready', url: 'http://127.0.0.1:12345' }), 50); +}); +process.on('disconnect', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); +`; +const active: LocalProcessRuntimeActivator[] = []; +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(active.splice(0).map((a) => a.close())); + await Promise.all( + dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function setup(maxWorkers = 4, code = fixture, startupMs = 3000) { + const stateDir = await mkdtemp( + path.join(os.tmpdir(), 'qwen-activator-test-'), + ); + dirs.push(stateDir); + const log = vi.fn(); + const activator = new LocalProcessRuntimeActivator({ + stateDir, + cliEntry: process.execPath, + launcher: ['-e', code], + env: process.env, + maxWorkers, + startupMs, + log, + }); + active.push(activator); + return { activator, log }; +} +function scope(id = 'a') { + return { + tenantId: 'tenant', + runtime: { + workspaceId: id, + workspaceCwd: os.tmpdir(), + trusted: true, + } as WorkspaceRuntime, + }; +} + +describe('owned Runtime activation', () => { + it('separates admission closure from verified worker exit', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 250));", + ), + ); + const workspace = scope(); + const use = activator.activate(workspace); + await use.endpoint; + let exited = false; + const exit = use.exited.then(() => { + exited = true; + }); + const shutdown = activator.revokeWorkspace(workspace.runtime); + expect(use.signal.aborted).toBe(true); + await Promise.resolve(); + expect(exited).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(1); + await shutdown; + await exit; + expect(exited).toBe(true); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + }); + + it('starts once for concurrent uses, fences each lease and ignores duplicate release', async () => { + const { activator, log } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + const second = activator.activate(workspace); + expect(activator.workspaceActivity(workspace.runtime)).toBe(2); + const [one, two] = await Promise.all([first.endpoint, second.endpoint]); + expect(one.boot.leaseId).toBe(two.boot.leaseId); + expect( + log.mock.calls.filter(([event]) => event === 'started'), + ).toHaveLength(1); + first.release('cancelled'); + first.release('cancelled'); + expect(second.signal.aborted).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(1); + second.release('completed'); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + await activator.revokeWorkspace(workspace.runtime); + expect(second.signal.aborted).toBe(true); + }); + it('keeps uncertain execution reserved after cancellation ACK until other uses finish', async () => { + const { activator } = await setup(1); + const workspace = scope(); + const first = activator.activate(workspace); + const other = activator.activate(workspace); + await first.endpoint; + const executed = first.beginOperation(); + executed(false); + first.release('cancelled'); + expect(other.signal.aborted).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(2); + await expect(activator.activate(workspace).endpoint).rejects.toMatchObject({ + code: 'managed_runtime_unavailable', + }); + await expect(activator.activate(scope('b')).endpoint).rejects.toMatchObject( + { code: 'managed_runtime_capacity_exhausted' }, + ); + other.release('completed'); + await vi.waitFor(() => + expect(activator.workspaceActivity(workspace.runtime)).toBe(0), + ); + const replacement = activator.activate(workspace); + const endpoint = await replacement.endpoint; + expect(endpoint.boot.epoch).toBe(2); + replacement.release('completed'); + }); + it('evicts idle generations before a new scope starts and never evicts busy uses', async () => { + const { activator } = await setup(1); + const workspace = scope(); + const first = activator.activate(workspace); + await first.endpoint; + await expect(activator.activate(scope('b')).endpoint).rejects.toMatchObject( + { code: 'managed_runtime_capacity_exhausted' }, + ); + first.release('completed'); + const next = activator.activate(scope('b')); + await next.endpoint; + expect(first.signal.aborted).toBe(true); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + next.release('completed'); + }); + it('blocks reload until the new snapshot publishes, and drain rollback cannot revive revoked use', async () => { + const { activator } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + await first.endpoint; + await activator.reloadWorkspace(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + activator.completeReload(workspace.runtime); + const next = activator.activate(workspace); + expect((await next.endpoint).boot.epoch).toBe(2); + activator.beginDrain(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + activator.cancelDrain(workspace.runtime); + next.release('completed'); + await activator.revokeWorkspace(workspace.runtime); + activator.cancelDrain(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + }); + it('contains startup cancellation and shutdown without late attachment', async () => { + const { activator } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + first.release('cancelled'); + await expect(first.endpoint).rejects.toThrow(); + await activator.close(); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + }); + it('bounds replacement requests while eviction is still awaiting exit', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 300));", + ), + ); + const first = activator.activate(scope('a')); + await first.endpoint; + first.release('completed'); + const replacement = activator.activate(scope('b')); + replacement.release('completed'); + for (let i = 0; i < 20; i++) + await expect( + activator.activate(scope(`extra-${i}`)).endpoint, + ).rejects.toMatchObject({ code: 'managed_runtime_capacity_exhausted' }); + await replacement.endpoint; + }); + it('reclaims a proven dead worker after forced termination', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => {});", + ), + 15_000, + ); + const first = activator.activate(scope('a')); + await first.endpoint; + first.release('completed'); + const next = activator.activate(scope('b')); + await next.endpoint; + expect(first.signal.aborted).toBe(true); + next.release('completed'); + }, 20_000); + + it('never forwards ambient model, Gateway, IDE or loader secrets', () => { + expect( + managedWorkerEnvironment({ + HOME: '/home/test', + PATH: '/bin', + WSL_INTEROP: '/run/WSL/123_interop', + QWEN_HOME: '/config', + QWEN_CODE_TRUSTED_FOLDERS_PATH: '/trust', + OPENAI_API_KEY: 'secret', + QWEN_SERVER_TOKEN: 'secret', + QWEN_MANAGED_RUNTIME_TOKEN: 'secret', + NODE_OPTIONS: '--import evil', + QWEN_CODE_IDE_WORKSPACE_PATH: '/other', + }), + ).toEqual({ + HOME: '/home/test', + PATH: '/bin', + WSL_INTEROP: '/run/WSL/123_interop', + QWEN_HOME: '/config', + QWEN_CODE_TRUSTED_FOLDERS_PATH: '/trust', + }); + }); +}); diff --git a/packages/cli/src/serve/local-process-runtime-activator.ts b/packages/cli/src/serve/local-process-runtime-activator.ts new file mode 100644 index 00000000000..66f7dc8298c --- /dev/null +++ b/packages/cli/src/serve/local-process-runtime-activator.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { setMaxListeners } from 'node:events'; +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { + ProcessRegistry, + ProcessExitError, + type TrackedChildProcess, +} from '@qwen-code/acp-bridge'; +import { + MANAGED_RUNTIME_STARTUP_MS, + ManagedRuntimeReleasedError, + type ManagedRuntimeEndpoint, + type ManagedRuntimeScope, + type ManagedRuntimeUse, + type ManagedWorkerBoot, +} from './managed-runtime-activator.js'; +import { ManagedRuntimeProviderError } from './managed-runtime-provider.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +export interface LocalProcessRuntimeActivatorOptions { + readonly stateDir: string; + readonly cliEntry: string; + readonly launcher: readonly string[]; + readonly env: NodeJS.ProcessEnv; + readonly maxWorkers?: number; + readonly startupMs?: number; + readonly log: (event: string, fields: Record) => void; +} +interface Generation { + readonly key: string; + readonly scope: ManagedRuntimeScope; + readonly boot: ManagedWorkerBoot; + readonly controller: AbortController; + readonly uses: Set; + readonly exited: Promise; + readonly resolveExit: () => void; + readonly rejectExit: (error: unknown) => void; + endpoint: Promise; + child?: ChildProcess; + tracked?: TrackedChildProcess; + stop?: Promise; + ready: boolean; + retiring: boolean; + operations: number; + lastUsed: number; + readonly startedAt: number; +} + +export function managedWorkerEnvironment( + source: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if ( + /^(PATH|HOME|USERPROFILE|HOMEDRIVE|HOMEPATH|SystemRoot|WINDIR|COMSPEC|PATHEXT|TMP|TEMP|TMPDIR|LANG|LC_[A-Z_]+|TZ|WSL_INTEROP|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy|QWEN_HOME|QWEN_CODE_TRUSTED_FOLDERS_PATH)$/.test( + key, + ) + ) + result[key] = value; + } + for (const key of ['QWEN_HOME', 'QWEN_CODE_TRUSTED_FOLDERS_PATH']) { + if (result[key]) result[key] = path.resolve(result[key]); + } + return result; +} + +export class LocalProcessRuntimeActivator { + private readonly incarnation = randomUUID(); + private readonly registry = new ProcessRegistry(); + private readonly generations = new Map(); + private readonly epochs = new Map(); + private readonly draining = new Set(); + private readonly revoked = new Set(); + private readonly reloading = new Set(); + private closed = false; + private closePromise?: Promise; + + constructor(private readonly options: LocalProcessRuntimeActivatorOptions) {} + + activate(scope: ManagedRuntimeScope): ManagedRuntimeUse { + const key = JSON.stringify([ + scope.tenantId, + scope.runtime.workspaceId, + scope.runtime.workspaceCwd, + ]); + let generation = this.generations.get(key); + if ( + this.closed || + this.draining.has(scope.runtime) || + this.revoked.has(scope.runtime) || + this.reloading.has(scope.runtime) || + generation?.retiring || + (generation && generation.scope.runtime !== scope.runtime) + ) { + return this.unavailable('managed_runtime_unavailable'); + } + if (!generation) { + const limit = this.options.maxWorkers ?? 4; + let eviction: Promise | undefined; + if (this.generations.size > limit) + return this.unavailable('managed_runtime_capacity_exhausted'); + if (this.generations.size >= limit) { + const idle = [...this.generations.values()] + .filter((g) => !g.retiring && g.uses.size === 0 && g.operations === 0) + .sort((a, b) => a.lastUsed - b.lastUsed)[0]; + if (!idle) + return this.unavailable('managed_runtime_capacity_exhausted'); + eviction = this.stop( + idle, + new ManagedRuntimeReleasedError('Managed Runtime evicted.'), + ); + } + const epoch = (this.epochs.get(key) ?? 0) + 1; + this.epochs.set(key, epoch); + const leaseId = randomUUID(); + let resolveExit!: () => void; + let rejectExit!: (error: unknown) => void; + const exited = new Promise((resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }); + void exited.catch(() => {}); + generation = { + key, + scope, + boot: { + type: 'boot', + cliEntry: this.options.cliEntry, + version: 1, + gatewayIncarnation: this.incarnation, + leaseId, + epoch, + tenantId: scope.tenantId, + workspaceId: scope.runtime.workspaceId, + workspaceCwd: scope.runtime.workspaceCwd, + token: randomBytes(32).toString('hex'), + outputRoot: path.join(this.options.stateDir, 'workers', leaseId), + }, + controller: new AbortController(), + uses: new Set(), + exited, + resolveExit, + rejectExit, + endpoint: Promise.resolve(undefined as never), + ready: false, + retiring: false, + operations: 0, + lastUsed: Date.now(), + startedAt: Date.now(), + }; + setMaxListeners(0, generation.controller.signal); + this.generations.set(key, generation); + const active = generation; + active.endpoint = this.start(active, eviction); + void active.endpoint.catch((error) => { + void this.stop( + active, + error instanceof Error + ? error + : new Error('Managed Runtime startup failed.'), + ).catch(() => {}); + }); + } else this.log(generation, 'reuse'); + const active = generation; + const id = Symbol('runtime-use'); + active.uses.add(id); + let released = false; + const prepareDeadline = active.ready + ? Date.now() + (this.options.startupMs ?? MANAGED_RUNTIME_STARTUP_MS) + : undefined; + return { + endpoint: active.endpoint.then((endpoint) => + prepareDeadline === undefined + ? endpoint + : { ...endpoint, deadline: prepareDeadline }, + ), + signal: active.controller.signal, + exited: active.exited, + release: (reason) => { + if (released) return; + released = true; + active.uses.delete(id); + active.lastUsed = Date.now(); + if ( + active.uses.size === 0 && + (active.retiring || (!active.ready && reason !== 'completed')) + ) { + void this.stop( + active, + new ManagedRuntimeReleasedError('Managed Runtime use ended.'), + ).catch(() => {}); + } + }, + beginOperation: () => { + active.controller.signal.throwIfAborted(); + active.operations++; + let state: 'active' | 'uncertain' | 'finished' = 'active'; + return (certain) => { + if (state === 'finished') return; + if (certain) { + state = 'finished'; + active.operations--; + } else { + state = 'uncertain'; + active.retiring = true; + } + if (active.retiring && active.uses.size === 0) + void this.stop( + active, + new ManagedRuntimeReleasedError( + 'Managed Runtime uncertain execution contained.', + ), + ).catch(() => {}); + }; + }, + }; + } + + beginDrain(runtime: WorkspaceRuntime): void { + this.draining.add(runtime); + } + cancelDrain(runtime: WorkspaceRuntime): void { + this.draining.delete(runtime); + } + workspaceActivity(runtime: WorkspaceRuntime): number { + return [...this.generations.values()] + .filter((g) => g.scope.runtime === runtime) + .reduce((n, g) => n + g.uses.size + g.operations, 0); + } + revokeWorkspace(runtime: WorkspaceRuntime): Promise { + this.revoked.add(runtime); + return this.stopWorkspace(runtime); + } + reloadWorkspace(runtime: WorkspaceRuntime): Promise { + this.reloading.add(runtime); + return this.stopWorkspace(runtime); + } + completeReload(runtime: WorkspaceRuntime): void { + this.reloading.delete(runtime); + } + private async stopWorkspace(runtime: WorkspaceRuntime): Promise { + await Promise.all( + [...this.generations.values()] + .filter((g) => g.scope.runtime === runtime) + .map((g) => + this.stop( + g, + new ManagedRuntimeReleasedError( + 'Managed Runtime workspace invalidated.', + ), + ), + ), + ); + } + close(): Promise { + this.closed = true; + this.closePromise ??= Promise.all( + [...this.generations.values()].map((g) => + this.stop( + g, + new ManagedRuntimeReleasedError('Managed Runtime Gateway stopped.'), + ), + ), + ).then(() => this.registry.shutdown()); + return this.closePromise; + } + killAllSync(): void { + this.closed = true; + this.registry.killAllSync(); + } + + private unavailable( + code: 'managed_runtime_capacity_exhausted' | 'managed_runtime_unavailable', + ): ManagedRuntimeUse { + this.options.log(code, {}); + const endpoint = Promise.reject( + new ManagedRuntimeProviderError( + code, + 'Managed Runtime cannot accept a new use.', + true, + ), + ); + void endpoint.catch(() => {}); + return { + endpoint, + signal: new AbortController().signal, + exited: Promise.resolve(), + release: () => {}, + beginOperation: () => () => {}, + }; + } + private async start( + g: Generation, + eviction?: Promise, + ): Promise { + const deadline = + g.startedAt + (this.options.startupMs ?? MANAGED_RUNTIME_STARTUP_MS); + await eviction; + g.controller.signal.throwIfAborted(); + await mkdir(g.boot.outputRoot, { recursive: true, mode: 0o700 }); + g.controller.signal.throwIfAborted(); + if (Date.now() >= deadline) + throw new Error('Managed Runtime startup timed out.'); + let endpointUrl: string | undefined; + const reservation = this.registry.reserve(); + try { + const child = spawn(process.execPath, [...this.options.launcher], { + cwd: g.boot.workspaceCwd, + env: managedWorkerEnvironment(this.options.env), + detached: process.platform !== 'win32', + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + g.child = child; + g.tracked = reservation.attach(child, { ownsProcessTree: true }); + // Drain without echoing arbitrary worker output (which may contain secrets). + child.stdout?.resume(); + child.stderr?.resume(); + child.once('exit', () => { + if (!g.retiring) + void this.stop( + g, + new Error('Managed Runtime worker exited unexpectedly.'), + ).catch(() => {}); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => done(new Error('Managed Runtime startup timed out.')), + Math.max(0, deadline - Date.now()), + ); + const aborted = () => done(g.controller.signal.reason); + const error = () => + done(new Error('Managed Runtime worker could not start.')); + const exited = () => + done(new Error('Managed Runtime worker exited during startup.')); + const message = (value: unknown) => { + const ready = value as Partial> & { + type?: string; + url?: unknown; + }; + if ( + ready?.type !== 'ready' || + ready.version !== 1 || + ready.leaseId !== g.boot.leaseId || + ready.epoch !== g.boot.epoch || + ready.gatewayIncarnation !== this.incarnation || + ready.workspaceId !== g.boot.workspaceId || + ready.workspaceCwd !== g.boot.workspaceCwd || + ready.tenantId !== g.boot.tenantId || + typeof ready.url !== 'string' + ) + return done( + new Error( + 'Managed Runtime worker returned an invalid handshake.', + ), + ); + let url: URL; + try { + url = new URL(ready.url); + } catch { + done(new Error('Managed Runtime worker returned an invalid URL.')); + return; + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + !url.port || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) + return done( + new Error('Managed Runtime worker returned an invalid endpoint.'), + ); + endpointUrl = url.origin; + done(); + }; + const done = (error?: unknown) => { + clearTimeout(timer); + g.controller.signal.removeEventListener('abort', aborted); + child.removeListener('message', message); + child.removeListener('error', errorHandler); + child.removeListener('exit', exited); + if (error) reject(error); + else resolve(); + }; + const errorHandler = error; + child.on('message', message); + child.once('error', errorHandler); + child.once('exit', exited); + g.controller.signal.addEventListener('abort', aborted, { once: true }); + if (g.controller.signal.aborted) aborted(); + else + child.send(g.boot, (err) => { + if (err) error(); + }); + }); + g.controller.signal.throwIfAborted(); + g.ready = true; + this.log(g, 'started'); + return { url: endpointUrl!, boot: g.boot, deadline }; + } finally { + reservation.cancel(); + } + } + private stop(g: Generation, reason: Error): Promise { + if (g.stop) return g.stop; + g.retiring = true; + g.controller.abort(reason); + g.stop = (async () => { + await g.endpoint.catch(() => {}); + if (g.tracked) { + try { + await g.tracked.terminate(); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + this.log(g, 'terminated'); + } + } + await rm(g.boot.outputRoot, { recursive: true, force: true }); + if (this.generations.get(g.key) === g) this.generations.delete(g.key); + this.log(g, 'released'); + g.resolveExit(); + })().catch((error) => { + g.rejectExit(error); + this.log(g, 'cleanup_failed'); + throw error; + }); + return g.stop; + } + private log(g: Generation, event: string): void { + this.options.log(event, { + workspaceId: g.boot.workspaceId, + generation: g.boot.epoch, + elapsedMs: Date.now() - g.startedAt, + pid: g.child?.pid, + }); + } +} diff --git a/packages/cli/src/serve/managed-runtime-activator.ts b/packages/cli/src/serve/managed-runtime-activator.ts new file mode 100644 index 00000000000..8426e8db61b --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-activator.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspaceRuntime } from './workspace-registry.js'; + +export const MANAGED_RUNTIME_STARTUP_MS = 5 * 60_000; +export const MANAGED_LEASE_ID_HEADER = 'X-Qwen-Managed-Lease-Id'; +export const MANAGED_LEASE_EPOCH_HEADER = 'X-Qwen-Managed-Lease-Epoch'; + +export interface ManagedRuntimeScope { + readonly tenantId: string; + readonly runtime: WorkspaceRuntime; +} +export interface ManagedWorkerBoot { + readonly type: 'boot'; + readonly cliEntry: string; + readonly version: 1; + readonly gatewayIncarnation: string; + readonly leaseId: string; + readonly epoch: number; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceCwd: string; + readonly token: string; + readonly outputRoot: string; +} +export interface ManagedRuntimeEndpoint { + readonly url: string; + readonly boot: ManagedWorkerBoot; + readonly deadline: number; +} +export type RuntimeFinishReason = 'completed' | 'failed' | 'cancelled'; +export class ManagedRuntimeReleasedError extends Error {} +export interface ManagedRuntimeUse { + readonly endpoint: Promise; + readonly signal: AbortSignal; + readonly exited: Promise; + release(reason: RuntimeFinishReason): void; + beginOperation(): (certain: boolean) => void; +} diff --git a/packages/cli/src/serve/managed-runtime-protocol.ts b/packages/cli/src/serve/managed-runtime-protocol.ts new file mode 100644 index 00000000000..e74fddb96b0 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-protocol.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, +} from '@qwen-code/acp-bridge/bridgeTypes'; +import { parseCallerSuppliedSessionId } from '../config/session-id.js'; + +export const MANAGED_RUNTIME_PROTOCOL_VERSION = 1 as const; +export const MANAGED_RUNTIME_ROUTE_PREFIX = + '/internal/managed-runtime/v1' as const; + +const MAX_ID_LENGTH = 256; +const MAX_EXECUTION_ID_LENGTH = 128; +const MAX_TURN_ID_LENGTH = 128; +const MAX_TOOL_CALL_ID_LENGTH = 512; +const MAX_WORKSPACE_CWD_LENGTH = 4096; +const MAX_TOOL_NAME_LENGTH = 256; +const MAX_TOOL_INPUT_BYTES = 256 * 1024; +const PREPARE_KEYS = new Set([ + 'protocolVersion', + 'tenantId', + 'workspaceId', + 'workspaceCwd', + 'sessionId', + 'turnKind', +]); + +export interface ManagedRuntimePrepareRequest { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceCwd: string; + readonly sessionId: string; + readonly turnKind: 'bootstrap' | 'continuation'; +} + +export interface ManagedRuntimeExecuteRequest + extends ManagedRuntimePrepareRequest { + readonly toolRequest: BridgeManagedRuntimeToolExecuteRequest; +} + +export interface ManagedRuntimeCancelRequest + extends ManagedRuntimePrepareRequest { + readonly executionId: string; +} + +export interface ManagedRuntimeReadyResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly ready: true; +} + +export interface ManagedRuntimeManifestResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly manifest: BridgeManagedRuntimeToolManifest; +} + +export interface ManagedRuntimeExecuteResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly result: BridgeManagedRuntimeToolExecuteResult; +} + +export interface ManagedRuntimeCancelResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly cancelled: boolean; +} + +export interface ManagedRuntimeReleaseResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly released: boolean; +} + +export class ManagedRuntimeProtocolError extends Error { + readonly code = 'managed_runtime_invalid_request'; + + constructor(message = 'Managed Runtime request is invalid.') { + super(message); + this.name = 'ManagedRuntimeProtocolError'; + } +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ManagedRuntimeProtocolError(); + } + return value as Record; +} + +function boundedString(value: unknown, maxLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength || + value.includes('\0') + ) { + throw new ManagedRuntimeProtocolError(); + } + return value; +} + +function assertKeys( + value: Record, + allowed: ReadonlySet, +): void { + if (Object.keys(value).some((key) => !allowed.has(key))) { + throw new ManagedRuntimeProtocolError(); + } +} + +function prepareFields(value: unknown): ManagedRuntimePrepareRequest { + const input = record(value); + if (input['protocolVersion'] !== MANAGED_RUNTIME_PROTOCOL_VERSION) { + throw new ManagedRuntimeProtocolError( + 'Managed Runtime protocol version is unsupported.', + ); + } + const turnKind = input['turnKind']; + if (turnKind !== 'bootstrap' && turnKind !== 'continuation') { + throw new ManagedRuntimeProtocolError(); + } + const sessionId = parseCallerSuppliedSessionId(input['sessionId']); + if (sessionId.kind !== 'valid') { + throw new ManagedRuntimeProtocolError(); + } + return { + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + tenantId: boundedString(input['tenantId'], MAX_ID_LENGTH), + workspaceId: boundedString(input['workspaceId'], MAX_ID_LENGTH), + workspaceCwd: boundedString( + input['workspaceCwd'], + MAX_WORKSPACE_CWD_LENGTH, + ), + sessionId: sessionId.sessionId, + turnKind, + }; +} + +export function parseManagedRuntimePrepareRequest( + value: unknown, +): ManagedRuntimePrepareRequest { + const input = record(value); + assertKeys(input, PREPARE_KEYS); + return prepareFields(input); +} + +export function parseManagedRuntimeExecuteRequest( + value: unknown, +): ManagedRuntimeExecuteRequest { + const input = record(value); + assertKeys(input, new Set([...PREPARE_KEYS, 'toolRequest'])); + const toolRequest = record(input['toolRequest']); + assertKeys( + toolRequest, + new Set([ + 'executionId', + 'turnId', + 'toolCallId', + 'capabilityDigest', + 'toolName', + 'input', + ]), + ); + const rawInput = record(toolRequest['input']); + let serializedInput: string; + try { + serializedInput = JSON.stringify(rawInput); + } catch { + throw new ManagedRuntimeProtocolError(); + } + if (Buffer.byteLength(serializedInput, 'utf8') > MAX_TOOL_INPUT_BYTES) { + throw new ManagedRuntimeProtocolError(); + } + return { + ...prepareFields(input), + toolRequest: { + executionId: boundedString( + toolRequest['executionId'], + MAX_EXECUTION_ID_LENGTH, + ), + turnId: boundedString(toolRequest['turnId'], MAX_TURN_ID_LENGTH), + toolCallId: boundedString( + toolRequest['toolCallId'], + MAX_TOOL_CALL_ID_LENGTH, + ), + capabilityDigest: (() => { + const digest = boundedString(toolRequest['capabilityDigest'], 64); + if (!/^[a-f0-9]{64}$/.test(digest)) { + throw new ManagedRuntimeProtocolError(); + } + return digest; + })(), + toolName: boundedString(toolRequest['toolName'], MAX_TOOL_NAME_LENGTH), + input: structuredClone(rawInput), + }, + }; +} + +export function parseManagedRuntimeCancelRequest( + value: unknown, +): ManagedRuntimeCancelRequest { + const input = record(value); + assertKeys(input, new Set([...PREPARE_KEYS, 'executionId'])); + return { + ...prepareFields(input), + executionId: boundedString(input['executionId'], MAX_EXECUTION_ID_LENGTH), + }; +} + +export function sameManagedRuntimeIdentity( + left: ManagedRuntimePrepareRequest, + right: ManagedRuntimePrepareRequest, +): boolean { + return ( + left.tenantId === right.tenantId && + left.workspaceId === right.workspaceId && + left.workspaceCwd === right.workspaceCwd && + left.sessionId === right.sessionId + ); +} diff --git a/packages/cli/src/serve/managed-runtime-provider.test.ts b/packages/cli/src/serve/managed-runtime-provider.test.ts new file mode 100644 index 00000000000..bfa13ad0377 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-provider.test.ts @@ -0,0 +1,1880 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment node + +import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express, { type RequestHandler } from 'express'; +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ManagedToolV2Client } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { ManagedToolFileHistoryState } from '@qwen-code/qwen-code-core'; +import type { ManagedWorkerBoot } from './managed-runtime-activator.js'; +import type { ManagedWorkerFileBoot } from './managed-runtime-worker-bootstrap.js'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { + LocalManagedRuntimeProvider, + RemoteManagedRuntimeProvider, +} from './managed-runtime-provider.js'; +import { + MANAGED_RUNTIME_PROTOCOL_VERSION, + MANAGED_RUNTIME_ROUTE_PREFIX, + parseManagedRuntimeExecuteRequest, + parseManagedRuntimePrepareRequest, + type ManagedRuntimePrepareRequest, +} from './managed-runtime-protocol.js'; +import { registerManagedRuntimeWorkerRoutes } from './routes/managed-runtime-worker.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; + +const workspaceCwd = '/tmp/managed-runtime-p8'; +const workspaceId = 'workspace-p8'; +const token = 'runtime-secret'; + +const prepareRequest: ManagedRuntimePrepareRequest = { + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + tenantId: 'tenant-p8', + workspaceId, + workspaceCwd, + sessionId: '550e8400-e29b-41d4-a716-446655440108', + turnKind: 'bootstrap', +}; + +const tools = [{ name: 'read_file', description: 'Read one file' }]; +const manifest = { + capabilityDigest: createHash('sha256') + .update(JSON.stringify(tools)) + .digest('hex'), + tools, +}; + +function fakeRuntime(): { + bridge: AcpSessionBridge; + registry: WorkspaceRegistry; + execute: ReturnType; + cancel: ReturnType; + close: ReturnType; +} { + const execute = vi.fn(async (_sessionId, toolRequest) => ({ + responseParts: [ + { + functionResponse: { + id: toolRequest.toolCallId, + name: toolRequest.toolName, + response: { output: 'remote workspace evidence' }, + }, + }, + ], + executionStatus: 'success' as const, + })); + const cancel = vi.fn(async () => ({ cancelled: true })); + const close = vi.fn(async () => undefined); + const bridge = { + spawnOrAttach: vi.fn(async (input) => ({ + sessionId: input.sessionId!, + workspaceCwd, + attached: false, + clientId: 'runtime-client-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: input.sessionId!, + })), + resumeSession: vi.fn(), + getSessionSummary: vi.fn(() => { + throw Object.assign(new Error('not live'), { code: 'session_not_found' }); + }), + recordHeartbeat: vi.fn(), + getManagedRuntimeToolManifest: vi.fn(async () => manifest), + executeManagedRuntimeTool: execute, + cancelManagedRuntimeTool: cancel, + closeSession: close, + detachClient: vi.fn(async () => undefined), + } as unknown as AcpSessionBridge; + const runtime = { + workspaceId, + workspaceCwd, + trusted: true, + bridge, + } as WorkspaceRuntime; + const registry = { + getByWorkspaceId: vi.fn((id: string) => + id === workspaceId ? runtime : undefined, + ), + } as unknown as WorkspaceRegistry; + return { bridge, registry, execute, cancel, close }; +} + +function workerApp( + provider: LocalManagedRuntimeProvider, + owned?: ManagedWorkerBoot | ManagedWorkerFileBoot, + beforeRoutes?: RequestHandler, +) { + const app = express(); + app.use(express.json({ limit: '10mb' })); + if (beforeRoutes) app.use(beforeRoutes); + registerManagedRuntimeWorkerRoutes(app, { + provider, + owned, + authorize: (req, res, next) => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + next(); + }, + }); + return app; +} + +async function listen(server: Server, port = 0): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + return (server.address() as AddressInfo).port; +} + +async function close(server: Server | undefined): Promise { + if (!server?.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +describe('Managed Runtime providers', () => { + const servers: Server[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => close(server))); + }); + + function toolV2Client() { + const historyState: ManagedToolFileHistoryState = { + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots: [], + }; + return { + fileHistory: { + bind: vi.fn(async () => historyState), + checkpoint: vi.fn(async () => historyState), + snapshot: vi.fn(async () => historyState), + }, + manifest: vi.fn(async () => manifest), + beginTurn: vi.fn(async () => {}), + prepare: vi.fn(async () => ({})), + confirmation: vi.fn(async () => ({})), + confirm: vi.fn(async () => {}), + preflight: vi.fn(async () => ({})), + execute: vi.fn(async () => ({ executionStatus: 'success' })), + status: vi.fn(async () => ({ state: 'executing' })), + cancel: vi.fn(async () => ({ state: 'cancel_requested' })), + }; + } + + function ownedBoot(): ManagedWorkerBoot { + return { + type: 'boot', + version: 1, + gatewayIncarnation: 'gateway', + leaseId: 'lease', + epoch: 1, + ...prepareRequest, + token, + outputRoot: '/tmp/owned-output', + cliEntry: '/cli.js', + }; + } + + function fileOwnedBoot(): ManagedWorkerFileBoot { + return { + ...ownedBoot(), + runtimeInstanceId: 'runtime-instance', + provisionRequestId: 'provision-request', + workspaceGeneration: 'workspace-generation', + capabilityDigest: 'capability-digest', + isolationClass: 'workspace', + }; + } + + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; + } + + it('attests the exact durable worker identity before private use', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const owned = fileOwnedBoot(); + const app = workerApp(local, owned); + const body = { + protocolVersion: 2, + provisionRequestId: owned.provisionRequestId, + tenantId: owned.tenantId, + workspaceId: owned.workspaceId, + workspaceGeneration: owned.workspaceGeneration, + workspaceCwd: owned.workspaceCwd, + capabilityDigest: owned.capabilityDigest, + isolationClass: owned.isolationClass, + }; + const attest = (value: Record) => + request(app) + .post('/internal/managed-runtime/v2/attest') + .set('Authorization', `Bearer ${token}`) + .set('X-Qwen-Managed-Lease-Id', owned.leaseId) + .set('X-Qwen-Managed-Lease-Epoch', String(owned.epoch)) + .send(value); + + await request(app) + .post('/internal/managed-runtime/v2/attest') + .send(body) + .expect(401); + await attest({ ...body, provisionRequestId: 'another' }).expect(409); + const response = await attest(body).expect(200); + expect(response.body).toEqual({ + protocolVersion: 2, + runtimeInstanceId: owned.runtimeInstanceId, + runtimeIncarnation: owned.gatewayIncarnation, + leaseId: owned.leaseId, + epoch: owned.epoch, + provisionRequestId: owned.provisionRequestId, + tenantId: owned.tenantId, + workspaceId: owned.workspaceId, + workspaceGeneration: owned.workspaceGeneration, + workspaceCwd: owned.workspaceCwd, + capabilityDigest: owned.capabilityDigest, + isolationClass: owned.isolationClass, + }); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('fences a parsed first v2 request that arrives after release is acknowledged', async () => { + const runtime = fakeRuntime(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => toolV2Client() as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const entered = deferred(); + const dispatch = deferred(); + const owned = ownedBoot(); + const server = createServer( + workerApp(local, owned, (req, _res, next) => { + if (req.path.endsWith('/v2/manifest')) { + entered.resolve(); + void dispatch.promise.then(() => next()); + } else next(); + }), + ); + servers.push(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${await listen(server)}`, + token, + lease: owned, + }); + try { + const client = await remote.getToolV2Client(prepareRequest); + const manifest = client.manifest().catch((error: unknown) => error); + await entered.promise; + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(await manifest).toBeInstanceOf(Error); + expect(runtime.close).toHaveBeenCalledOnce(); + const late = vi.spyOn(local, 'getToolV2Client'); + dispatch.resolve(); + await vi.waitFor(() => expect(late).toHaveBeenCalledOnce()); + await expect(late.mock.results[0].value).rejects.toThrow(); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledOnce(); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + expect(() => remote.prepare(prepareRequest)).toThrow(); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + } finally { + dispatch.resolve(); + remote.dispose(); + local.dispose(); + } + }); + + it('upgrades a pending local v1 release to a terminal identity before completing cleanup', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + const close = deferred(); + runtime.close.mockReturnValueOnce(close.promise); + const ordinary = local.release(prepareRequest.sessionId, prepareRequest); + const terminal = local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }); + expect(() => local.prepare(prepareRequest)).toThrow(); + close.resolve(); + expect(await Promise.all([ordinary, terminal])).toEqual([true, true]); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect(local.getToolV2Client(prepareRequest)).rejects.toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + await expect( + local.release( + prepareRequest.sessionId, + { ...prepareRequest, tenantId: 'other' }, + { terminal: true }, + ), + ).rejects.toThrow('identity'); + expect(runtime.close).toHaveBeenCalledOnce(); + local.dispose(); + }); + + it('retains a failed terminal close for retry and seals before any preparation has arrived', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( + new Error('restore failed'), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('restore failed'); + expect(() => local.prepare(prepareRequest)).toThrow(); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(runtime.bridge.resumeSession).toHaveBeenCalledTimes(2); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('keeps ordinary v1 release reusable for the same Session identity', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + await local.release(prepareRequest.sessionId, prepareRequest); + await local.prepare(prepareRequest).ready; + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(2); + await local.release(prepareRequest.sessionId, prepareRequest); + local.dispose(); + }); + + it('waits for a v2 terminal receipt when a pending remote v1 release is upgraded', async () => { + const ordinaryAck = deferred(); + const terminalAck = deferred(); + const versions: number[] = []; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: ownedBoot(), + fetch: vi.fn(async (url: RequestInfo | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/prepare')) + return Response.json({ protocolVersion: 1, ready: true }); + const version = path.includes('/v2/') ? 2 : 1; + versions.push(version); + return version === 1 ? ordinaryAck.promise : terminalAck.promise; + }), + }); + try { + await remote.prepare(prepareRequest).ready; + let closed = false; + const ordinary = remote + .release(prepareRequest.sessionId, prepareRequest) + .then(() => { + closed = true; + }); + await vi.waitFor(() => expect(versions).toEqual([1])); + const terminal = remote.release( + prepareRequest.sessionId, + prepareRequest, + { terminal: true }, + ); + ordinaryAck.resolve( + Response.json({ protocolVersion: 1, released: true }), + ); + await vi.waitFor(() => expect(versions).toEqual([1, 2])); + expect(closed).toBe(false); + terminalAck.resolve( + Response.json({ protocolVersion: 2, released: true }), + ); + await Promise.all([ordinary, terminal]); + expect(() => remote.prepare(prepareRequest)).toThrow(); + } finally { + remote.dispose(); + } + }); + + it('rejects a v1 receipt for terminal release and retries the owned v2 endpoint', async () => { + const versions: string[] = []; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: ownedBoot(), + fetch: vi.fn(async (url: RequestInfo | URL) => { + versions.push(new URL(String(url)).pathname); + return Response.json({ + protocolVersion: versions.length === 1 ? 1 : 2, + released: true, + }); + }), + }); + try { + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('did not confirm'); + expect(() => remote.prepare(prepareRequest)).toThrow(); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(versions).toEqual([ + '/internal/managed-runtime/v2/release', + '/internal/managed-runtime/v2/release', + ]); + } finally { + remote.dispose(); + } + }); + + it('requires an owned lease for terminal release before sending any request', async () => { + const fetch = vi.fn(); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch, + }); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('owned Runtime lease'); + expect(fetch).not.toHaveBeenCalled(); + remote.dispose(); + }); + + it('exposes terminal release only with owned auth, lease and strict workspace identity', async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValue( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const path = '/internal/managed-runtime/v2/release'; + const body = { ...prepareRequest, protocolVersion: 2 }; + await request(workerApp(local)).post(path).send(body).expect(404); + const app = workerApp(local, ownedBoot()); + await request(app).post(path).send(body).expect(401); + await request(app) + .post(path) + .set('authorization', `Bearer ${token}`) + .send(body) + .expect(409); + const authorized = () => + request(app) + .post(path) + .set('authorization', `Bearer ${token}`) + .set('X-Qwen-Managed-Lease-Id', 'lease') + .set('X-Qwen-Managed-Lease-Epoch', '1'); + await authorized() + .send({ ...body, tenantId: 'other' }) + .expect(409); + await authorized() + .send({ ...body, extra: true }) + .expect(400); + await authorized() + .send({ ...body, protocolVersion: 1 }) + .expect(400); + const result = await authorized().send(body).expect(200); + expect(result.body).toEqual({ protocolVersion: 2, released: true }); + expect(() => local.prepare(prepareRequest)).toThrow(); + local.dispose(); + }); + + it('connects v2 invocations and file history through the owned HTTP listener without losing optional fields', async () => { + const runtime = fakeRuntime(); + const client = toolV2Client(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => client as unknown as ManagedToolV2Client, + ); + vi.mocked(runtime.bridge.getSessionSummary).mockReturnValue({ + workspaceCwd, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + } as never); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const owned: ManagedWorkerBoot = { + type: 'boot', + version: 1, + gatewayIncarnation: 'gateway', + leaseId: 'lease', + epoch: 1, + ...prepareRequest, + token, + outputRoot: '/tmp/owned-output', + cliEntry: '/cli.js', + }; + const server = createServer(workerApp(local, owned)); + servers.push(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${await listen(server)}`, + token, + lease: owned, + }); + try { + const connected = await remote.getToolV2Client(prepareRequest); + const trackedFileBackups = Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [ + `${'directory/'.repeat(5)}file-${i}.txt`, + { + backupFileName: '0123456789abcdef@v1', + version: 1, + backupTime: '2026-09-09T00:00:00.000Z', + }, + ]), + ); + const snapshots = Array.from({ length: 100 }, (_, i) => ({ + promptId: `turn-${i}`, + timestamp: '2026-09-09T00:00:00.000Z', + trackedFileBackups, + })); + expect(Buffer.byteLength(JSON.stringify(snapshots))).toBeGreaterThan( + 1024 * 1024, + ); + client.fileHistory.bind.mockResolvedValueOnce({ + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots, + }); + const binding = { + ownerSessionId: prepareRequest.sessionId, + ownerRuntimeSessionId: prepareRequest.sessionId, + executionCwd: workspaceCwd, + snapshots, + }; + await expect(connected.fileHistory!.bind(binding)).resolves.toEqual({ + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots, + }); + await connected.fileHistory!.checkpoint('parent-turn'); + await connected.fileHistory!.snapshot(); + expect(client.fileHistory.bind).toHaveBeenCalledExactlyOnceWith(binding); + expect(client.fileHistory.checkpoint).toHaveBeenCalledExactlyOnceWith( + 'parent-turn', + ); + expect(client.fileHistory.snapshot).toHaveBeenCalledExactlyOnceWith(); + client.fileHistory.snapshot.mockResolvedValueOnce({ + ownerSessionId: prepareRequest.sessionId, + revision: -1, + snapshots: [], + }); + await expect(connected.fileHistory!.snapshot()).rejects.toThrow(); + await connected.manifest(); + const identity = { + sessionId: invocation.sessionId, + promptId: invocation.promptId, + callId: invocation.callId, + capabilityDigest: invocation.capabilityDigest, + policyRevision: invocation.policyRevision, + }; + await connected.beginTurn(identity); + await connected.prepare(identity, 'read_file', { + file_path: 'proof.txt', + }); + const mediaContext = { inputModalities: { image: true, pdf: false } }; + await connected.prepare( + identity, + 'read_file', + { file_path: 'image.png' }, + undefined, + mediaContext, + ); + expect(client.prepare).toHaveBeenLastCalledWith( + identity, + 'read_file', + { file_path: 'image.png' }, + undefined, + mediaContext, + ); + await connected.confirmation(invocation); + await connected.confirm(invocation, 'proceed_once' as never); + await connected.confirm( + invocation, + 'proceed_once' as never, + { answers: { answer: 'yes' } }, + 'preflight', + ); + await connected.preflight(invocation); + await connected.execute(invocation); + await connected.status(invocation); + await connected.cancel(invocation); + expect(client.confirm.mock.calls).toEqual([ + [invocation, 'proceed_once', undefined, 'permission'], + [ + invocation, + 'proceed_once', + { answers: { answer: 'yes' } }, + 'preflight', + ], + ]); + expect(client.execute).toHaveBeenCalledExactlyOnceWith(invocation); + expect(client.status).toHaveBeenCalledExactlyOnceWith(invocation, 0); + const data = Buffer.alloc(7 * 1024 * 1024, 1).toString('base64'); + const mediaResult = { + executionStatus: 'success', + result: { + llmContent: [{ inlineData: { mimeType: 'image/jpeg', data } }], + returnDisplay: 'image', + }, + }; + const mediaStatus = { + state: 'settled', + result: mediaResult, + progress: [], + cancelRequested: false, + firstAvailableSeq: 1, + lastSeq: 0, + progressGap: false, + }; + client.execute.mockResolvedValueOnce(mediaResult); + client.status.mockResolvedValueOnce(mediaStatus); + client.cancel.mockResolvedValueOnce(mediaStatus); + expect(data.length).toBeGreaterThan(8 * 1024 * 1024); + await expect(connected.execute(invocation)).resolves.toEqual(mediaResult); + await expect(connected.status(invocation)).resolves.toEqual(mediaStatus); + await expect(connected.cancel(invocation)).resolves.toEqual(mediaStatus); + await expect( + remote.getToolV2Client({ ...prepareRequest, tenantId: 'other' }), + ).rejects.toThrow(); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + } finally { + remote.dispose(); + local.dispose(); + } + }); + + it('applies the media exception only to valid v2 inline bytes over a chunked HTTP response', async () => { + let responseText = ''; + const server = createServer((req, res) => { + req.resume(); + res.setHeader('content-type', 'application/json'); + if (req.url?.endsWith('/v1/prepare')) { + res.end(JSON.stringify({ protocolVersion: 1, ready: true })); + return; + } + for (let start = 0; start < responseText.length; start += 256 * 1024) { + res.write(responseText.slice(start, start + 256 * 1024)); + } + res.end(); + }); + servers.push(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${await listen(server)}`, + token, + lease: ownedBoot(), + }); + try { + const connected = await remote.getToolV2Client(prepareRequest); + const data = 'AAAA'.repeat(2300 * 1024); + const media = { + executionStatus: 'success', + result: { + llmContent: [{ inlineData: { mimeType: 'image/jpeg', data } }], + returnDisplay: 'image', + }, + }; + const valid = JSON.stringify({ protocolVersion: 2, result: media }); + responseText = valid; + await expect(connected.execute(invocation)).resolves.toEqual(media); + for (const makeResponse of [ + () => + JSON.stringify({ + protocolVersion: 2, + result: { + executionStatus: 'success', + result: { llmContent: data }, + }, + }), + () => + JSON.stringify({ + protocolVersion: 2, + result: { ...media, postHook: { text: data } }, + }), + () => + `{"result":${JSON.stringify(media)},"protocolVersion":2,"result":{"executionStatus":"success"}}`, + () => valid + ' '.repeat(8 * 1024 * 1024), + () => valid.replace(data, '\\u0041'.repeat(data.length)), + ]) { + responseText = makeResponse(); + await expect(connected.execute(invocation)).rejects.toThrow( + 'control response exceeded', + ); + } + responseText = JSON.stringify({ + protocolVersion: 2, + result: { + ...media, + result: { + llmContent: [ + { inlineData: { mimeType: 'image/jpeg', data: 'AR==' } }, + ], + }, + }, + }); + await expect(connected.execute(invocation)).rejects.toThrow( + 'invalid inline media', + ); + responseText = JSON.stringify({ + protocolVersion: 2, + result: { state: 'executing', result: media }, + }); + await expect(connected.status(invocation)).rejects.toThrow( + 'control response exceeded', + ); + responseText = JSON.stringify({ + protocolVersion: 2, + result: { source: data }, + }); + await expect(connected.fileHistory!.snapshot()).rejects.toThrow( + 'size limit', + ); + responseText = JSON.stringify({ protocolVersion: 1, result: media }); + await expect( + remote + .prepare(prepareRequest) + .execute({} as never, new AbortController().signal), + ).rejects.toThrow('size limit'); + responseText = valid + ' '.repeat(64 * 1024 * 1024); + await expect(connected.execute(invocation)).rejects.toThrow('size limit'); + } finally { + remote.dispose(); + } + }); + + it('keeps an admitted v2 execute response during release while refusing new execution', async () => { + const runtime = fakeRuntime(); + const client = toolV2Client(); + const started = deferred(); + const closing = deferred(); + const finish = deferred(); + client.execute.mockImplementation(async () => { + started.resolve(); + await finish.promise; + return { executionStatus: 'success' }; + }); + runtime.close.mockImplementation(async () => { + closing.resolve(); + await finish.promise; + }); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => client as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const server = createServer(workerApp(local, ownedBoot())); + servers.push(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${await listen(server)}`, + token, + lease: ownedBoot(), + }); + let pending: Promise | undefined; + let release: Promise | undefined; + try { + const connected = await remote.getToolV2Client(prepareRequest); + let settled = false; + pending = connected.execute(invocation).then( + (result) => { + settled = true; + return result; + }, + (error: unknown) => { + settled = true; + return error; + }, + ); + await started.promise; + release = remote.release(prepareRequest.sessionId, prepareRequest); + await closing.promise; + await expect(connected.execute(invocation)).rejects.toThrow('released'); + await expect( + connected.prepare(invocation, 'read_file', {}), + ).rejects.toThrow('released'); + await expect(connected.status(invocation)).resolves.toMatchObject({ + state: 'executing', + }); + await expect(connected.cancel(invocation)).resolves.toMatchObject({ + state: 'cancel_requested', + }); + expect(settled).toBe(false); + finish.resolve(); + await expect(pending).resolves.toEqual({ executionStatus: 'success' }); + await expect(release).resolves.toBe(true); + expect(client.execute).toHaveBeenCalledOnce(); + await expect(connected.execute(invocation)).rejects.toThrow(); + } finally { + finish.resolve(); + await Promise.allSettled([pending, release]); + remote.dispose(); + local.dispose(); + } + }); + + it('pins request identity and lease while retrying cold-start preparation', async () => { + const lease = { leaseId: 'owned-lease', epoch: 1 }; + const input = { ...prepareRequest }; + let finish!: (response: Response) => void; + const first = new Promise((resolve) => { + finish = resolve; + }); + let requests = 0; + const fetchImpl = vi.fn( + async (_url: RequestInfo | URL, _options?: RequestInit) => { + if (++requests === 1) return first; + return Response.json({ protocolVersion: 1, ready: true }); + }, + ); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease, + fetch: fetchImpl, + prepareRetryDelayMs: 0, + }); + try { + const pending = remote.getToolV2Client(input); + input.workspaceCwd = '/other'; + input.tenantId = 'other'; + lease.epoch = 2; + lease.leaseId = 'other'; + finish(new Response('', { status: 503 })); + await pending; + expect(fetchImpl).toHaveBeenCalledTimes(2); + for (const [, options] of fetchImpl.mock.calls) { + expect(JSON.parse(String(options?.body))).toEqual(prepareRequest); + expect( + new Headers(options?.headers).get('X-Qwen-Managed-Lease-Id'), + ).toBe('owned-lease'); + expect( + new Headers(options?.headers).get('X-Qwen-Managed-Lease-Epoch'), + ).toBe('1'); + } + } finally { + remote.dispose(); + } + }); + + it('retains a failed remote release, permits v2 drain queries, and retries cleanup without executing twice', async () => { + let releases = 0; + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const pathname = new URL(String(input)).pathname; + if (pathname.endsWith('/v1/prepare')) + return Response.json({ protocolVersion: 1, ready: true }); + if (pathname.endsWith('/release')) { + if (++releases === 1) return new Response('', { status: 500 }); + return Response.json({ protocolVersion: 2, released: true }); + } + if (pathname.endsWith('/execute')) + throw new TypeError('lost execute response'); + return Response.json({ + protocolVersion: 2, + result: { + state: 'settled', + cancelRequested: true, + result: { executionStatus: 'cancelled' }, + }, + }); + }); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: fetchImpl, + }); + try { + const client = await remote.getToolV2Client(prepareRequest); + await expect(client.execute(invocation)).rejects.toThrow( + 'lost execute response', + ); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('HTTP 500'); + await expect( + client.prepare(invocation, 'read_file', {}), + ).rejects.toThrow(); + await expect(client.status(invocation)).resolves.toMatchObject({ + state: 'settled', + }); + await expect(client.cancel(invocation)).resolves.toMatchObject({ + state: 'settled', + }); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + await expect(client.status(invocation)).rejects.toThrow(); + expect( + fetchImpl.mock.calls.filter(([url]) => + new URL(String(url)).pathname.endsWith('/execute'), + ), + ).toHaveLength(1); + } finally { + remote.dispose(); + } + }); + + it.each([ + { method: 'execute', result: {} }, + { method: 'execute', result: { executionStatus: 'executing' } }, + { method: 'status', result: { state: 'settled' } }, + { method: 'cancel', result: { state: 'settled', result: {} } }, + ] as const)( + 'rejects a $method response without a physical terminal result: $result', + async ({ method, result }) => { + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: vi.fn(async (input: RequestInfo | URL) => + Response.json( + new URL(String(input)).pathname.endsWith('/v1/prepare') + ? { protocolVersion: 1, ready: true } + : { protocolVersion: 2, result }, + ), + ), + }); + try { + const client = await remote.getToolV2Client(prepareRequest); + await expect(client[method](invocation)).rejects.toThrow( + method === 'execute' + ? 'did not confirm physical execution' + : 'invalid invocation status', + ); + } finally { + remote.dispose(); + } + }, + ); + + it('waits for in-flight preparation before releasing its remote Session and keeps failed acquire cleanup reachable', async () => { + let finish!: (value: Response) => void; + const ready = new Promise((resolve) => { + finish = resolve; + }); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => + new URL(String(input)).pathname.endsWith('/prepare') + ? ready + : Response.json({ protocolVersion: 2, released: true }), + ); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: fetchImpl, + }); + try { + const acquired = remote.getToolV2Client(prepareRequest); + const acquireResult = acquired.catch((error: unknown) => error); + const released = remote.release(prepareRequest.sessionId, prepareRequest); + await Promise.resolve(); + expect(fetchImpl).toHaveBeenCalledOnce(); + finish(new Response('', { status: 500 })); + expect(await acquireResult).toBeInstanceOf(Error); + await expect(released).resolves.toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + remote.dispose(); + } + }); + + const invocation = { + sessionId: prepareRequest.sessionId, + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'revision', + invocationId: 'invocation', + argsDigest: 'b'.repeat(64), + }; + + it('revokes issued v2 clients when the provider is disposed or its workspace is replaced', async () => { + for (const revoke of ['dispose', 'replace'] as const) { + const runtime = fakeRuntime(); + const downstream = toolV2Client(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => downstream as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const client = await local.getToolV2Client(prepareRequest); + if (revoke === 'dispose') local.dispose(); + else + vi.mocked(runtime.registry.getByWorkspaceId).mockReturnValue({ + ...runtime.registry.getByWorkspaceId(workspaceId)!, + }); + await expect(client.execute(invocation)).rejects.toThrow(); + await expect(client.status(invocation)).rejects.toThrow(); + expect(downstream.execute).not.toHaveBeenCalled(); + expect(downstream.status).not.toHaveBeenCalled(); + await expect(client.fileHistory!.snapshot()).rejects.toThrow(); + await expect( + client.fileHistory!.checkpoint('parent-turn'), + ).rejects.toThrow(); + expect(downstream.fileHistory.snapshot).not.toHaveBeenCalled(); + expect(downstream.fileHistory.checkpoint).not.toHaveBeenCalled(); + local.dispose(); + } + }); + + it('retains a failed release for retry, blocks admission and permits only drain queries', async () => { + const runtime = fakeRuntime(); + const downstream = toolV2Client(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => downstream as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const client = await local.getToolV2Client(prepareRequest); + let failClose!: (error: Error) => void; + runtime.close.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failClose = reject; + }), + ); + const release = local.release(prepareRequest.sessionId, prepareRequest); + const concurrent = local.release(prepareRequest.sessionId, prepareRequest); + const firstResult = release.catch((error: unknown) => error); + const secondResult = concurrent.catch((error: unknown) => error); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect(client.execute(invocation)).rejects.toThrow(); + const drain = await local.getToolV2Client(prepareRequest); + await expect(drain.status(invocation)).resolves.toEqual({ + state: 'executing', + }); + await expect(drain.cancel(invocation)).resolves.toEqual({ + state: 'cancel_requested', + }); + await expect(drain.manifest()).rejects.toThrow(); + await expect(drain.fileHistory!.snapshot()).resolves.toEqual({ + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots: [], + }); + await expect(drain.fileHistory!.checkpoint('late')).rejects.toThrow(); + await expect( + drain.fileHistory!.bind({ + ownerSessionId: prepareRequest.sessionId, + ownerRuntimeSessionId: prepareRequest.sessionId, + executionCwd: workspaceCwd, + snapshots: [], + }), + ).rejects.toThrow(); + expect(downstream.fileHistory.checkpoint).not.toHaveBeenCalled(); + expect(downstream.fileHistory.bind).not.toHaveBeenCalled(); + expect(runtime.close).toHaveBeenCalledTimes(1); + const failure = new Error('close did not drain'); + failClose(failure); + expect(await firstResult).toBe(failure); + expect(await secondResult).toBe(failure); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledTimes(2); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + await expect(client.status(invocation)).rejects.toThrow(); + local.dispose(); + }); + + it('exposes v2 only on an owned listener and forwards strictly bound calls to the issued client', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const execute = vi.fn(async () => ({ + executionStatus: 'success' as const, + result: { llmContent: 'proof', returnDisplay: 'proof' }, + })); + const client = { + manifest: vi.fn(async () => ({ + ...manifest, + policyRevision: 'generation', + })), + execute, + beginTurn: vi.fn(async () => {}), + confirm: vi.fn(async () => {}), + } as unknown as ManagedToolV2Client; + runtime.bridge.getManagedToolV2Client = vi.fn(() => client); + const outer = { ...prepareRequest, protocolVersion: 2 }; + await request(workerApp(local)) + .post('/internal/managed-runtime/v2/manifest') + .set('Authorization', `Bearer ${token}`) + .send(outer) + .expect(404); + const app = express(); + app.use(express.json()); + registerManagedRuntimeWorkerRoutes(app, { + provider: local, + owned: { + type: 'boot', + version: 1, + gatewayIncarnation: 'gateway', + leaseId: 'lease', + epoch: 2, + ...prepareRequest, + token, + outputRoot: '/tmp/owned', + cliEntry: '/tmp/cli.js', + }, + authorize: (req, res, next) => { + if (req.headers.authorization !== `Bearer ${token}`) + res.sendStatus(401); + else next(); + }, + }); + const post = (operation: string, fields: Record = {}) => + request(app) + .post(`/internal/managed-runtime/v2/${operation}`) + .set('Authorization', `Bearer ${token}`) + .set('X-Qwen-Managed-Lease-Id', 'lease') + .set('X-Qwen-Managed-Lease-Epoch', '2') + .send({ ...outer, ...fields }); + for (const operation of [ + 'manifest', + 'begin-turn', + 'prepare', + 'confirmation', + 'confirm', + 'preflight', + 'execute', + 'status', + 'cancel', + ]) { + await request(app) + .post(`/internal/managed-runtime/v2/${operation}`) + .send(outer) + .expect(401); + await request(app) + .post(`/internal/managed-runtime/v2/${operation}`) + .set('Authorization', `Bearer ${token}`) + .send(outer) + .expect(409); + await post(operation, { tenantId: 'foreign' }).expect(409); + } + expect(runtime.bridge.getManagedToolV2Client).not.toHaveBeenCalled(); + await post('manifest').expect(200); + expect(runtime.bridge.getManagedToolV2Client).toHaveBeenCalledWith( + prepareRequest.sessionId, + { clientId: 'runtime-client-p8' }, + ); + const identity = { + sessionId: prepareRequest.sessionId, + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: manifest.capabilityDigest, + policyRevision: 'generation', + }; + const reference = { + ...identity, + invocationId: 'invocation-1', + argsDigest: 'b'.repeat(64), + }; + await post('execute', { protocolVersion: 1, reference }).expect(400); + await post('execute', { reference, authorized: true }).expect(400); + await post('execute', { + reference: { + ...reference, + sessionId: '550e8400-e29b-41d4-a716-446655440109', + }, + }).expect(400); + expect(execute).not.toHaveBeenCalled(); + const beginning = await post('begin-turn', { identity }).expect(200); + expect(beginning.body).toEqual({ protocolVersion: 2, result: null }); + const result = await post('execute', { reference }).expect(200); + expect(result.body).toMatchObject({ + protocolVersion: 2, + result: { executionStatus: 'success' }, + }); + expect(execute).toHaveBeenCalledExactlyOnceWith(reference); + expect(runtime.execute).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('requires the immutable owned lease and scope on all private operations', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const app = express(); + app.use(express.json()); + const owned = { + type: 'boot' as const, + version: 1 as const, + gatewayIncarnation: 'gateway', + leaseId: 'lease', + epoch: 2, + ...prepareRequest, + token, + outputRoot: '/tmp/owned', + cliEntry: '/tmp/cli.js', + }; + registerManagedRuntimeWorkerRoutes(app, { + provider: local, + owned, + authorize: (req, res, next) => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.sendStatus(401); + return; + } + next(); + }, + }); + for (const operation of [ + 'prepare', + 'manifest', + 'execute', + 'cancel', + 'release', + ]) { + const url = `${MANAGED_RUNTIME_ROUTE_PREFIX}/${operation}`; + await request(app).post(url).send(prepareRequest).expect(401); + await request(app) + .post(url) + .set('Authorization', `Bearer ${token}`) + .send(prepareRequest) + .expect(409); + await request(app) + .post(url) + .set('Authorization', `Bearer ${token}`) + .set('X-Qwen-Managed-Lease-Id', 'old') + .set('X-Qwen-Managed-Lease-Epoch', '2') + .send(prepareRequest) + .expect(409); + await request(app) + .post(url) + .set('Authorization', `Bearer ${token}`) + .set('X-Qwen-Managed-Lease-Id', 'lease') + .set('X-Qwen-Managed-Lease-Epoch', '2') + .send({ ...prepareRequest, tenantId: 'other' }) + .expect(409); + } + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + const server = createServer(app); + servers.push(server); + const port = await listen(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${port}`, + token, + lease: owned, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledOnce(); + await expect( + handle.getManifest(new AbortController().signal), + ).resolves.toEqual(manifest); + remote.dispose(); + local.dispose(); + }); + + it('keeps Gateway prepare pending until a separate Runtime worker starts', async () => { + const reservation = createServer(); + const port = await listen(reservation); + await close(reservation); + + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${port}`, + token, + prepareRetryDelayMs: 5, + prepareRetryMaxDelayMs: 10, + prepareRetryWindowMs: 2_000, + }); + const handle = remote.prepare(prepareRequest); + let ready = false; + void handle.ready.then(() => { + ready = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(ready).toBe(false); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + + const server = createServer(workerApp(local)); + servers.push(server); + await listen(server, port); + await handle.ready; + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + + await expect( + handle.getManifest(new AbortController().signal), + ).resolves.toEqual(manifest); + await expect( + handle.execute( + { + executionId: 'execution-p8', + turnId: 'turn-p8', + toolCallId: 'call-p8', + capabilityDigest: manifest.capabilityDigest, + toolName: 'read_file', + input: { file_path: 'proof.txt' }, + }, + new AbortController().signal, + ), + ).resolves.toMatchObject({ executionStatus: 'success' }); + expect(runtime.execute).toHaveBeenCalledTimes(1); + + await close(server); + local.dispose(); + vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'runtime-client-restored-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + sourcePersisted: true, + state: {} as never, + }); + const restoredLocal = new LocalManagedRuntimeProvider(runtime.registry); + const restoredServer = createServer(workerApp(restoredLocal)); + servers.push(restoredServer); + await listen(restoredServer, port); + const continuation = remote.prepare({ + ...prepareRequest, + turnKind: 'continuation', + }); + await continuation.ready; + await expect( + continuation.getManifest(new AbortController().signal), + ).resolves.toEqual(manifest); + expect(runtime.bridge.resumeSession).toHaveBeenCalledWith({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + }); + + await close(restoredServer); + restoredLocal.dispose(); + vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'runtime-client-release-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + sourcePersisted: true, + state: {} as never, + }); + const releaseLocal = new LocalManagedRuntimeProvider(runtime.registry); + const releaseServer = createServer(workerApp(releaseLocal)); + servers.push(releaseServer); + await listen(releaseServer, port); + + await expect(remote.release(prepareRequest.sessionId)).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledWith(prepareRequest.sessionId, { + clientId: 'runtime-client-release-p8', + }); + remote.dispose(); + releaseLocal.dispose(); + }); + + it('sends explicit cancellation without retrying Tool execution', async () => { + const runtime = fakeRuntime(); + let executionStarted!: () => void; + const started = new Promise((resolve) => { + executionStarted = resolve; + }); + runtime.execute.mockImplementation( + (_sessionId, _toolRequest, signal: AbortSignal) => + new Promise((_resolve, reject) => { + executionStarted(); + signal.addEventListener( + 'abort', + () => reject(signal.reason ?? new Error('aborted')), + { once: true }, + ); + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const server = createServer(workerApp(local)); + servers.push(server); + const port = await listen(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${port}`, + token, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + const controller = new AbortController(); + const execution = handle.execute( + { + executionId: 'execution-cancel-p8', + turnId: 'turn-cancel-p8', + toolCallId: 'call-cancel-p8', + capabilityDigest: manifest.capabilityDigest, + toolName: 'read_file', + input: { file_path: 'proof.txt' }, + }, + controller.signal, + ); + await started; + controller.abort(new Error('deadline')); + await expect(execution).rejects.toThrow('deadline'); + await vi.waitFor(() => expect(runtime.cancel).toHaveBeenCalledTimes(1)); + expect(runtime.execute).toHaveBeenCalledTimes(1); + remote.dispose(); + local.dispose(); + }); + + it('closes a restored Session when release races with warmup', async () => { + const runtime = fakeRuntime(); + let finishResume!: (session: { + sessionId: string; + workspaceCwd: string; + attached: true; + clientId: string; + hasActivePrompt: false; + sourceType: 'managed-gateway'; + sourceId: string; + sourcePersisted: true; + state: never; + }) => void; + vi.mocked(runtime.bridge.resumeSession).mockImplementationOnce( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const continuation = { + ...prepareRequest, + turnKind: 'continuation', + } as const; + const handle = local.prepare(continuation); + const ready = handle.ready.catch((error: unknown) => error); + const release = local.release(prepareRequest.sessionId, continuation); + + finishResume({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'runtime-client-racing-release-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + sourcePersisted: true, + state: {} as never, + }); + + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + await expect(release).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledWith(prepareRequest.sessionId, { + clientId: 'runtime-client-racing-release-p8', + }); + local.dispose(); + }); + + it('does not close a colliding Session when release races with warmup', async () => { + const runtime = fakeRuntime(); + let finishResume!: (session: { + sessionId: string; + workspaceCwd: string; + attached: true; + clientId: string; + hasActivePrompt: false; + sourceType: 'foreign-owner'; + sourceId: string; + sourcePersisted: true; + state: never; + }) => void; + vi.mocked(runtime.bridge.resumeSession).mockImplementationOnce( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const continuation = { + ...prepareRequest, + turnKind: 'continuation', + } as const; + const handle = local.prepare(continuation); + const ready = handle.ready.catch((error: unknown) => error); + const release = local.release(prepareRequest.sessionId, continuation); + + finishResume({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-client-p8', + hasActivePrompt: false, + sourceType: 'foreign-owner', + sourceId: 'foreign-session-p8', + sourcePersisted: true, + state: {} as never, + }); + + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + await expect(release).resolves.toBe(true); + expect(runtime.close).not.toHaveBeenCalled(); + expect(runtime.bridge.detachClient).toHaveBeenCalledWith( + prepareRequest.sessionId, + 'foreign-client-p8', + ); + local.dispose(); + }); + + it('retains a Session created during warmup until its failed close is retried', async () => { + const runtime = fakeRuntime(); + let finishSpawn!: ( + session: Awaited>, + ) => void; + vi.mocked(runtime.bridge.spawnOrAttach).mockReturnValueOnce( + new Promise((resolve) => { + finishSpawn = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const ready = local + .prepare(prepareRequest) + .ready.catch((error: unknown) => error); + const failure = new Error('close failed after spawn'); + runtime.close.mockRejectedValueOnce(failure); + const releasing = local + .release(prepareRequest.sessionId, prepareRequest) + .catch((error: unknown) => error); + expect(() => local.prepare(prepareRequest)).toThrow(); + expect(runtime.close).not.toHaveBeenCalled(); + finishSpawn({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: false, + clientId: 'late-client', + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + hasActivePrompt: false, + sourcePersisted: true, + }); + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + expect(await releasing).toBe(failure); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledTimes(2); + expect(runtime.close).toHaveBeenLastCalledWith(prepareRequest.sessionId, { + clientId: 'late-client', + }); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + expect(runtime.bridge.resumeSession).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('does not treat a vanished Session after failed close as proof of release', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + runtime.close + .mockRejectedValueOnce(new Error('transport lost')) + .mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('transport lost'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('not found'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + local.dispose(); + }); + + it('retains a failed cleanup of a colliding warmup attachment for retry', async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.bridge.spawnOrAttach).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-client', + sourceType: 'managed-gateway', + sourceId: 'foreign-owner', + }); + vi.mocked(runtime.bridge.detachClient).mockRejectedValueOnce( + new Error('detach failed'), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await expect(local.prepare(prepareRequest).ready).rejects.toThrow( + 'cleanup failed', + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('cleanup failed'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.bridge.detachClient).toHaveBeenCalledTimes(2); + expect(runtime.close).not.toHaveBeenCalled(); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + local.dispose(); + }); + + it('retries the same attachment cleanup when restoring an untracked release', async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-restored-client', + sourceType: 'managed-gateway', + sourceId: 'foreign-owner', + state: {}, + }); + vi.mocked(runtime.bridge.detachClient).mockRejectedValueOnce( + new Error('detach failed'), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('cleanup failed'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.bridge.detachClient).toHaveBeenCalledTimes(2); + expect(runtime.bridge.detachClient).toHaveBeenLastCalledWith( + prepareRequest.sessionId, + 'foreign-restored-client', + ); + expect(runtime.bridge.resumeSession).toHaveBeenCalledTimes(1); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + expect(runtime.close).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('rejects unauthenticated and model-bearing worker requests', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const app = workerApp(local); + await request(app) + .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) + .send(prepareRequest) + .expect(401); + const forbidden = await request(app) + .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) + .set('authorization', `Bearer ${token}`) + .send({ ...prepareRequest, prompt: 'must not cross the boundary' }); + expect(forbidden.status).toBe(400); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('treats a workspace mapping mismatch as a permanent conflict', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const response = await request(workerApp(local)) + .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) + .set('authorization', `Bearer ${token}`) + .send({ ...prepareRequest, workspaceCwd: '/tmp/wrong-workspace' }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + code: 'managed_runtime_identity_conflict', + }); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('rejects cleartext non-loopback Runtime origins', () => { + expect( + () => + new RemoteManagedRuntimeProvider({ + baseUrl: 'http://10.0.0.10:4181', + token, + }), + ).toThrow('must use HTTPS'); + }); + + it('stops reading an oversized Runtime response', async () => { + let requestCount = 0; + const fetchImpl = vi.fn(async () => { + requestCount += 1; + if (requestCount === 1) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + ready: true, + }); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(600 * 1024)); + controller.enqueue(new Uint8Array(600 * 1024)); + controller.close(); + }, + }), + ); + }) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('exceeded its size limit'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('bounds a prepare request when the Runtime never responds', async () => { + const fetchImpl = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return; + const rejectAborted = () => reject(signal.reason); + if (signal.aborted) rejectAborted(); + else signal.addEventListener('abort', rejectAborted, { once: true }); + }), + ) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + prepareRetryWindowMs: 20, + }); + + await expect(remote.prepare(prepareRequest).ready).rejects.toThrow( + 'preparation timed out', + ); + remote.prepare(prepareRequest); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('invalidates an existing remote handle when its Session is released', async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const pathname = new URL(String(input)).pathname; + if (pathname.endsWith('/prepare')) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + ready: true, + }); + } + if (pathname.endsWith('/release')) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + released: true, + }); + } + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + manifest, + }); + }) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + await expect(remote.release(prepareRequest.sessionId)).resolves.toBe(true); + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('Managed Runtime Session released.'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('invalidates an existing local handle when its Session is released', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const handle = local.prepare(prepareRequest); + await handle.ready; + await expect(local.release(prepareRequest.sessionId)).resolves.toBe(true); + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('Managed Runtime Session released.'); + expect(runtime.bridge.getManagedRuntimeToolManifest).not.toHaveBeenCalled(); + local.dispose(); + }); +}); + +describe('Managed Runtime protocol', () => { + it('accepts only versioned Prompt-free identity payloads', () => { + expect(parseManagedRuntimePrepareRequest(prepareRequest)).toEqual( + prepareRequest, + ); + expect(() => + parseManagedRuntimePrepareRequest({ + ...prepareRequest, + protocolVersion: 2, + }), + ).toThrow('unsupported'); + expect(() => + parseManagedRuntimePrepareRequest({ + ...prepareRequest, + history: [], + }), + ).toThrow(); + expect(() => + parseManagedRuntimeExecuteRequest({ + ...prepareRequest, + toolRequest: { + executionId: 'x'.repeat(129), + turnId: 'turn-p8', + toolCallId: 'call-p8', + capabilityDigest: manifest.capabilityDigest, + toolName: 'read_file', + input: {}, + }, + }), + ).toThrow(); + }); +}); diff --git a/packages/cli/src/serve/managed-runtime-provider.ts b/packages/cli/src/serve/managed-runtime-provider.ts new file mode 100644 index 00000000000..8e3c5556c07 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-provider.ts @@ -0,0 +1,1568 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, + ManagedToolV2Client, +} from '@qwen-code/acp-bridge/bridgeTypes'; +import type { ManagedToolInvocationStatus } from '@qwen-code/qwen-code-core/tools/managed-tool-runtime.js'; +import { + MANAGED_LEASE_ID_HEADER, + MANAGED_LEASE_EPOCH_HEADER, + type RuntimeFinishReason, +} from './managed-runtime-activator.js'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { + isManagedMediaOperation, + managedToolResponseMediaBytes, + MAX_MANAGED_MEDIA_RESPONSE_BYTES, +} from '../acp-integration/managed-tool-media.js'; +import { isLoopbackBind } from './loopback-binds.js'; + +interface ManagedGatewayToolRuntime { + getManifest(signal: AbortSignal): Promise; + execute( + request: BridgeManagedRuntimeToolExecuteRequest, + signal: AbortSignal, + ): Promise; +} +import { + MANAGED_RUNTIME_PROTOCOL_VERSION, + type ManagedRuntimeCancelResponse, + type ManagedRuntimeExecuteResponse, + type ManagedRuntimeManifestResponse, + type ManagedRuntimePrepareRequest, + type ManagedRuntimeReadyResponse, + sameManagedRuntimeIdentity, +} from './managed-runtime-protocol.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; + +const MAX_REMOTE_MANIFEST_BYTES = 1024 * 1024; +const MAX_REMOTE_TOOL_RESULT_BYTES = 8 * 1024 * 1024; +const MAX_REMOTE_CONTROL_RESPONSE_BYTES = 64 * 1024; +const DEFAULT_PREPARE_RETRY_WINDOW_MS = 5 * 60_000; +const DEFAULT_PREPARE_RETRY_DELAY_MS = 100; +const DEFAULT_PREPARE_RETRY_MAX_DELAY_MS = 2_000; +const CONTROL_REQUEST_TIMEOUT_MS = 5_000; +const TOOL_REQUEST_TIMEOUT_MS = 10 * 60_000; + +type BridgeSession = Awaited>; + +export interface ManagedRuntimeHandle extends ManagedGatewayToolRuntime { + readonly ready: Promise; + finish(reason: RuntimeFinishReason): void; +} + +export interface ManagedRuntimeReleaseOptions { + terminal?: boolean; +} + +export interface ManagedRuntimeToolClientContext { + readonly harnessSessionId: string; +} + +export interface ManagedRuntimeExecutionIdentity { + readonly harnessSessionId: string; + readonly runtimeSessionId: string; + readonly executionCallId: string; + readonly afterSeq?: number; +} + +export type ManagedRuntimeExecutionInspection = + | { + readonly outcome: 'known'; + readonly status: ManagedToolInvocationStatus; + } + | { readonly outcome: 'unknown' }; + +export type ManagedRuntimeUnknownResolution = + | 'confirmed_not_executed' + | 'accepted_unknown'; + +export interface ManagedRuntimeProvider { + getToolV2Client?( + request: ManagedRuntimePrepareRequest, + context?: ManagedRuntimeToolClientContext, + ): Promise; + inspectExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + reconcileExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + cancelExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + resolveExecution?( + identity: ManagedRuntimeExecutionIdentity, + resolution: ManagedRuntimeUnknownResolution, + ): Promise; + prepare(request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle; + cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise; + release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise; + dispose(): void | Promise; +} + +export class ManagedRuntimeProviderError extends Error { + constructor( + readonly code: + | 'managed_runtime_identity_conflict' + | 'managed_runtime_unavailable' + | 'managed_runtime_disposed' + | 'managed_runtime_capacity_exhausted', + message: string, + readonly retryable: boolean, + ) { + super(message); + this.name = 'ManagedRuntimeProviderError'; + } +} + +interface LocalBinding { + readonly request: ManagedRuntimePrepareRequest; + readonly runtime: WorkspaceRuntime; + readonly runtimeClientId: string; + readonly controller: AbortController; +} + +interface LocalWarmup { + readonly request: ManagedRuntimePrepareRequest; + readonly runtime: WorkspaceRuntime; + readonly controller: AbortController; + readonly promise: Promise; +} + +interface LocalRelease { + readonly request: ManagedRuntimePrepareRequest; + warmup?: LocalWarmup; + binding?: LocalBinding; + pending?: Promise; + cleanup?: () => Promise; + terminal?: boolean; + completed?: boolean; +} + +class ManagedRuntimeSessionCleanupError extends Error { + constructor( + readonly cleanup: () => Promise, + cause: unknown, + ) { + super('Managed Runtime Session cleanup failed.', { cause }); + } +} + +class ManagedRuntimeReleaseAbortError extends Error { + constructor() { + super('Managed Runtime Session released.'); + this.name = 'ManagedRuntimeReleaseAbortError'; + } +} + +function abortError(signal: AbortSignal, fallback: string): unknown { + return signal.reason instanceof Error + ? signal.reason + : new DOMException(fallback, 'AbortError'); +} + +function waitForValue(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Managed Runtime wait aborted.')); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(abortError(signal, 'Managed Runtime wait aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void pending.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +async function cleanupLocalSession( + bridge: AcpSessionBridge, + session: BridgeSession, +): Promise { + if (session.attached && session.clientId) { + await bridge.detachClient(session.sessionId, session.clientId); + return; + } + await bridge.closeSession( + session.sessionId, + session.clientId ? { clientId: session.clientId } : undefined, + ); +} + +function waitForLocalSession( + bridge: AcpSessionBridge, + pending: Promise, + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + allowAttached: boolean, +): Promise<{ session: BridgeSession; runtimeClientId: string }> { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Runtime preparation aborted.')); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + if (signal.reason instanceof ManagedRuntimeReleaseAbortError) return; + reject(abortError(signal, 'Runtime preparation aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void pending + .then( + async (session) => { + signal.removeEventListener('abort', onAbort); + const matchesIdentity = + session.sessionId === request.sessionId && + session.workspaceCwd === request.workspaceCwd && + session.sourceType === 'managed-gateway' && + session.sourceId === request.sessionId; + if ( + signal.reason instanceof ManagedRuntimeReleaseAbortError && + matchesIdentity && + (allowAttached || !session.attached) && + session.clientId + ) { + resolve({ session, runtimeClientId: session.clientId }); + return; + } + const valid = + !signal.aborted && + (allowAttached || !session.attached) && + matchesIdentity && + session.hasActivePrompt !== true && + Boolean(session.clientId); + if (!valid) { + const cleanup = () => cleanupLocalSession(bridge, session); + try { + await cleanup(); + } catch (error) { + throw new ManagedRuntimeSessionCleanupError(cleanup, error); + } + reject( + signal.aborted + ? abortError(signal, 'Runtime preparation aborted.') + : new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity did not match its binding.', + false, + ), + ); + return; + } + resolve({ session, runtimeClientId: session.clientId! }); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ) + .catch(reject); + }); +} + +function isSessionNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'session_not_found' + ); +} + +export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly bindings = new Map(); + private readonly warmups = new Map(); + private readonly releases = new Map(); + private readonly lifetime = new AbortController(); + + constructor(private readonly workspaceRegistry: WorkspaceRegistry) {} + + prepare(request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle { + this.assertNotReleasing(request.sessionId); + if (this.lifetime.signal.aborted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_disposed', + 'Managed Runtime provider is disposed.', + false, + ); + } + const runtime = this.workspaceRegistry.getByWorkspaceId( + request.workspaceId, + ); + if (!runtime || !runtime.trusted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is unavailable or untrusted.', + true, + ); + } + if (runtime.workspaceCwd !== request.workspaceCwd) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime workspace identity changed.', + false, + ); + } + const readyBinding = this.startWarmup(runtime, request); + return { + ready: readyBinding.then(() => undefined), + finish: () => {}, + getManifest: async (signal) => { + const binding = await waitForValue(readyBinding, signal); + const operationSignal = AbortSignal.any([ + signal, + binding.controller.signal, + this.lifetime.signal, + ]); + operationSignal.throwIfAborted(); + return waitForValue( + binding.runtime.bridge.getManagedRuntimeToolManifest( + request.sessionId, + { clientId: binding.runtimeClientId }, + ), + operationSignal, + ); + }, + execute: async (toolRequest, signal) => { + const binding = await waitForValue(readyBinding, signal); + const operationSignal = AbortSignal.any([ + signal, + binding.controller.signal, + this.lifetime.signal, + ]); + operationSignal.throwIfAborted(); + return binding.runtime.bridge.executeManagedRuntimeTool( + request.sessionId, + toolRequest, + operationSignal, + { clientId: binding.runtimeClientId }, + ); + }, + }; + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + ): Promise { + const releasing = this.releases.get(request.sessionId); + if (!releasing) await this.prepare(request).ready; + const binding = + releasing?.binding ?? + this.bindings.get(request.sessionId) ?? + (await this.warmups.get(request.sessionId)?.promise); + if (!binding || !sameManagedRuntimeIdentity(binding.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + const assertBinding = (allowDraining: boolean) => { + this.lifetime.signal.throwIfAborted(); + const release = this.releases.get(request.sessionId); + const retained = release?.binding === binding; + const runtime = this.workspaceRegistry.getByWorkspaceId( + request.workspaceId, + ); + if ( + (this.bindings.get(request.sessionId) !== binding && !retained) || + runtime !== binding.runtime || + runtime.workspaceCwd !== request.workspaceCwd + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + if (!allowDraining) { + this.assertNotReleasing(request.sessionId); + if (!runtime.trusted) + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is untrusted.', + false, + ); + } + if ( + !( + allowDraining && + retained && + binding.controller.signal.reason instanceof + ManagedRuntimeReleaseAbortError + ) + ) { + binding.controller.signal.throwIfAborted(); + } + }; + assertBinding(true); + const client = binding.runtime.bridge.getManagedToolV2Client( + request.sessionId, + { + clientId: binding.runtimeClientId, + }, + ); + const call = async ( + operation: () => Promise, + allowDraining = false, + ): Promise => { + assertBinding(allowDraining); + return operation(); + }; + const history = client.fileHistory; + return { + fileHistory: history + ? { + bind: (binding) => call(() => history.bind(binding)), + checkpoint: (promptId) => call(() => history.checkpoint(promptId)), + snapshot: () => call(() => history.snapshot(), true), + } + : undefined, + manifest: () => call(() => client.manifest()), + beginTurn: (identity) => call(() => client.beginTurn(identity)), + prepare: (...args) => call(() => client.prepare(...args)), + confirmation: (reference) => call(() => client.confirmation(reference)), + confirm: (reference, outcome, payload, phase) => + call(() => client.confirm(reference, outcome, payload, phase)), + preflight: (reference) => call(() => client.preflight(reference)), + execute: (reference) => call(() => client.execute(reference)), + status: (reference, afterSeq) => + call(() => client.status(reference, afterSeq), true), + cancel: (reference) => call(() => client.cancel(reference), true), + }; + } + + private assertNotReleasing(sessionId: string): void { + if (this.releases.has(sessionId)) + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Session is closing.', + false, + ); + } + + async cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise { + const binding = this.bindings.get(sessionId); + const warmup = this.warmups.get(sessionId); + const current = binding?.request ?? warmup?.request; + if (expected && current && !sameManagedRuntimeIdentity(current, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + if (!binding) return false; + const result = await binding.runtime.bridge.cancelManagedRuntimeTool( + sessionId, + executionId, + { clientId: binding.runtimeClientId }, + ); + return result.cancelled; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + const warmup = this.warmups.get(sessionId); + const binding = this.bindings.get(sessionId); + let release = this.releases.get(sessionId); + const current = release?.request ?? warmup?.request ?? binding?.request; + if ( + expected && + (expected.sessionId !== sessionId || + (current && !sameManagedRuntimeIdentity(current, expected))) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + this.lifetime.signal.throwIfAborted(); + if (!release) { + const request = current ?? expected; + if (!request) return false; + release = { request: structuredClone(request), binding, warmup }; + this.releases.set(sessionId, release); + warmup?.controller.abort(new ManagedRuntimeReleaseAbortError()); + binding?.controller.abort(new ManagedRuntimeReleaseAbortError()); + } + release.terminal ||= options?.terminal === true; + if (release.completed) return true; + if (release.pending) return release.pending; + const retained = release; + const pending = this.finishRelease(sessionId, retained) + .then((released) => { + if (this.releases.get(sessionId) === retained) { + if (this.bindings.get(sessionId) === retained.binding) + this.bindings.delete(sessionId); + if (this.warmups.get(sessionId) === retained.warmup) + this.warmups.delete(sessionId); + if (retained.terminal) { + retained.completed = true; + delete retained.binding; + delete retained.warmup; + delete retained.cleanup; + } else this.releases.delete(sessionId); + } + return released; + }) + .finally(() => { + retained.pending = undefined; + }); + retained.pending = pending; + return pending; + } + + private async finishRelease( + sessionId: string, + release: LocalRelease, + ): Promise { + if (release.cleanup) { + await release.cleanup(); + return true; + } + if (!release.binding && release.warmup) { + try { + release.binding = await release.warmup.promise; + } catch (error) { + if (error instanceof ManagedRuntimeReleaseAbortError) return true; + if (error instanceof ManagedRuntimeSessionCleanupError) + release.cleanup = error.cleanup; + throw error; + } + } + if (!release.binding) { + this.lifetime.signal.throwIfAborted(); + const expected = release.request; + const runtime = this.workspaceRegistry.getByWorkspaceId( + expected.workspaceId, + ); + if (!runtime || !runtime.trusted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is unavailable or untrusted.', + true, + ); + } + if (runtime.workspaceCwd !== expected.workspaceCwd) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime workspace identity changed.', + false, + ); + } + try { + const restored = await waitForLocalSession( + runtime.bridge, + runtime.bridge.resumeSession({ + sessionId, + workspaceCwd: expected.workspaceCwd, + sourceType: 'managed-gateway', + sourceId: sessionId, + }), + expected, + this.lifetime.signal, + true, + ); + const controller = new AbortController(); + controller.abort(new ManagedRuntimeReleaseAbortError()); + release.binding = { + request: expected, + runtime, + runtimeClientId: restored.runtimeClientId, + controller, + }; + } catch (error) { + if (error instanceof ManagedRuntimeSessionCleanupError) + release.cleanup = error.cleanup; + if (isSessionNotFound(error)) return true; + throw error; + } + } + await release.binding.runtime.bridge.closeSession(sessionId, { + clientId: release.binding.runtimeClientId, + }); + return true; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime provider disposed.')); + for (const warmup of this.warmups.values()) { + warmup.controller.abort(new Error('Managed Runtime provider disposed.')); + } + for (const binding of this.bindings.values()) { + binding.controller.abort(new Error('Managed Runtime provider disposed.')); + } + this.warmups.clear(); + this.bindings.clear(); + this.releases.clear(); + } + + private startWarmup( + runtime: WorkspaceRuntime, + request: ManagedRuntimePrepareRequest, + ): Promise { + const existingWarmup = this.warmups.get(request.sessionId); + if (existingWarmup) { + if ( + existingWarmup.runtime !== runtime || + !sameManagedRuntimeIdentity(existingWarmup.request, request) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime warmup changed Session identity.', + false, + ); + } + return waitForValue( + existingWarmup.promise, + AbortSignal.any([ + this.lifetime.signal, + existingWarmup.controller.signal, + ]), + ); + } + + const controller = new AbortController(); + const signal = AbortSignal.any([this.lifetime.signal, controller.signal]); + const promise = this.prepareBinding(runtime, request, signal, controller); + const warmup: LocalWarmup = { request, runtime, controller, promise }; + this.warmups.set(request.sessionId, warmup); + void promise.then( + (binding) => { + if (signal.aborted || this.warmups.get(request.sessionId) !== warmup) { + return; + } + this.warmups.delete(request.sessionId); + this.bindings.set(request.sessionId, binding); + }, + (error: unknown) => { + if ( + this.warmups.get(request.sessionId) === warmup && + !(error instanceof ManagedRuntimeSessionCleanupError) + ) { + this.warmups.delete(request.sessionId); + } + }, + ); + return waitForValue(promise, signal); + } + + private async prepareBinding( + runtime: WorkspaceRuntime, + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + controller: AbortController, + ): Promise { + const cached = this.bindings.get(request.sessionId); + if (cached) { + if (!sameManagedRuntimeIdentity(cached.request, request)) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime live Session identity changed.', + false, + ); + } + if (cached.runtime !== runtime) { + this.forgetBinding(request.sessionId, cached); + } else { + let summary: + | ReturnType + | undefined; + try { + summary = runtime.bridge.getSessionSummary(request.sessionId); + } catch { + this.forgetBinding(request.sessionId, cached); + } + if ( + summary && + (summary.workspaceCwd !== request.workspaceCwd || + summary.sourceType !== 'managed-gateway' || + summary.sourceId !== request.sessionId) + ) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime live Session identity changed.', + false, + ); + } + if (summary) { + if (summary.hasActivePrompt) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime already has an active Prompt.', + false, + ); + } + try { + runtime.bridge.recordHeartbeat(request.sessionId, { + clientId: cached.runtimeClientId, + }); + return cached; + } catch { + this.forgetBinding(request.sessionId, cached); + } + } + } + } + + const waitForSession = ( + pending: Promise, + allowAttached: boolean, + ) => + waitForLocalSession( + runtime.bridge, + pending, + request, + signal, + allowAttached, + ); + let result: Awaited>; + if (request.turnKind === 'continuation') { + try { + result = await waitForSession( + runtime.bridge.resumeSession({ + sessionId: request.sessionId, + workspaceCwd: request.workspaceCwd, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + true, + ); + } catch (error) { + if (!isSessionNotFound(error)) throw error; + result = await waitForSession( + runtime.bridge.spawnOrAttach({ + workspaceCwd: request.workspaceCwd, + sessionScope: 'thread', + sessionId: request.sessionId, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + false, + ); + } + } else { + result = await waitForSession( + runtime.bridge.spawnOrAttach({ + workspaceCwd: request.workspaceCwd, + sessionScope: 'thread', + sessionId: request.sessionId, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + false, + ); + } + return { + request: structuredClone(request), + runtime, + runtimeClientId: result.runtimeClientId, + controller, + }; + } + + private forgetBinding(sessionId: string, binding: LocalBinding): void { + if (this.bindings.get(sessionId) === binding) { + this.bindings.delete(sessionId); + } + binding.controller.abort(new Error('Managed Runtime binding changed.')); + } +} + +interface RemoteProviderEntry { + request: ManagedRuntimePrepareRequest; + readonly controller: AbortController; + readonly ready: Promise; + releasing?: boolean; + release?: Promise; + v2?: ManagedToolV2Client; + terminal?: boolean; +} + +class RemoteResponseError extends Error { + constructor(readonly status: number) { + super(`Managed Runtime returned HTTP ${status}.`); + this.name = 'RemoteResponseError'; + } +} + +export interface RemoteManagedRuntimeProviderOptions { + readonly baseUrl: string; + readonly lease?: { readonly leaseId: string; readonly epoch: number }; + readonly token: string; + readonly fetch?: typeof fetch; + readonly prepareRetryWindowMs?: number; + readonly prepareRetryDelayMs?: number; + readonly prepareRetryMaxDelayMs?: number; +} + +function resolveRemoteBaseUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Managed Runtime URL is invalid.'); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + throw new Error('Managed Runtime URL must be an HTTP(S) origin.'); + } + if (url.protocol === 'http:' && !isLoopbackBind(url.hostname)) { + throw new Error( + 'Managed Runtime URL must use HTTPS outside the loopback interface.', + ); + } + url.pathname = '/'; + return url; +} + +function delay(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Managed Runtime retry aborted.')); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + timer.unref(); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + reject(abortError(signal, 'Managed Runtime retry aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +function validRetryOption( + value: number | undefined, + fallback: number, + minimum: number, +): number { + return value !== undefined && Number.isFinite(value) && value >= minimum + ? value + : fallback; +} + +async function readBoundedResponseText( + response: Response, + maxResponseBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + totalBytes += chunk.value.byteLength; + if (totalBytes > maxResponseBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('Managed Runtime response exceeded its size limit.'); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +export class RemoteManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly baseUrl: URL; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + private readonly retryWindowMs: number; + private readonly retryDelayMs: number; + private readonly retryMaxDelayMs: number; + private readonly entries = new Map(); + private readonly closedSessions = new Map< + string, + ManagedRuntimePrepareRequest + >(); + private readonly lifetime = new AbortController(); + private readonly lease?: { readonly leaseId: string; readonly epoch: number }; + + constructor(options: RemoteManagedRuntimeProviderOptions) { + this.lease = options.lease && { ...options.lease }; + this.baseUrl = resolveRemoteBaseUrl(options.baseUrl); + this.token = options.token.trim(); + if (!this.token) throw new Error('Managed Runtime token is required.'); + this.fetchImpl = options.fetch ?? fetch; + this.retryWindowMs = validRetryOption( + options.prepareRetryWindowMs, + DEFAULT_PREPARE_RETRY_WINDOW_MS, + 0, + ); + this.retryDelayMs = validRetryOption( + options.prepareRetryDelayMs, + DEFAULT_PREPARE_RETRY_DELAY_MS, + 0, + ); + this.retryMaxDelayMs = validRetryOption( + options.prepareRetryMaxDelayMs, + DEFAULT_PREPARE_RETRY_MAX_DELAY_MS, + 1, + ); + } + + prepare( + request: ManagedRuntimePrepareRequest, + retryWindowMs = this.retryWindowMs, + ): ManagedRuntimeHandle { + request = structuredClone(request); + if (this.lifetime.signal.aborted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_disposed', + 'Managed Runtime provider is disposed.', + false, + ); + } + const closed = this.closedSessions.get(request.sessionId); + if (closed) { + throw new ManagedRuntimeProviderError( + sameManagedRuntimeIdentity(closed, request) + ? 'managed_runtime_unavailable' + : 'managed_runtime_identity_conflict', + 'Managed Runtime Session is permanently closed.', + false, + ); + } + let entry = this.entries.get(request.sessionId); + if (entry && !sameManagedRuntimeIdentity(entry.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + if (entry?.releasing) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Session is being released.', + false, + ); + } + if (entry) { + entry.request = structuredClone(request); + } + if (!entry) { + const controller = new AbortController(); + // Release must await preparation before sending close. Aborting only its + // HTTP response would leave a late-created Session without an owner. + const signal = this.lifetime.signal; + const ready = this.prepareRemote(request, signal, retryWindowMs); + entry = { request: structuredClone(request), controller, ready }; + this.entries.set(request.sessionId, entry); + void ready.catch(() => { + if ( + this.entries.get(request.sessionId) === entry && + !entry?.v2 && + !entry?.releasing + ) { + this.entries.delete(request.sessionId); + } + }); + } + const active = entry; + return { + ready: active.ready, + finish: () => {}, + getManifest: async (signal) => { + const operationSignal = AbortSignal.any([ + signal, + active.controller.signal, + this.lifetime.signal, + ]); + await waitForValue(active.ready, operationSignal); + const response = + await this.postIdempotentJson( + 'manifest', + active.request, + operationSignal, + MAX_REMOTE_MANIFEST_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + !response.manifest + ) { + throw new Error( + 'Managed Runtime returned an invalid manifest response.', + ); + } + return response.manifest; + }, + execute: async (toolRequest, signal) => { + const operationSignal = AbortSignal.any([ + signal, + active.controller.signal, + this.lifetime.signal, + ]); + await waitForValue(active.ready, operationSignal); + try { + const response = await this.postJson( + 'execute', + { ...active.request, toolRequest }, + operationSignal, + MAX_REMOTE_TOOL_RESULT_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + !response.result + ) { + throw new Error( + 'Managed Runtime returned an invalid Tool execution response.', + ); + } + return response.result; + } catch (error) { + if (signal.aborted) { + void this.cancel(request.sessionId, toolRequest.executionId).catch( + () => undefined, + ); + } + throw error; + } + }, + }; + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + ): Promise { + if (!this.lease) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Tool v2 requires an owned Runtime lease.', + false, + ); + } + const current = this.entries.get(request.sessionId); + if (!current?.releasing) this.prepare(request); + const entry = this.entries.get(request.sessionId); + if (!entry || !sameManagedRuntimeIdentity(entry.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + entry.v2 ??= this.createToolV2Client(entry); + await entry.ready; + this.lifetime.signal.throwIfAborted(); + return entry.v2; + } + + private createToolV2Client(entry: RemoteProviderEntry): ManagedToolV2Client { + const call = async ( + operation: string, + params: Record = {}, + allowDraining = false, + ): Promise => { + // Leaf modules, not the core barrel: a dynamic import of the barrel + // keeps every core export, and with it the encoding tables and other + // runtime the serve fast path must not load + // (scripts/check-serve-fast-path-bundle.js). + const [{ managedToolDigest }, { MANAGED_TOOL_FILE_HISTORY_MAX_BYTES }] = + await Promise.all([ + import('@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'), + import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ), + ]); + this.lifetime.signal.throwIfAborted(); + if (this.entries.get(entry.request.sessionId) !== entry) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + if (!allowDraining) entry.controller.signal.throwIfAborted(); + const identity = params['identity'] ?? params['reference']; + if ( + identity !== undefined && + (identity as { sessionId?: unknown }).sessionId !== + entry.request.sessionId + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime invocation belongs to another Session.', + false, + ); + } + const body = { ...entry.request, protocolVersion: 2, ...params }; + managedToolDigest( + body, + operation === 'bind-history' + ? MANAGED_TOOL_FILE_HISTORY_MAX_BYTES + : 1024 * 1024, + ); + const response = await this.postJson<{ + protocolVersion?: unknown; + result?: T; + }>( + operation, + body, + AbortSignal.any([ + this.lifetime.signal, + // Release closes admission, but an admitted execute must retain its + // physical result until the worker has drained the operation. + ...(allowDraining || operation === 'execute' + ? [] + : [entry.controller.signal]), + AbortSignal.timeout(TOOL_REQUEST_TIMEOUT_MS), + ]), + operation === 'manifest' + ? MAX_REMOTE_MANIFEST_BYTES + : isManagedMediaOperation(operation) + ? MAX_MANAGED_MEDIA_RESPONSE_BYTES + : MAX_REMOTE_TOOL_RESULT_BYTES, + 2, + ); + const empty = operation === 'begin-turn' || operation === 'confirm'; + if ( + response.protocolVersion !== 2 || + (empty + ? response.result !== null + : !response.result || + typeof response.result !== 'object' || + Array.isArray(response.result)) + ) { + throw new Error('Managed Runtime returned an invalid v2 response.'); + } + const validResult = (value: unknown) => + value !== null && + typeof value === 'object' && + ['not_started', 'success', 'error', 'cancelled'].includes( + (value as { executionStatus?: string }).executionStatus ?? '', + ); + if (operation === 'execute' && !validResult(response.result)) { + throw new Error('Managed Runtime did not confirm physical execution.'); + } + if (operation === 'status' || operation === 'cancel') { + const status = response.result as { state?: string; result?: unknown }; + if ( + !['prepared', 'executing', 'cancel_requested', 'settled'].includes( + status.state ?? '', + ) || + (status.state === 'settled' && !validResult(status.result)) + ) { + throw new Error( + 'Managed Runtime returned an invalid invocation status.', + ); + } + } + return response.result as T; + }; + return { + fileHistory: { + bind: async (binding) => { + const [ + { + parseManagedToolFileHistoryBinding, + parseManagedToolFileHistoryState, + }, + { ManagedToolProtocolError }, + ] = await Promise.all([ + import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ), + import('@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'), + ]); + const parsed = parseManagedToolFileHistoryBinding(binding); + const state = parseManagedToolFileHistoryState( + await call('bind-history', { binding: parsed }), + ); + if (state.ownerSessionId !== parsed.ownerSessionId) + throw new ManagedToolProtocolError( + 'Managed file history owner changed.', + ); + return state; + }, + checkpoint: async (promptId) => { + const { + parseManagedToolFileHistoryPromptId, + parseManagedToolFileHistoryState, + } = await import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ); + return parseManagedToolFileHistoryState( + await call('checkpoint', { + promptId: parseManagedToolFileHistoryPromptId(promptId), + }), + ); + }, + snapshot: async () => { + const { parseManagedToolFileHistoryState } = await import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ); + return parseManagedToolFileHistoryState( + await call('history', {}, true), + ); + }, + }, + manifest: () => call('manifest'), + beginTurn: async (identity) => { + await call('begin-turn', { identity }); + }, + prepare: (identity, toolName, input, modification, mediaContext) => + call('prepare', { + identity, + toolName, + input, + ...(modification === undefined ? {} : { modification }), + ...(mediaContext === undefined ? {} : { mediaContext }), + }), + confirmation: (reference) => call('confirmation', { reference }), + confirm: async (reference, outcome, payload, phase) => { + await call('confirm', { + reference, + outcome, + ...(payload === undefined ? {} : { payload }), + ...(phase === undefined ? {} : { phase }), + }); + }, + preflight: (reference) => call('preflight', { reference }), + execute: (reference) => call('execute', { reference }), + status: (reference, afterSeq) => + call( + 'status', + { + reference, + ...(afterSeq === undefined ? {} : { afterSeq }), + }, + true, + ), + cancel: (reference) => call('cancel', { reference }, true), + }; + } + + async cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise { + const entry = this.entries.get(sessionId); + if (!entry || this.lifetime.signal.aborted) return false; + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + const response = await this.postJson( + 'cancel', + { ...entry.request, executionId }, + AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + ); + if (response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION) { + throw new Error('Managed Runtime returned an invalid cancel response.'); + } + return response.cancelled === true; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + let entry = this.entries.get(sessionId); + const closed = this.closedSessions.get(sessionId); + const terminal = options?.terminal === true || entry?.v2 !== undefined; + if (terminal && !this.lease) { + throw new Error( + 'Terminal Session release requires an owned Runtime lease.', + ); + } + if ( + expected && + (expected.sessionId !== sessionId || + (closed && !sameManagedRuntimeIdentity(closed, expected))) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + this.lifetime.signal.throwIfAborted(); + if (closed) return true; + if (!entry) { + if (!expected) return false; + entry = { + request: structuredClone(expected), + controller: new AbortController(), + ready: Promise.resolve(), + }; + this.entries.set(sessionId, entry); + } + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + entry.terminal ||= terminal; + entry.releasing = true; + entry.controller.abort(new Error('Managed Runtime Session released.')); + if (entry.release) return entry.release; + const retained = entry; + const release = (async () => { + await retained.ready.catch(() => {}); + this.lifetime.signal.throwIfAborted(); + let sentTerminal: boolean; + do { + sentTerminal = retained.terminal === true; + const protocolVersion = sentTerminal ? 2 : 1; + const response = await this.postIdempotentJson<{ + protocolVersion: unknown; + released: unknown; + }>( + 'release', + { ...retained.request, protocolVersion }, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + ]), + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + protocolVersion, + ); + if ( + response.protocolVersion !== protocolVersion || + response.released !== true + ) { + throw new Error('Managed Runtime did not confirm Session release.'); + } + } while (retained.terminal && !sentTerminal); + if (sentTerminal) { + this.closedSessions.set(sessionId, retained.request); + } + if (this.entries.get(sessionId) === retained) + this.entries.delete(sessionId); + return true; + })().finally(() => { + retained.release = undefined; + }); + retained.release = release; + return release; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime provider disposed.')); + for (const entry of this.entries.values()) { + entry.controller.abort(new Error('Managed Runtime provider disposed.')); + } + this.entries.clear(); + this.closedSessions.clear(); + } + + private async prepareRemote( + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + retryWindowMs: number, + ): Promise { + const startedAt = Date.now(); + let retryDelayMs = this.retryDelayMs; + const deadline = new AbortController(); + const deadlineTimer = setTimeout( + () => + deadline.abort( + new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime preparation timed out.', + true, + ), + ), + retryWindowMs, + ); + deadlineTimer.unref(); + const prepareSignal = AbortSignal.any([signal, deadline.signal]); + try { + while (true) { + prepareSignal.throwIfAborted(); + try { + const response = await this.postJson( + 'prepare', + request, + prepareSignal, + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + response.ready !== true + ) { + throw new Error( + 'Managed Runtime returned an invalid ready response.', + ); + } + return; + } catch (error) { + if (prepareSignal.aborted) { + throw abortError(prepareSignal, 'Runtime preparation aborted.'); + } + const retryable = + error instanceof TypeError || + (error instanceof RemoteResponseError && + (error.status === 429 || error.status === 503)); + if (!retryable || Date.now() - startedAt >= retryWindowMs) { + throw error; + } + await delay(retryDelayMs, prepareSignal); + retryDelayMs = Math.min( + Math.max(retryDelayMs * 2, 1), + this.retryMaxDelayMs, + ); + } + } + } finally { + clearTimeout(deadlineTimer); + } + } + + private async postJson( + operation: string, + body: unknown, + signal: AbortSignal, + maxResponseBytes: number, + protocolVersion: 1 | 2 = 1, + ): Promise { + const response = await this.fetchImpl( + new URL( + `internal/managed-runtime/v${protocolVersion}/${operation}`, + this.baseUrl, + ), + { + method: 'POST', + headers: { + authorization: `Bearer ${this.token}`, + 'content-type': 'application/json', + ...(this.lease + ? { + [MANAGED_LEASE_ID_HEADER]: this.lease.leaseId, + [MANAGED_LEASE_EPOCH_HEADER]: String(this.lease.epoch), + } + : {}), + }, + body: JSON.stringify(body), + signal, + }, + ); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new RemoteResponseError(response.status); + } + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) { + await response.body?.cancel().catch(() => undefined); + throw new Error('Managed Runtime response exceeded its size limit.'); + } + const text = await readBoundedResponseText(response, maxResponseBytes); + let parsed: T; + try { + parsed = JSON.parse(text) as T; + } catch { + throw new Error('Managed Runtime returned invalid JSON.'); + } + if (protocolVersion === 2 && isManagedMediaOperation(operation)) { + const mediaBytes = managedToolResponseMediaBytes( + (parsed as { result?: unknown } | null)?.result, + operation, + ); + if (Buffer.byteLength(text) - mediaBytes > MAX_REMOTE_TOOL_RESULT_BYTES) { + throw new Error( + 'Managed Runtime control response exceeded its size limit.', + ); + } + } + return parsed; + } + + private async postIdempotentJson( + operation: string, + body: unknown, + signal: AbortSignal, + maxResponseBytes: number, + protocolVersion: 1 | 2 = 1, + ): Promise { + try { + return await this.postJson( + operation, + body, + signal, + maxResponseBytes, + protocolVersion, + ); + } catch (error) { + const retryable = + !signal.aborted && + (error instanceof TypeError || + (error instanceof RemoteResponseError && error.status === 503)); + if (!retryable) throw error; + return this.postJson( + operation, + body, + signal, + maxResponseBytes, + protocolVersion, + ); + } + } +} + +export type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, +}; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 16015b275d4..868e5586e12 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -27,6 +27,8 @@ import express, { type Response, } from 'express'; import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; +import { validateHostedHarnessProfile } from './hosted-harness-profile.js'; +import { HOSTED_HARNESS_CAPABILITY_DIGEST_ENV } from './hosted-harness-contract.js'; import { isWithinRoot } from '../config/path-comparison.js'; import { readSshWorkspace } from './ssh-workspace-store.js'; import { @@ -3362,6 +3364,33 @@ async function runQwenServeImpl( deps.bootSettings ?? {}, 'serve / ACP / web terminals', ); + + const hostedProfile = optsIn.profile ?? 'default'; + if (hostedProfile === 'hosted-harness') { + optsIn.managedRuntimeBrokerUrl ??= + process.env['QWEN_RUNTIME_BROKER_URL']; + optsIn.managedRuntimeBrokerToken ??= + process.env['QWEN_RUNTIME_BROKER_TOKEN']; + optsIn.hostedHarnessCapabilityDigest ??= + process.env[HOSTED_HARNESS_CAPABILITY_DIGEST_ENV]; + validateHostedHarnessProfile( + { ...optsIn, profile: hostedProfile }, + { + serverToken: QWEN_SERVER_TOKEN_ENV, + brokerUrl: 'QWEN_RUNTIME_BROKER_URL', + brokerToken: 'QWEN_RUNTIME_BROKER_TOKEN', + capabilityDigest: HOSTED_HARNESS_CAPABILITY_DIGEST_ENV, + }, + ); + } else if ( + optsIn.managedRuntimeBrokerUrl !== undefined || + optsIn.managedRuntimeBrokerToken !== undefined || + optsIn.hostedHarnessCapabilityDigest !== undefined + ) { + throw new Error( + 'Managed Runtime Broker options require --profile hosted-harness.', + ); + } const baseEnv: NodeJS.ProcessEnv = { ...process.env }; const launchMemoryProjectScopeValue = baseEnv['QWEN_CODE_MEMORY_PROJECT_SCOPE']; diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index aaffcf4ccea..dfbae6b4316 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -44,6 +44,7 @@ import type { * implemented. */ export type ServeMode = 'http-bridge' | 'native'; +export type ServeProfile = 'default' | 'hosted-harness'; export type ServeChannelSelection = | { mode: 'all' } @@ -58,6 +59,8 @@ export interface ChannelWebhookConfigSource { export interface ServeOptions { hostname: string; port: number; + /** Deployment boundary for provider selection and exposed surfaces. */ + profile?: ServeProfile; /** * Bearer token required on every request. Optional when bound to loopback * (developer convenience). On a non-loopback bind with neither this option @@ -339,6 +342,21 @@ export interface ServeOptions { * `POST /session/:id/prompt` from receipt to completion. */ promptDeadlineMs?: number; + /** Mount the experimental resident Managed Gateway and Tool Runtime path. */ + experimentalManagedAgents?: boolean; + /** Expose the private authenticated Tool-only Runtime worker protocol. */ + experimentalManagedRuntimeWorker?: boolean; + experimentalManagedRuntimeAutoLocal?: boolean; + /** Use a remote Runtime worker origin instead of the local provider. */ + experimentalManagedRuntimeUrl?: string; + /** Bearer credential used only for the remote Runtime worker. */ + experimentalManagedRuntimeToken?: string; + /** Java Runtime Broker origin used only by the Hosted Harness profile. */ + managedRuntimeBrokerUrl?: string; + /** Service credential used only for Harness-to-Broker calls. */ + managedRuntimeBrokerToken?: string; + /** Deployment-generated digest for the Hosted Harness private contract. */ + hostedHarnessCapabilityDigest?: string; /** * Per-SSE-connection idle deadline. */ @@ -424,6 +442,15 @@ export interface ServeOptions { * * `v` is the wire schema version; bumped only on breaking frame changes. */ +export interface HostedHarnessCapabilities { + readonly protocolVersions: { + readonly current: 1; + readonly supported: readonly [1]; + }; + readonly bootId: string; + readonly capabilityDigest: string; +} + export interface CapabilitiesEnvelope { v: 1; /** @@ -438,6 +465,8 @@ export interface CapabilitiesEnvelope { qwenCodeVersion?: string; /** Process-wide live-state polling interval in milliseconds; older daemons omit it. */ sessionLiveStatePollIntervalMs?: number; + /** Private Hosted Harness handshake. Present only in that deployment profile. */ + hostedHarness?: HostedHarnessCapabilities; mode: ServeMode; features: string[]; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 939ade9351a..0667a9d77a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -150,6 +150,15 @@ export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; export * from './tools/client-mcp-registrar.js'; export * from './tools/modifiable-tool.js'; +export type { + ManagedToolExecutionResult, + ManagedToolInvocationStatus, + ManagedToolV2Client, +} from './tools/managed-tool-runtime.js'; +export { + managedToolDigest, + type ManagedToolInvocationReference, +} from './tools/managed-tool-protocol.js'; export * from './managed-runtime/managed-session-records.js'; // Selective re-exports of types/utilities from tool files (avoids loading full tool modules) diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.test.ts b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts new file mode 100644 index 00000000000..d2c03b92e31 --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + captureManagedToolExecutionContext, + parseManagedToolFileHistoryBinding, + type ManagedToolExecutionContext, + parseManagedToolFileHistoryPromptId, + parseManagedToolFileHistoryState, +} from './managed-tool-file-history-protocol.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; +import type { Config } from '../config/config.js'; + +const ownerSessionId = '550e8400-e29b-41d4-a716-446655440108'; +const ownerRuntimeSessionId = '550e8400-e29b-41d4-a716-446655440109'; +const timestamp = '2026-09-09T00:00:00.000Z'; +const backup = { + backupFileName: '0123456789abcdef@v1', + version: 1, + backupTime: timestamp, +}; +const snapshot = { + promptId: 'parent-turn', + timestamp, + trackedFileBackups: { 'proof.txt': backup }, +}; +const binding = { + ownerSessionId, + ownerRuntimeSessionId, + executionCwd: path.resolve('managed-child'), + snapshots: [snapshot], +}; + +describe('managed file history protocol', () => { + const context: ManagedToolExecutionContext = { + workspaceDirectories: [ + binding.executionCwd, + path.resolve('additional-workspace'), + ], + memoryBaseDir: path.resolve('gateway-memory'), + lsToolEnabled: true, + fileFilteringOptions: { + respectGitIgnore: false, + respectQwenIgnore: true, + customIgnoreFiles: [], + }, + }; + + it('copies the exact execution scope without adding directories or default ignore files', () => { + const input = { ...binding, executionContext: structuredClone(context) }; + const parsed = parseManagedToolFileHistoryBinding(input); + expect(parsed).toEqual(input); + input.executionContext.workspaceDirectories.reverse(); + input.executionContext.fileFilteringOptions.customIgnoreFiles.push( + '.laterignore', + ); + expect(parsed.executionContext).toEqual(context); + expect( + parseManagedToolFileHistoryBinding({ + ...binding, + executionContext: { ...context, workspaceDirectories: [] }, + }).executionContext?.workspaceDirectories, + ).toEqual([]); + }); + + it.each([null, 17.5])( + 'round trips explicit output limits (%s) and backend preferences', + (chars) => { + const config = { + getWorkspaceContext: () => ({ + getDirectories: () => context.workspaceDirectories, + }), + getMemoryBaseDir: () => context.memoryBaseDir, + isLsToolEnabled: () => context.lsToolEnabled, + getFileFilteringOptions: () => context.fileFilteringOptions, + getUseRipgrep: () => false, + getUseBuiltinRipgrep: () => true, + getTruncateToolOutputThreshold: () => chars ?? Infinity, + getTruncateToolOutputLines: () => Infinity, + isTruncateToolOutputThresholdExplicit: () => true, + } as unknown as Config; + const captured = captureManagedToolExecutionContext(config); + expect(captured.grepOptions).toEqual({ + useRipgrep: false, + useBuiltinRipgrep: true, + }); + expect(captured.outputLimits).toEqual({ + chars, + lines: null, + charsExplicit: true, + }); + const parsed = parseManagedToolFileHistoryBinding( + JSON.parse(JSON.stringify({ ...binding, executionContext: captured })), + ); + expect(parsed.executionContext).toEqual(captured); + captured.grepOptions!.useRipgrep = true; + captured.outputLimits!.lines = 3; + expect(parsed.executionContext?.grepOptions?.useRipgrep).toBe(false); + expect(parsed.executionContext?.outputLimits?.lines).toBeNull(); + }, + ); + + it.each([ + { grepOptions: {} }, + { grepOptions: { useRipgrep: 'true', useBuiltinRipgrep: true } }, + { grepOptions: { useRipgrep: true, useBuiltinRipgrep: true, extra: true } }, + { outputLimits: { chars: 0, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: 1, lines: -1, charsExplicit: false } }, + { outputLimits: { chars: Infinity, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: NaN, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: null, lines: null } }, + { outputLimits: { chars: null, lines: null, charsExplicit: 'false' } }, + ])('rejects invalid search preferences and limits', (extra) => { + expect(() => + parseManagedToolFileHistoryBinding({ + ...binding, + executionContext: { ...context, ...extra }, + }), + ).toThrow(); + }); + + it.each([ + { ...context, extra: true }, + { ...context, workspaceDirectories: ['relative'] }, + { + ...context, + workspaceDirectories: [binding.executionCwd, binding.executionCwd], + }, + { ...context, memoryBaseDir: 'relative' }, + { ...context, lsToolEnabled: 'true' }, + { + ...context, + fileFilteringOptions: { ...context.fileFilteringOptions, extra: true }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + respectGitIgnore: 'true', + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['../parent'], + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['.qwenignore'], + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['.custom', '.custom'], + }, + }, + ])('rejects malformed execution context', (executionContext) => { + expect(() => + parseManagedToolFileHistoryBinding({ ...binding, executionContext }), + ).toThrow(); + }); + + it('accepts accumulated history above 1 MiB without widening tool input limits', () => { + const trackedFileBackups = Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [ + `${'directory/'.repeat(5)}file-${i}.txt`, + backup, + ]), + ); + const snapshots = Array.from({ length: 100 }, (_, i) => ({ + promptId: `turn-${i}`, + timestamp, + trackedFileBackups, + })); + const state = { ownerSessionId, revision: 100, snapshots }; + expect(Buffer.byteLength(JSON.stringify(state))).toBeGreaterThan( + 1024 * 1024, + ); + expect( + parseManagedToolFileHistoryBinding({ ...binding, snapshots }).snapshots, + ).toEqual(snapshots); + expect(parseManagedToolFileHistoryState(state)).toEqual(state); + expect(() => managedToolDigest(state, 1024 * 1024)).toThrow('size limit'); + }); + + it('copies valid bindings and preserves empty snapshots and explicit failed backups', () => { + const input = { + ...binding, + snapshots: [ + snapshot, + { promptId: 'next', timestamp, trackedFileBackups: {} }, + { + promptId: 'failed', + timestamp, + trackedFileBackups: { + [path.resolve('outside.txt')]: { + ...backup, + backupFileName: null, + failed: true, + }, + }, + }, + ], + }; + const parsed = parseManagedToolFileHistoryBinding(input); + expect(parsed).toEqual(input); + expect(parsed.snapshots).not.toBe(input.snapshots); + expect(parsed.snapshots[0].trackedFileBackups['proof.txt']).not.toBe( + backup, + ); + expect(() => managedToolDigest(parsed)).not.toThrow(); + expect( + parseManagedToolFileHistoryState({ + ownerSessionId, + revision: 0, + snapshots: [], + }), + ).toEqual({ ownerSessionId, revision: 0, snapshots: [] }); + }); + + it.each([ + { ...binding, extra: true }, + { ...binding, ownerSessionId: ownerSessionId.toUpperCase() }, + { ...binding, ownerRuntimeSessionId: '../another-owner' }, + { ...binding, executionCwd: 'relative' }, + { ...binding, executionCwd: `${binding.executionCwd}${path.sep}..` }, + { ...binding, snapshots: undefined }, + { ...binding, snapshots: [snapshot, snapshot] }, + { ...binding, snapshots: [{ ...snapshot, extra: true }] }, + { ...binding, snapshots: [{ ...snapshot, timestamp: 'invalid' }] }, + { ...binding, snapshots: [{ ...snapshot, trackedFileBackups: [] }] }, + { + ...binding, + snapshots: [ + { ...snapshot, trackedFileBackups: { '../outside': backup } }, + ], + }, + ])('rejects malformed bindings before filesystem use', (input) => { + expect(() => parseManagedToolFileHistoryBinding(input)).toThrow(); + }); + + it.each([ + { ...backup, extra: true }, + { ...backup, backupFileName: '../other-session/backup' }, + { ...backup, backupFileName: '..\\other-session\\backup' }, + { ...backup, backupFileName: '.' }, + { ...backup, version: -1 }, + { ...backup, version: Infinity }, + { ...backup, version: Number.MAX_SAFE_INTEGER + 1 }, + { ...backup, backupTime: '2026-09-09' }, + { ...backup, failed: undefined }, + { ...backup, failed: 1 }, + ])('rejects malformed backup records', (invalidBackup) => { + expect(() => + parseManagedToolFileHistoryBinding({ + ...binding, + snapshots: [ + { ...snapshot, trackedFileBackups: { 'proof.txt': invalidBackup } }, + ], + }), + ).toThrow(); + }); + + it.each([-1, 0.1, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'rejects revision %s', + (revision) => { + expect(() => + parseManagedToolFileHistoryState({ + ownerSessionId, + revision, + snapshots: [], + }), + ).toThrow(); + }, + ); + + it('rejects non-JSON values, excessive snapshots and oversized state', () => { + const cycle: Record = {}; + cycle['self'] = cycle; + for (const input of [ + cycle, + { + ...binding, + snapshots: Array.from({ length: 101 }, (_, i) => ({ + ...snapshot, + promptId: String(i), + })), + }, + { ...binding, executionCwd: 'x'.repeat(1024 * 1024) }, + ]) + expect(() => parseManagedToolFileHistoryBinding(input)).toThrow(); + }); + + it.each(['', 'x'.repeat(129), 'nul\0prompt', undefined])( + 'rejects invalid prompt ID', + (promptId) => { + expect(() => parseManagedToolFileHistoryPromptId(promptId)).toThrow(); + }, + ); +}); diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.ts b/packages/core/src/tools/managed-tool-file-history-protocol.ts new file mode 100644 index 00000000000..dc23fbdb4ff --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history-protocol.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import type { Config } from '../config/config.js'; +import { normalizeQwenCustomIgnoreFileNames } from '../utils/qwenIgnoreParser.js'; +import type { SerializedFileHistorySnapshot } from '../services/fileHistoryService.js'; +import type { ManagedToolFileHistoryState } from './managed-tool-file-history.js'; +import { + managedToolDigest, + ManagedToolProtocolError, +} from './managed-tool-protocol.js'; + +export const MANAGED_TOOL_FILE_HISTORY_MAX_BYTES = 8 * 1024 * 1024; + +export interface ManagedToolExecutionContext { + workspaceDirectories: string[]; + memoryBaseDir: string; + lsToolEnabled: boolean; + grepOptions?: { + useRipgrep: boolean; + useBuiltinRipgrep: boolean; + }; + outputLimits?: { + chars: number | null; + lines: number | null; + charsExplicit: boolean; + }; + fileFilteringOptions: { + respectGitIgnore: boolean; + respectQwenIgnore: boolean; + customIgnoreFiles: string[]; + }; +} + +export function captureManagedToolExecutionContext( + config: Config, +): ManagedToolExecutionContext { + const filtering = config.getFileFilteringOptions(); + return { + workspaceDirectories: [...config.getWorkspaceContext().getDirectories()], + memoryBaseDir: config.getMemoryBaseDir(), + lsToolEnabled: config.isLsToolEnabled(), + grepOptions: { + useRipgrep: config.getUseRipgrep(), + useBuiltinRipgrep: config.getUseBuiltinRipgrep(), + }, + outputLimits: { + chars: encodeOutputLimit(config.getTruncateToolOutputThreshold()), + lines: encodeOutputLimit(config.getTruncateToolOutputLines()), + charsExplicit: config.isTruncateToolOutputThresholdExplicit(), + }, + fileFilteringOptions: { + respectGitIgnore: filtering.respectGitIgnore, + respectQwenIgnore: filtering.respectQwenIgnore, + customIgnoreFiles: normalizeQwenCustomIgnoreFileNames( + filtering.customIgnoreFiles, + ), + }, + }; +} + +export interface ManagedToolFileHistoryBinding { + ownerSessionId: string; + ownerRuntimeSessionId: string; + executionCwd: string; + executionContext?: ManagedToolExecutionContext; + snapshots: SerializedFileHistorySnapshot[]; +} + +export interface ManagedToolFileHistoryClient { + bind( + binding: ManagedToolFileHistoryBinding, + ): Promise; + checkpoint(promptId: string): Promise; + snapshot(): Promise; +} + +function record( + value: unknown, + keys?: readonly string[], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ManagedToolProtocolError(); + const result = value as Record; + if (keys && Object.keys(result).some((key) => !keys.includes(key))) + throw new ManagedToolProtocolError(); + return result; +} + +function text(value: unknown, limit: number): string { + if ( + typeof value !== 'string' || + !value.length || + value.length > limit || + value.includes('\0') + ) + throw new ManagedToolProtocolError(); + return value; +} + +function uuid(value: unknown): string { + const result = text(value, 256); + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + result, + ) + ) + throw new ManagedToolProtocolError(); + return result; +} + +function integer(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new ManagedToolProtocolError(); + return value; +} + +function timestamp(value: unknown): string { + const result = text(value, 32); + const date = new Date(result); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== result) + throw new ManagedToolProtocolError(); + return result; +} + +export function parseManagedToolFileHistoryPromptId(value: unknown): string { + return text(value, 128); +} + +function snapshots(value: unknown): SerializedFileHistorySnapshot[] { + if (!Array.isArray(value) || value.length > 100) + throw new ManagedToolProtocolError(); + const prompts = new Set(); + return value.map((item) => { + const snapshot = record(item, [ + 'promptId', + 'timestamp', + 'trackedFileBackups', + ]); + const promptId = parseManagedToolFileHistoryPromptId(snapshot['promptId']); + if (prompts.has(promptId)) throw new ManagedToolProtocolError(); + prompts.add(promptId); + return { + promptId, + timestamp: timestamp(snapshot['timestamp']), + trackedFileBackups: Object.fromEntries( + Object.entries(record(snapshot['trackedFileBackups'])).map( + ([filePath, value]) => { + text(filePath, 4096); + if (filePath.split(path.sep).includes('..')) + throw new ManagedToolProtocolError(); + const backup = record(value, [ + 'backupFileName', + 'version', + 'backupTime', + 'failed', + ]); + const backupFileName = + backup['backupFileName'] === null + ? null + : text(backup['backupFileName'], 256); + if ( + backupFileName !== null && + (backupFileName === '.' || + backupFileName === '..' || + /[/\\]/.test(backupFileName)) + ) + throw new ManagedToolProtocolError(); + if ('failed' in backup && typeof backup['failed'] !== 'boolean') + throw new ManagedToolProtocolError(); + return [ + filePath, + { + backupFileName, + version: integer(backup['version']), + backupTime: timestamp(backup['backupTime']), + ...('failed' in backup + ? { failed: backup['failed'] as boolean } + : {}), + }, + ]; + }, + ), + ), + }; + }); +} + +function absolutePath(value: unknown): string { + const result = text(value, 4096); + if (!path.isAbsolute(result) || path.normalize(result) !== result) + throw new ManagedToolProtocolError(); + return result; +} + +function encodeOutputLimit(value: number): number | null { + return value === Number.POSITIVE_INFINITY ? null : value; +} + +function outputLimit(value: unknown): number | null { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + throw new ManagedToolProtocolError(); + return value; +} + +function grepOptions( + value: unknown, +): ManagedToolExecutionContext['grepOptions'] { + const input = record(value, ['useRipgrep', 'useBuiltinRipgrep']); + if ( + typeof input['useRipgrep'] !== 'boolean' || + typeof input['useBuiltinRipgrep'] !== 'boolean' + ) + throw new ManagedToolProtocolError(); + return { + useRipgrep: input['useRipgrep'], + useBuiltinRipgrep: input['useBuiltinRipgrep'], + }; +} + +function outputLimits( + value: unknown, +): ManagedToolExecutionContext['outputLimits'] { + const input = record(value, ['chars', 'lines', 'charsExplicit']); + if (typeof input['charsExplicit'] !== 'boolean') + throw new ManagedToolProtocolError(); + return { + chars: outputLimit(input['chars']), + lines: outputLimit(input['lines']), + charsExplicit: input['charsExplicit'], + }; +} + +function executionContext(value: unknown): ManagedToolExecutionContext { + const input = record(value, [ + 'workspaceDirectories', + 'memoryBaseDir', + 'fileFilteringOptions', + 'lsToolEnabled', + 'grepOptions', + 'outputLimits', + ]); + const filtering = record(input['fileFilteringOptions'], [ + 'respectGitIgnore', + 'respectQwenIgnore', + 'customIgnoreFiles', + ]); + if ( + !Array.isArray(input['workspaceDirectories']) || + !Array.isArray(filtering['customIgnoreFiles']) || + typeof filtering['respectGitIgnore'] !== 'boolean' || + typeof filtering['respectQwenIgnore'] !== 'boolean' || + typeof input['lsToolEnabled'] !== 'boolean' + ) + throw new ManagedToolProtocolError(); + const directories = input['workspaceDirectories'].map(absolutePath); + if (new Set(directories).size !== directories.length) + throw new ManagedToolProtocolError(); + const customIgnoreFiles = filtering['customIgnoreFiles'].map((value) => + text(value, 4096), + ); + if ( + JSON.stringify(normalizeQwenCustomIgnoreFileNames(customIgnoreFiles)) !== + JSON.stringify(customIgnoreFiles) + ) + throw new ManagedToolProtocolError(); + return { + workspaceDirectories: directories, + memoryBaseDir: absolutePath(input['memoryBaseDir']), + lsToolEnabled: input['lsToolEnabled'], + ...('grepOptions' in input + ? { grepOptions: grepOptions(input['grepOptions']) } + : {}), + ...('outputLimits' in input + ? { outputLimits: outputLimits(input['outputLimits']) } + : {}), + fileFilteringOptions: { + respectGitIgnore: filtering['respectGitIgnore'], + respectQwenIgnore: filtering['respectQwenIgnore'], + customIgnoreFiles, + }, + }; +} + +export function parseManagedToolFileHistoryBinding( + value: unknown, +): ManagedToolFileHistoryBinding { + managedToolDigest(value, MANAGED_TOOL_FILE_HISTORY_MAX_BYTES); + const input = record(value, [ + 'ownerSessionId', + 'ownerRuntimeSessionId', + 'executionCwd', + 'executionContext', + 'snapshots', + ]); + const executionCwd = text(input['executionCwd'], 4096); + if ( + !path.isAbsolute(executionCwd) || + path.normalize(executionCwd) !== executionCwd + ) + throw new ManagedToolProtocolError(); + return { + ownerSessionId: uuid(input['ownerSessionId']), + ownerRuntimeSessionId: uuid(input['ownerRuntimeSessionId']), + executionCwd, + ...('executionContext' in input + ? { executionContext: executionContext(input['executionContext']) } + : {}), + snapshots: snapshots(input['snapshots']), + }; +} + +export function parseManagedToolFileHistoryState( + value: unknown, +): ManagedToolFileHistoryState { + managedToolDigest(value, MANAGED_TOOL_FILE_HISTORY_MAX_BYTES); + const input = record(value, ['ownerSessionId', 'revision', 'snapshots']); + return { + ownerSessionId: uuid(input['ownerSessionId']), + revision: integer(input['revision']), + snapshots: snapshots(input['snapshots']), + }; +} diff --git a/packages/core/src/tools/managed-tool-file-history.test.ts b/packages/core/src/tools/managed-tool-file-history.test.ts new file mode 100644 index 00000000000..ff26e253c7f --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history.test.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + FileHistoryService, + type SerializedFileHistorySnapshot, +} from '../services/fileHistoryService.js'; +import { ManagedToolFileHistory } from './managed-tool-file-history.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; + +const storageDir = vi.hoisted(() => vi.fn()); +vi.mock('../config/storage.js', () => ({ + Storage: { getGlobalQwenDir: storageDir }, +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const ownerSessionId = '1e4a1b6a-9a0f-47e4-aef6-6ce937ac0988'; + +describe('ManagedToolFileHistory', () => { + let cwd: string; + let backupRoot: string; + let owner: ManagedToolFileHistory; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'managed-history-workspace-')); + backupRoot = await mkdtemp(join(tmpdir(), 'managed-history-backups-')); + storageDir.mockReturnValue(backupRoot); + owner = new ManagedToolFileHistory(ownerSessionId, cwd, []); + await owner.ready(); + }); + + afterEach(async () => { + await owner.drain(); + await rm(cwd, { recursive: true, force: true }); + await rm(backupRoot, { recursive: true, force: true }); + }); + + it('records empty parent checkpoints, deduplicates the last one and refuses older turns', async () => { + expect(owner.state()).toEqual({ + ownerSessionId, + revision: 0, + snapshots: [], + }); + await Promise.all([ + owner.checkpoint('parent-1'), + owner.checkpoint('parent-1'), + ]); + expect(owner.state()).toMatchObject({ + revision: 1, + snapshots: [{ promptId: 'parent-1', trackedFileBackups: {} }], + }); + await owner.checkpoint('parent-2'); + await expect(owner.checkpoint('parent-1')).rejects.toThrow('earlier turn'); + expect(owner.state().revision).toBe(2); + expect(() => managedToolDigest(owner.state())).not.toThrow(); + }); + + it('orders real writes and parent checkpoints around the same shared backup owner', async () => { + const filePath = join(cwd, 'shared.txt'); + await writeFile(filePath, 'zero'); + await owner.checkpoint('parent-1'); + const entered = deferred(); + const finishWrite = deferred(); + const first = owner.run(async () => { + await owner.service.trackEdit(filePath); + entered.resolve(); + await finishWrite.promise; + await writeFile(filePath, 'one'); + }); + await entered.promise; + const checkpoint = owner.checkpoint('parent-2'); + let secondStarted = false; + const second = owner.run(async () => { + secondStarted = true; + await owner.service.trackEdit(filePath); + await writeFile(filePath, 'two'); + }); + await Promise.resolve(); + expect(secondStarted).toBe(false); + expect(owner.state().snapshots).toHaveLength(1); + finishWrite.resolve(); + await Promise.all([first, checkpoint, second]); + + const state = owner.state(); + expect(state.snapshots.map(({ promptId }) => promptId)).toEqual([ + 'parent-1', + 'parent-2', + ]); + const backups = state.snapshots.map( + (snapshot) => snapshot.trackedFileBackups['shared.txt'], + ); + expect(backups.map(({ version }) => version)).toEqual([1, 2]); + for (const [index, backup] of backups.entries()) { + expect( + await readFile( + join( + backupRoot, + 'file-history', + ownerSessionId, + backup.backupFileName!, + ), + 'utf8', + ), + ).toBe(index === 0 ? 'zero' : 'one'); + } + expect(await readFile(filePath, 'utf8')).toBe('two'); + expect(() => managedToolDigest(state)).not.toThrow(); + expect(backups.every((backup) => !Object.hasOwn(backup, 'failed'))).toBe( + true, + ); + }); + + it('captures callback changes synchronously and isolates returned state', async () => { + const filePath = join(cwd, 'a.txt'); + await writeFile(filePath, 'before'); + await owner.checkpoint('parent-1'); + await owner.run(async () => { + await owner.service.trackEdit(filePath); + const state = owner.state(); + expect(state.revision).toBe(2); + state.snapshots[0].trackedFileBackups['a.txt'].version = 999; + state.snapshots.length = 0; + expect( + owner.state().snapshots[0].trackedFileBackups['a.txt'].version, + ).toBe(1); + }); + expect(owner.state().revision).toBe(2); + }); + + it('restores real durable backups and captures rewind without a recorder callback', async () => { + const filePath = join(cwd, 'a.txt'); + await writeFile(filePath, 'before'); + await owner.checkpoint('parent-1'); + await owner.run(async () => { + await owner.service.trackEdit(filePath); + await writeFile(filePath, 'after'); + }); + await owner.checkpoint('parent-2'); + const stored = owner.state().snapshots; + const restored = new ManagedToolFileHistory(ownerSessionId, cwd, stored); + await restored.ready(); + stored[0].trackedFileBackups['a.txt'].version = 999; + expect( + restored.state().snapshots[0].trackedFileBackups['a.txt'].version, + ).toBe(1); + expect( + await restored.run(() => restored.service.rewind('parent-1')), + ).toEqual({ + filesChanged: [filePath], + filesFailed: [], + }); + expect(await readFile(filePath, 'utf8')).toBe('before'); + expect(restored.state()).toMatchObject({ + revision: 1, + snapshots: [{ promptId: 'parent-1' }], + }); + await expect(restored.checkpoint('parent-2')).rejects.toThrow( + 'earlier turn', + ); + }); + + it('waits for restore validation and publishes missing-backup failures as JSON', async () => { + const snapshots: SerializedFileHistorySnapshot[] = [ + { + promptId: 'parent-1', + timestamp: new Date().toISOString(), + trackedFileBackups: { + 'missing.txt': { + backupFileName: 'missing@v1', + version: 1, + backupTime: new Date().toISOString(), + failed: undefined, + }, + }, + }, + ]; + const restored = new ManagedToolFileHistory(ownerSessionId, cwd, snapshots); + await restored.run(async () => { + expect( + restored.state().snapshots[0].trackedFileBackups['missing.txt'].failed, + ).toBe(true); + }); + expect(restored.state().revision).toBe(1); + expect(() => managedToolDigest(restored.state())).not.toThrow(); + expect( + snapshots[0].trackedFileBackups['missing.txt'].failed, + ).toBeUndefined(); + }); + + it('preserves parent-relative and outside-parent path attribution', async () => { + const filePath = join(backupRoot, 'outside.txt'); + await writeFile(filePath, 'outside'); + await owner.checkpoint('parent-1'); + await owner.run(() => owner.service.trackEdit(filePath)); + expect(Object.keys(owner.state().snapshots[0].trackedFileBackups)).toEqual([ + filePath, + ]); + }); + + it('drains queued work and keeps admitting operations after an execution error', async () => { + const entered = deferred(); + const finish = deferred(); + const failed = owner.run(async () => { + entered.resolve(); + await finish.promise; + throw new Error('execution failed'); + }); + const observed = failed.catch((error: unknown) => error); + await entered.promise; + const next = owner.checkpoint('parent-1'); + let drained = false; + const drain = owner.drain().then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + finish.resolve(); + expect(await observed).toMatchObject({ message: 'execution failed' }); + await Promise.all([next, drain]); + expect(drained).toBe(true); + expect(owner.state().snapshots).toHaveLength(1); + }); + + it('rejects non-JSON numeric backup versions before restore', () => { + expect( + () => + new ManagedToolFileHistory(ownerSessionId, cwd, [ + { + promptId: 'parent-1', + timestamp: new Date().toISOString(), + trackedFileBackups: { + 'a.txt': { + backupFileName: null, + version: Infinity, + backupTime: '', + }, + }, + }, + ]), + ).toThrow('backup version'); + }); + + it('does not run tools or report a successful drain after initialization fails', async () => { + const failure = new Error('restore validation failed'); + const validate = vi + .spyOn(FileHistoryService.prototype, 'validateRestoredSnapshots') + .mockRejectedValueOnce(failure); + try { + const failed = new ManagedToolFileHistory(ownerSessionId, cwd, []); + const operation = vi.fn(async () => {}); + await expect(failed.ready()).rejects.toBe(failure); + await expect(failed.run(operation)).rejects.toBe(failure); + await expect(failed.run(operation)).rejects.toBe(failure); + await expect(failed.drain()).rejects.toBe(failure); + expect(operation).not.toHaveBeenCalled(); + } finally { + validate.mockRestore(); + } + }); +}); diff --git a/packages/core/src/tools/managed-tool-file-history.ts b/packages/core/src/tools/managed-tool-file-history.ts new file mode 100644 index 00000000000..77b81bfd44e --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + FileHistoryService, + deserializeSnapshots, + serializeSnapshot, + type SerializedFileHistorySnapshot, +} from '../services/fileHistoryService.js'; + +export interface ManagedToolFileHistoryState { + ownerSessionId: string; + revision: number; + snapshots: SerializedFileHistorySnapshot[]; +} + +function copySnapshots( + snapshots: SerializedFileHistorySnapshot[], +): SerializedFileHistorySnapshot[] { + return snapshots.map((snapshot) => ({ + promptId: snapshot.promptId, + timestamp: snapshot.timestamp, + trackedFileBackups: Object.fromEntries( + Object.entries(snapshot.trackedFileBackups).map(([filePath, backup]) => { + if (!Number.isSafeInteger(backup.version) || backup.version < 0) { + throw new Error('Invalid managed file history backup version.'); + } + return [ + filePath, + { + backupFileName: backup.backupFileName, + version: backup.version, + backupTime: backup.backupTime, + ...(backup.failed ? { failed: true } : {}), + }, + ]; + }), + ), + })); +} + +export class ManagedToolFileHistory { + readonly service: FileHistoryService; + private readonly initialization: Promise; + private pending: Promise = Promise.resolve(); + private readonly startedPrompts: Set; + private revision = 0; + private snapshots: SerializedFileHistorySnapshot[]; + private snapshotJson: string; + + constructor( + private readonly ownerSessionId: string, + cwd: string, + serializedSnapshots: SerializedFileHistorySnapshot[], + ) { + this.service = new FileHistoryService(ownerSessionId, true, cwd, () => + this.capture(), + ); + this.service.restoreFromSnapshots( + deserializeSnapshots(copySnapshots(serializedSnapshots)), + ); + this.snapshots = this.readSnapshots(); + this.snapshotJson = JSON.stringify(this.snapshots); + this.startedPrompts = new Set( + this.snapshots.map(({ promptId }) => promptId), + ); + this.initialization = this.service.validateRestoredSnapshots(); + void this.initialization.catch(() => {}); + } + + ready(): Promise { + return this.initialization; + } + + state(): ManagedToolFileHistoryState { + return { + ownerSessionId: this.ownerSessionId, + revision: this.revision, + snapshots: structuredClone(this.snapshots), + }; + } + + checkpoint(promptId: string): Promise { + return this.run(async () => { + if (this.service.getSnapshots().at(-1)?.promptId === promptId) return; + if (this.startedPrompts.has(promptId)) { + throw new Error('Managed file history cannot reopen an earlier turn.'); + } + await this.service.makeSnapshot(promptId); + this.startedPrompts.add(promptId); + }); + } + + run(operation: () => Promise): Promise { + const result = this.pending.then(async () => { + await this.initialization; + try { + return await operation(); + } finally { + this.capture(); + } + }); + this.pending = result.then( + () => {}, + () => {}, + ); + return result; + } + + async drain(): Promise { + const pending = this.pending; + await this.initialization; + await pending; + } + + private readSnapshots(): SerializedFileHistorySnapshot[] { + return copySnapshots(this.service.getSnapshots().map(serializeSnapshot)); + } + + private capture(): void { + const snapshots = this.readSnapshots(); + const snapshotJson = JSON.stringify(snapshots); + if (snapshotJson === this.snapshotJson) return; + this.snapshots = snapshots; + this.snapshotJson = snapshotJson; + this.revision++; + } +} diff --git a/packages/core/src/tools/managed-tool-protocol.test.ts b/packages/core/src/tools/managed-tool-protocol.test.ts new file mode 100644 index 00000000000..feca31bcb57 --- /dev/null +++ b/packages/core/src/tools/managed-tool-protocol.test.ts @@ -0,0 +1,367 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { ToolCallConfirmationDetails } from './tools.js'; +import { + managedToolDigest, + parseManagedToolContentModification, + parseManagedToolMediaContext, + ManagedToolProtocolError, + parseManagedToolCallIdentity, + parseManagedToolConfirmationPayload, + parseManagedToolInvocationReference, + serializeManagedToolConfirmation, +} from './managed-tool-protocol.js'; + +const identity = { + sessionId: '01a05708-a79d-4a02-8b79-02c4120eb054', + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', +}; +const reference = { + ...identity, + invocationId: 'invocation-1', + argsDigest: 'b'.repeat(64), +}; + +describe('managed media context', () => { + it('copies only explicit resolved input capabilities', () => { + const input = { + inputModalities: { image: true, pdf: false, audio: true, video: false }, + }; + const parsed = parseManagedToolMediaContext(input); + expect(parsed).toEqual(input); + input.inputModalities.image = false; + expect(parsed.inputModalities.image).toBe(true); + expect(parseManagedToolMediaContext({ inputModalities: {} })).toEqual({ + inputModalities: {}, + }); + }); + + it.each([ + undefined, + null, + [], + {}, + { inputModalities: null }, + { inputModalities: [] }, + { inputModalities: { image: 'true' } }, + { inputModalities: { pdf: 1 } }, + { inputModalities: { image: undefined } }, + { inputModalities: { text: true } }, + { inputModalities: {}, model: 'worker-model' }, + { inputModalities: {}, prompt: 'worker-prompt' }, + { inputModalities: {}, apiKey: 'credential' }, + ])('rejects malformed or expanded context %#', (input) => { + expect(() => parseManagedToolMediaContext(input)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool identities', () => { + it('normalizes session UUIDs and preserves the complete invocation binding', () => { + expect( + parseManagedToolCallIdentity({ + ...identity, + sessionId: identity.sessionId.toUpperCase(), + }), + ).toEqual(identity); + expect(parseManagedToolInvocationReference(reference)).toEqual(reference); + expect( + parseManagedToolInvocationReference({ + ...reference, + promptId: 'p'.repeat(128), + callId: 'c'.repeat(512), + policyRevision: 'r'.repeat(256), + invocationId: 'i'.repeat(128), + }), + ).toMatchObject({ invocationId: 'i'.repeat(128) }); + }); + + it.each([ + null, + [], + {}, + { ...identity, sessionId: 'session-1' }, + { ...identity, sessionId: `${identity.sessionId}-agent-1` }, + { ...identity, promptId: '' }, + { ...identity, promptId: 'p'.repeat(129) }, + { ...identity, callId: 'c'.repeat(513) }, + { ...identity, callId: 'call\0suffix' }, + { ...identity, capabilityDigest: 'A'.repeat(64) }, + { ...identity, capabilityDigest: 'a'.repeat(63) }, + { ...identity, policyRevision: 1 }, + { ...identity, policyRevision: 'r'.repeat(257) }, + { ...identity, extra: true }, + reference, + ])('rejects invalid or extended standalone identity %#', (value) => { + expect(() => parseManagedToolCallIdentity(value)).toThrow( + ManagedToolProtocolError, + ); + }); + + it.each([ + identity, + { ...reference, invocationId: '' }, + { ...reference, invocationId: 'i'.repeat(129) }, + { ...reference, argsDigest: 'not-a-digest' }, + { ...reference, params: {} }, + ])('rejects incomplete or extended references %#', (value) => { + expect(() => parseManagedToolInvocationReference(value)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool JSON digests', () => { + it('hashes canonical JSON with sorted nested and numeric object keys', () => { + const expected = createHash('sha256') + .update('{"10":"ten","2":"two","a":{"x":[true,null,1],"z":2}}') + .digest('hex'); + expect( + managedToolDigest({ + a: { z: 2, x: [true, null, 1] }, + 2: 'two', + 10: 'ten', + }), + ).toBe(expected); + expect( + managedToolDigest({ + 10: 'ten', + a: { x: [true, null, 1], z: 2 }, + 2: 'two', + }), + ).toBe(expected); + expect(managedToolDigest([1, 2])).not.toBe(managedToolDigest([2, 1])); + expect(managedToolDigest({ a: 1 })).not.toBe(managedToolDigest({ a: '1' })); + }); + + it('allows shared JSON subtrees and null-prototype records', () => { + const shared = { value: 1 }; + expect(managedToolDigest([shared, shared])).toBe( + managedToolDigest([{ value: 1 }, { value: 1 }]), + ); + expect(managedToolDigest(Object.assign(Object.create(null), shared))).toBe( + managedToolDigest(shared), + ); + expect(managedToolDigest(JSON.parse('{"__proto__":{"x":1}}'))).not.toBe( + managedToolDigest({}), + ); + }); + + it.each([ + undefined, + () => {}, + Symbol('value'), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + { missing: undefined }, + [undefined], + new Array(1), + new Date(0), + new Map(), + { [Symbol('hidden')]: 1 }, + Object.defineProperty({}, 'hidden', { value: 1 }), + Object.assign([], { extra: 1 }), + ])('rejects values that JSON would omit or coerce %#', (value) => { + expect(() => managedToolDigest(value)).toThrow(ManagedToolProtocolError); + }); + + it('rejects accessors, toJSON methods and cycles without invoking them', () => { + const getter = vi.fn(() => 'secret'); + const toJSON = vi.fn(() => ({})); + expect(() => + managedToolDigest( + Object.defineProperty({}, 'value', { get: getter, enumerable: true }), + ), + ).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest({ toJSON })).toThrow( + ManagedToolProtocolError, + ); + expect(getter).not.toHaveBeenCalled(); + expect(toJSON).not.toHaveBeenCalled(); + const cycle: Record = {}; + cycle['self'] = cycle; + expect(() => managedToolDigest(cycle)).toThrow(ManagedToolProtocolError); + }); + + it('bounds canonical UTF-8 bytes, escaping, nesting and explicit limits', () => { + expect(managedToolDigest('é', 4)).toMatch(/^[a-f0-9]{64}$/); + expect(() => managedToolDigest('é', 3)).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest('\n', 3)).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest('x'.repeat(256 * 1024))).toThrow( + ManagedToolProtocolError, + ); + expect(managedToolDigest('x'.repeat(256 * 1024), 1024 * 1024)).toMatch( + /^[a-f0-9]{64}$/, + ); + for (const limit of [0, -1, 1.5, Infinity]) { + expect(() => managedToolDigest({}, limit)).toThrow( + ManagedToolProtocolError, + ); + } + let nested: unknown = null; + for (let depth = 0; depth < 64; depth++) nested = [nested]; + expect(managedToolDigest(nested)).toMatch(/^[a-f0-9]{64}$/); + expect(() => managedToolDigest([nested])).toThrow(ManagedToolProtocolError); + }); +}); + +describe('managed tool confirmation DTOs', () => { + const onConfirm = vi.fn(async () => {}); + const details: ToolCallConfirmationDetails[] = [ + { + type: 'edit', + title: 'Edit', + onConfirm, + hideAlwaysAllow: false, + fileName: 'file.ts', + filePath: '/workspace/file.ts', + fileDiff: '-old\n+new', + originalContent: null, + newContent: 'new', + isModifying: true, + hideModify: true, + skipIdeDiff: true, + warnings: ['warning'], + autoModeFallback: { + reason: 'classifier_unavailable', + message: 'Unavailable', + }, + }, + { + type: 'exec', + title: 'Run', + onConfirm, + command: 'echo ok', + rootCommand: 'echo', + permissionRules: ['Shell(echo *)'], + warnings: [], + }, + { + type: 'mcp', + title: 'MCP', + onConfirm, + serverName: 'server', + toolName: 'tool', + toolDisplayName: 'Tool', + permissionRules: ['mcp__server__tool'], + }, + { + type: 'info', + title: 'Info', + onConfirm, + prompt: 'Continue?', + renderPromptAsPlainText: true, + urls: ['https://example.com'], + permissionRules: [], + }, + ]; + + it.each(details)( + 'preserves $type fields and excludes callbacks and extensions', + (detail) => { + const { onConfirm: _callback, ...expected } = detail; + const extended = { ...detail, secret: 'do not serialize' }; + expect(serializeManagedToolConfirmation(extended)).toEqual(expected); + expect(onConfirm).not.toHaveBeenCalled(); + }, + ); + + it('clones nested metadata and strips unknown fields within AUTO metadata', () => { + const detail = details[0]; + const wire = serializeManagedToolConfirmation({ + ...detail, + autoModeFallback: Object.assign( + { reason: 'classifier_unavailable' as const, message: 'Unavailable' }, + { secret: 'do not serialize' }, + ), + }); + expect(wire.autoModeFallback).toEqual({ + reason: 'classifier_unavailable', + message: 'Unavailable', + }); + expect(wire.autoModeFallback).not.toBe(detail.autoModeFallback); + if (wire.type === 'edit' && detail.type === 'edit') { + expect(wire.warnings).not.toBe(detail.warnings); + } + }); + + it.each([ + { type: 'plan', title: 'Plan', plan: 'plan', onConfirm }, + { type: 'ask_user_question', title: 'Question', questions: [], onConfirm }, + ])('rejects Gateway-only $type confirmations', (detail) => { + expect(() => serializeManagedToolConfirmation(detail)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool confirmation payloads', () => { + it('preserves and clones all existing payload fields', () => { + const payload = { + newContent: '', + cancelMessage: 'cancel', + permissionRules: ['Edit(*)'], + answers: { question: 'answer' }, + updatedInput: { nested: [true, null, 1] }, + }; + const parsed = parseManagedToolConfirmationPayload(payload); + expect(parsed).toEqual(payload); + expect(parsed?.updatedInput).not.toBe(payload.updatedInput); + expect(parsed?.answers).not.toBe(payload.answers); + expect(parseManagedToolConfirmationPayload(undefined)).toBeUndefined(); + expect(parseManagedToolConfirmationPayload({})).toEqual({}); + }); + + it.each([ + null, + [], + 'payload', + { newContent: 1 }, + { cancelMessage: null }, + { permissionRules: 'rule' }, + { permissionRules: [1] }, + { answers: [] }, + { answers: { q: false } }, + { updatedInput: [] }, + { updatedInput: { value: undefined } }, + { unknown: true }, + { newContent: 'x'.repeat(256 * 1024) }, + ])('rejects malformed, extended or oversized payload %#', (value) => { + expect(() => parseManagedToolConfirmationPayload(value)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed notebook modification metadata', () => { + it('copies the full reference and accepts empty content for native validation', () => { + const input = { source: { ...reference }, newContent: '' }; + const parsed = parseManagedToolContentModification(input); + input.source.callId = 'mutated'; + expect(parsed).toEqual({ source: reference, newContent: '' }); + }); + it.each([ + {}, + { source: reference }, + { source: reference, newContent: null }, + { source: reference, newContent: '{}', approved: true }, + { source: { ...reference, argsDigest: 'wrong' }, newContent: '{}' }, + { source: reference, newContent: 'x'.repeat(256 * 1024) }, + ])('rejects malformed or oversized metadata %#', (input) => { + expect(() => parseManagedToolContentModification(input)).toThrow( + ManagedToolProtocolError, + ); + }); +}); diff --git a/packages/core/src/tools/managed-tool-protocol.ts b/packages/core/src/tools/managed-tool-protocol.ts new file mode 100644 index 00000000000..1385c24896b --- /dev/null +++ b/packages/core/src/tools/managed-tool-protocol.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { FunctionDeclaration } from '@google/genai'; +import type { PermissionDecision } from '../permissions/types.js'; +import type { InputModalities } from '../core/contentGenerator.js'; +import type { + Kind, + ToolCallConfirmationDetails, + ToolConfirmationPayload, + ToolLocation, +} from './tools.js'; + +const MAX_JSON_BYTES = 256 * 1024; +const MAX_JSON_DEPTH = 64; +const SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DIGEST_PATTERN = /^[a-f0-9]{64}$/; +const IDENTITY_KEYS = [ + 'sessionId', + 'promptId', + 'callId', + 'capabilityDigest', + 'policyRevision', +] as const; + +export interface ManagedToolCallIdentity { + readonly sessionId: string; + readonly promptId: string; + readonly callId: string; + readonly capabilityDigest: string; + readonly policyRevision: string; +} + +export interface ManagedToolInvocationReference + extends ManagedToolCallIdentity { + readonly invocationId: string; + readonly argsDigest: string; +} + +export interface ManagedToolContentModification { + readonly source: ManagedToolInvocationReference; + readonly newContent: string; +} + +export interface ManagedToolMediaContext { + readonly inputModalities: Readonly; +} + +export interface ManagedToolDescriptor { + readonly name: string; + readonly displayName: string; + readonly kind: Kind; + readonly description: string; + readonly schema: FunctionDeclaration; + readonly permissionAliases?: readonly string[]; + readonly canUpdateOutput: boolean; + readonly isOutputMarkdown?: boolean; + readonly shouldDefer?: boolean; + readonly alwaysLoad?: boolean; + readonly searchHint?: string; + readonly maxOutputChars?: number | 'unlimited'; + readonly truncateKeep?: 'head' | 'tail' | 'both'; + readonly isMcp?: boolean; + readonly mcpServerName?: string; + readonly mcpToolName?: string; +} + +export interface ManagedToolPrepareResponse + extends ManagedToolInvocationReference { + readonly params: Record; + readonly description: string; + readonly locations: ToolLocation[]; + readonly defaultPermission: PermissionDecision; + readonly requiresUserInteraction: boolean; + readonly toolUseId: string; +} + +type WorkspaceConfirmation = Extract< + ToolCallConfirmationDetails, + { type: 'edit' | 'exec' | 'mcp' | 'info' } +>; + +export type ManagedToolConfirmationDetails = { + [T in WorkspaceConfirmation['type']]: Omit< + Extract, + 'onConfirm' + >; +}[WorkspaceConfirmation['type']]; + +export class ManagedToolProtocolError extends Error { + readonly code = 'managed_tool_invalid_request'; + + constructor(message = 'Managed Tool invocation data is invalid.') { + super(message); + this.name = 'ManagedToolProtocolError'; + } +} + +function canonicalJson(value: unknown, maxBytes = MAX_JSON_BYTES): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new ManagedToolProtocolError('Invalid Managed Tool JSON size limit.'); + } + const ancestors = new Set(); + const chunks: string[] = []; + let bytes = 0; + const append = (chunk: string) => { + bytes += Buffer.byteLength(chunk, 'utf8'); + if (bytes > maxBytes) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds size limit.', + ); + } + chunks.push(chunk); + }; + const appendString = (text: string) => { + if (Buffer.byteLength(text, 'utf8') > maxBytes - bytes) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds size limit.', + ); + } + append(JSON.stringify(text)); + }; + const visit = (input: unknown, depth: number): void => { + if (depth > MAX_JSON_DEPTH) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds depth limit.', + ); + } + if (input === null) { + append('null'); + } else if (typeof input === 'string') { + appendString(input); + } else if (typeof input === 'boolean') { + append(input ? 'true' : 'false'); + } else if (typeof input === 'number' && Number.isFinite(input)) { + append(JSON.stringify(input)); + } else if (typeof input === 'object') { + if ( + ancestors.has(input) || + (!Array.isArray(input) && + Object.getPrototypeOf(input) !== Object.prototype && + Object.getPrototypeOf(input) !== null) + ) { + throw new ManagedToolProtocolError(); + } + ancestors.add(input); + const descriptors = Object.getOwnPropertyDescriptors(input); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.some((key) => { + if (typeof key !== 'string') return true; + if (Array.isArray(input) && key === 'length') return false; + const descriptor = descriptors[key]; + return !descriptor.enumerable || !('value' in descriptor); + }) + ) { + throw new ManagedToolProtocolError(); + } + if (Array.isArray(input)) { + if ( + keys.length !== input.length + 1 || + keys.some( + (key) => + key !== 'length' && + (typeof key !== 'string' || + !/^(0|[1-9][0-9]*)$/.test(key) || + Number(key) >= input.length), + ) + ) { + throw new ManagedToolProtocolError(); + } + append('['); + for (let index = 0; index < input.length; index++) { + if (index > 0) append(','); + visit(descriptors[String(index)].value, depth + 1); + } + append(']'); + } else { + append('{'); + Object.keys(descriptors) + .sort() + .forEach((key, index) => { + if (index > 0) append(','); + appendString(key); + append(':'); + visit(descriptors[key].value, depth + 1); + }); + append('}'); + } + ancestors.delete(input); + } else { + throw new ManagedToolProtocolError(); + } + }; + visit(value, 0); + return chunks.join(''); +} + +export function managedToolDigest( + value: unknown, + maxBytes = MAX_JSON_BYTES, +): string { + return createHash('sha256') + .update(canonicalJson(value, maxBytes)) + .digest('hex'); +} + +function record(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ManagedToolProtocolError(); + } + return value as Record; +} + +function jsonRecord(value: unknown): Record { + return record(JSON.parse(canonicalJson(value))); +} + +function assertKeys( + input: Record, + keys: readonly string[], +): void { + if (Object.keys(input).some((key) => !keys.includes(key))) { + throw new ManagedToolProtocolError(); + } +} + +function boundedId(value: unknown, maxLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength || + value.includes('\0') + ) { + throw new ManagedToolProtocolError(); + } + return value; +} + +function digest(value: unknown): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new ManagedToolProtocolError(); + } + return value; +} + +function identityFields( + input: Record, +): ManagedToolCallIdentity { + const sessionId = boundedId(input['sessionId'], 256); + if (!SESSION_ID_PATTERN.test(sessionId)) throw new ManagedToolProtocolError(); + return { + sessionId: sessionId.toLowerCase(), + promptId: boundedId(input['promptId'], 128), + callId: boundedId(input['callId'], 512), + capabilityDigest: digest(input['capabilityDigest']), + policyRevision: boundedId(input['policyRevision'], 256), + }; +} + +export function parseManagedToolCallIdentity( + value: unknown, +): ManagedToolCallIdentity { + const input = jsonRecord(value); + assertKeys(input, IDENTITY_KEYS); + return identityFields(input); +} + +export function parseManagedToolInvocationReference( + value: unknown, +): ManagedToolInvocationReference { + const input = jsonRecord(value); + assertKeys(input, [...IDENTITY_KEYS, 'invocationId', 'argsDigest']); + return { + ...identityFields(input), + invocationId: boundedId(input['invocationId'], 128), + argsDigest: digest(input['argsDigest']), + }; +} + +export function parseManagedToolContentModification( + value: unknown, +): ManagedToolContentModification { + const input = jsonRecord(value); + assertKeys(input, ['source', 'newContent']); + if (typeof input['newContent'] !== 'string') + throw new ManagedToolProtocolError(); + return { + source: parseManagedToolInvocationReference(input['source']), + newContent: input['newContent'], + }; +} + +export function parseManagedToolMediaContext( + value: unknown, +): ManagedToolMediaContext { + const input = jsonRecord(value); + assertKeys(input, ['inputModalities']); + const modalities = record(input['inputModalities']); + assertKeys(modalities, ['image', 'pdf', 'audio', 'video']); + if ( + Object.values(modalities).some((enabled) => typeof enabled !== 'boolean') + ) { + throw new ManagedToolProtocolError(); + } + return { inputModalities: modalities as InputModalities }; +} + +export function serializeManagedToolConfirmation( + details: ToolCallConfirmationDetails, +): ManagedToolConfirmationDetails { + if (details.type === 'plan' || details.type === 'ask_user_question') { + throw new ManagedToolProtocolError('Confirmation belongs to the Gateway.'); + } + const base = { + title: details.title, + ...(details.hideAlwaysAllow !== undefined + ? { hideAlwaysAllow: details.hideAlwaysAllow } + : {}), + ...(details.autoModeFallback !== undefined + ? { + autoModeFallback: { + reason: details.autoModeFallback.reason, + message: details.autoModeFallback.message, + }, + } + : {}), + }; + let result: ManagedToolConfirmationDetails; + switch (details.type) { + case 'edit': + result = { + ...base, + type: 'edit', + fileName: details.fileName, + filePath: details.filePath, + fileDiff: details.fileDiff, + originalContent: details.originalContent, + newContent: details.newContent, + ...(details.isModifying !== undefined + ? { isModifying: details.isModifying } + : {}), + ...(details.hideModify !== undefined + ? { hideModify: details.hideModify } + : {}), + ...(details.skipIdeDiff !== undefined + ? { skipIdeDiff: details.skipIdeDiff } + : {}), + ...(details.warnings !== undefined + ? { warnings: details.warnings } + : {}), + }; + break; + case 'exec': + result = { + ...base, + type: 'exec', + command: details.command, + rootCommand: details.rootCommand, + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + ...(details.warnings !== undefined + ? { warnings: details.warnings } + : {}), + }; + break; + case 'mcp': + result = { + ...base, + type: 'mcp', + serverName: details.serverName, + toolName: details.toolName, + toolDisplayName: details.toolDisplayName, + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + }; + break; + case 'info': + result = { + ...base, + type: 'info', + prompt: details.prompt, + ...(details.renderPromptAsPlainText !== undefined + ? { renderPromptAsPlainText: details.renderPromptAsPlainText } + : {}), + ...(details.urls !== undefined ? { urls: details.urls } : {}), + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + }; + break; + default: + throw new ManagedToolProtocolError( + 'Confirmation belongs to the Gateway.', + ); + } + return JSON.parse(canonicalJson(result)) as ManagedToolConfirmationDetails; +} + +export function parseManagedToolConfirmationPayload( + value: unknown, +): ToolConfirmationPayload | undefined { + if (value === undefined) return undefined; + const input = jsonRecord(value); + assertKeys(input, [ + 'newContent', + 'cancelMessage', + 'permissionRules', + 'answers', + 'updatedInput', + ]); + const result: ToolConfirmationPayload = {}; + for (const key of ['newContent', 'cancelMessage'] as const) { + if (input[key] !== undefined) { + if (typeof input[key] !== 'string') throw new ManagedToolProtocolError(); + result[key] = input[key]; + } + } + const rules = input['permissionRules']; + if (rules !== undefined) { + if ( + !Array.isArray(rules) || + rules.some((rule) => typeof rule !== 'string') + ) { + throw new ManagedToolProtocolError(); + } + result.permissionRules = rules; + } + if (input['answers'] !== undefined) { + const answers = record(input['answers']); + if (Object.values(answers).some((answer) => typeof answer !== 'string')) { + throw new ManagedToolProtocolError(); + } + result.answers = answers as Record; + } + if (input['updatedInput'] !== undefined) { + result.updatedInput = record(input['updatedInput']); + } + return result; +} diff --git a/packages/core/src/tools/managed-tool-runtime.test.ts b/packages/core/src/tools/managed-tool-runtime.test.ts new file mode 100644 index 00000000000..32e5c9251a7 --- /dev/null +++ b/packages/core/src/tools/managed-tool-runtime.test.ts @@ -0,0 +1,845 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../config/config.js'; +import { getInvocationContext } from '../utils/invocation-context.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolConfirmationOutcome, + type ToolCallConfirmationDetails, + type ToolResult, + type ToolResultDisplay, +} from './tools.js'; +import { + ManagedToolRuntime, + createBuiltinManagedToolRuntime, + type ManagedToolExecutionResult, + type ManagedToolRuntimeFileHistory, +} from './managed-tool-runtime.js'; +import type { + ManagedToolCallIdentity, + ManagedToolInvocationReference, + ManagedToolPrepareResponse, +} from './managed-tool-protocol.js'; + +const hooks = vi.hoisted(() => ({ + pre: vi.fn(), + post: vi.fn(), + failure: vi.fn(), +})); +vi.mock('../core/toolHookTriggers.js', () => ({ + generateToolUseId: () => 'test-tool-use-id', + firePreToolUseHook: hooks.pre, + firePostToolUseHook: hooks.post, + firePostToolUseFailureHook: hooks.failure, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const sessionId = 'c911c54f-ad76-420f-8c76-fb124c0ce623'; +const rawResult: ToolResult = { llmContent: 'done', returnDisplay: 'Done' }; +const input = { value: 'original' }; + +class FixtureInvocation extends BaseToolInvocation< + { value: string }, + ToolResult +> { + getDescription() { + return `write ${this.params.value}`; + } + override toolLocations() { + return [{ path: '/scratch/output' }]; + } + override getDefaultPermission = vi.fn(async () => 'ask' as const); + onConfirm = vi.fn(async () => {}); + override getConfirmationDetails = vi.fn( + async (_signal: AbortSignal): Promise => ({ + type: 'info', + title: 'Write', + prompt: this.getDescription(), + onConfirm: this.onConfirm, + }), + ); + execute = vi.fn( + async ( + _signal: AbortSignal, + _update?: (output: ToolResultDisplay) => void, + ): Promise => structuredClone(rawResult), + ); +} + +class FixtureTool extends BaseDeclarativeTool<{ value: string }, ToolResult> { + invocations: FixtureInvocation[] = []; + setup = (_invocation: FixtureInvocation) => {}; + constructor() { + super('fixture_write', 'Write', 'Write fixture', Kind.Edit, { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }); + } + protected createInvocation(params: { value: string }) { + const invocation = new FixtureInvocation(params); + this.setup(invocation); + this.invocations.push(invocation); + return invocation; + } +} + +function reference( + value: ManagedToolPrepareResponse, +): ManagedToolInvocationReference { + const { + sessionId, + promptId, + callId, + capabilityDigest, + policyRevision, + invocationId, + argsDigest, + } = value; + return { + sessionId, + promptId, + callId, + capabilityDigest, + policyRevision, + invocationId, + argsDigest, + }; +} + +describe('ManagedToolRuntime', () => { + let tool: FixtureTool; + let runtime: ManagedToolRuntime; + let snapshot: ReturnType; + let revision: string; + let identity: ManagedToolCallIdentity; + let events: string[]; + let config: Config; + + beforeEach(() => { + vi.resetAllMocks(); + events = []; + hooks.pre.mockImplementation(async () => { + events.push('preflight'); + return { shouldProceed: true }; + }); + hooks.post.mockImplementation(async () => { + events.push('post'); + return { shouldStop: false }; + }); + hooks.failure.mockResolvedValue({}); + snapshot = vi.fn(async () => { + events.push('snapshot'); + }); + config = { + getSessionId: () => sessionId, + getDisableAllHooks: () => false, + getMessageBus: () => undefined, + getApprovalMode: () => ApprovalMode.DEFAULT, + getFileHistoryService: () => ({ makeSnapshot: snapshot }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + showColor: false, + }), + } as unknown as Config; + revision = 'workspace-generation-1'; + tool = new FixtureTool(); + runtime = new ManagedToolRuntime( + config, + () => [tool], + () => revision, + ); + identity = { + sessionId, + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: runtime.manifest().capabilityDigest, + policyRevision: revision, + }; + }); + afterEach(async () => { + await runtime.dispose(); + }); + + async function prepare(args = input, call = identity) { + await runtime.beginTurn(call); + return reference(await runtime.prepare(call, tool.name, args)); + } + + function useSharedHistory(history: ManagedToolRuntimeFileHistory) { + runtime = new ManagedToolRuntime( + config, + () => [tool], + () => revision, + history, + ); + } + + it('waits for the shared parent history without creating child snapshots', async () => { + const ready = deferred(); + const prepareTurn = vi.fn(() => ready.promise); + useSharedHistory({ prepareTurn, execute: (operation) => operation() }); + const beginning = runtime.beginTurn(identity); + const preparing = runtime.prepare(identity, tool.name, input); + await Promise.resolve(); + expect(prepareTurn).toHaveBeenCalledExactlyOnceWith(identity); + expect(tool.invocations).toHaveLength(0); + expect(snapshot).not.toHaveBeenCalled(); + ready.resolve(); + await beginning; + const ref = reference(await preparing); + await runtime.preflight(ref); + await runtime.execute(ref); + await runtime.beginTurn({ ...identity, promptId: 'child-prompt-2' }); + expect(prepareTurn).toHaveBeenCalledTimes(2); + expect(snapshot).not.toHaveBeenCalled(); + }); + + it.each(['cancel', 'policy', 'session'] as const)( + 'settles queued %s invalidation without physically executing the tool', + async (change) => { + const gate = deferred(); + const execute = vi.fn( + async (operation: () => Promise) => { + await gate.promise; + return operation(); + }, + ); + useSharedHistory({ prepareTurn: async () => {}, execute }); + const ref = await prepare(); + await runtime.preflight(ref); + const pending = runtime.execute(ref); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + if (change === 'cancel') runtime.cancel(ref); + if (change === 'policy') revision = 'replaced-policy'; + if (change === 'session') { + vi.spyOn(config, 'getSessionId').mockReturnValue( + '4f17902c-bb4c-4ae8-bb5e-a3f25753a6ab', + ); + } + expect(runtime.status(ref).state).not.toBe('settled'); + gate.resolve(); + expect(await pending).toMatchObject({ + executionStatus: 'not_started', + error: { message: expect.any(String) }, + }); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + result: { executionStatus: 'not_started' }, + }); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + expect(hooks.post).not.toHaveBeenCalled(); + expect(hooks.failure).not.toHaveBeenCalled(); + }, + ); + + it('waits for queued execution cancellation before Runtime disposal finishes', async () => { + const gate = deferred(); + useSharedHistory({ + prepareTurn: async () => {}, + execute: async (operation) => { + await gate.promise; + return operation(); + }, + }); + const ref = await prepare(); + await runtime.preflight(ref); + const pending = runtime.execute(ref); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + gate.resolve(); + expect((await pending).executionStatus).toBe('not_started'); + await disposal; + expect(disposed).toBe(true); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('binds admitted builtins once to the child tool Config and forwards shared history', async () => { + const { ReadFileTool } = await import('./read-file.js'); + const parentRead = new ReadFileTool(config); + const parentBuild = vi.spyOn(parentRead, 'build'); + const getTool = vi.fn((name: string) => + name === ReadFileTool.Name ? parentRead : tool, + ); + config.getToolRegistry = () => + ({ + getTool, + ensureTool: vi.fn(async () => undefined), + }) as unknown as ReturnType; + config.isLsToolEnabled = () => false; + const childConfig = Object.assign(Object.create(config) as Config, { + getTargetDir: () => '/managed-child', + getFileService: () => ({ shouldQwenIgnoreFile: () => false }), + getWorkspaceContext: () => ({ isPathWithinWorkspace: () => true }), + getPlansDir: () => '/managed-child/plans', + storage: { + getProjectTempDir: () => '/managed-child/tmp', + getProjectDir: () => '/managed-child', + getUserSkillsDirs: () => [], + getWorkflowRunsDir: () => '/managed-child/workflow-runs', + }, + }); + const prepareTurn = vi.fn(async () => {}); + const build = vi.spyOn(ReadFileTool.prototype, 'build'); + try { + runtime = await createBuiltinManagedToolRuntime( + config, + { + prepareTurn, + execute: (operation) => operation(), + }, + childConfig, + ); + const manifest = runtime.manifest(); + expect(manifest.tools.map(({ name }) => name)).toEqual([ + ReadFileTool.Name, + ]); + expect(runtime.manifest()).toEqual(manifest); + const call = { + ...identity, + policyRevision: manifest.policyRevision, + capabilityDigest: manifest.capabilityDigest, + }; + await runtime.beginTurn(call); + const first = await runtime.prepare(call, ReadFileTool.Name, { + file_path: '/managed-child/a.txt', + }); + await runtime.prepare({ ...call, callId: 'call-2' }, ReadFileTool.Name, { + file_path: '/managed-child/b.txt', + }); + expect(first.description).toBe('a.txt'); + expect(first.defaultPermission).toBe('allow'); + expect(first.sessionId).toBe(sessionId); + expect(prepareTurn).toHaveBeenCalledExactlyOnceWith(call); + expect(snapshot).not.toHaveBeenCalled(); + expect(parentBuild).not.toHaveBeenCalled(); + expect(build).toHaveBeenCalledTimes(2); + expect(build.mock.instances[0]).toBe(build.mock.instances[1]); + expect(build.mock.instances[0]).not.toBe(parentRead); + getTool.mockReturnValue(tool); + expect(runtime.manifest().tools).toEqual([]); + await expect(runtime.preflight(reference(first))).rejects.toThrow( + 'capability changed', + ); + } finally { + build.mockRestore(); + parentBuild.mockRestore(); + } + }); + + it('prepares once without execution and checkpoints at the explicit turn boundary', async () => { + await runtime.beginTurn(identity); + const prepared = await runtime.prepare(identity, tool.name, input); + const repeated = await runtime.prepare(identity, tool.name, { + value: 'original', + }); + expect(repeated).toEqual(prepared); + expect(tool.invocations).toHaveLength(1); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + expect(prepared).toMatchObject({ + params: input, + description: 'write original', + defaultPermission: 'ask', + locations: [{ path: '/scratch/output' }], + }); + expect(snapshot).toHaveBeenCalledExactlyOnceWith(identity.promptId); + expect(runtime.hasActiveWork()).toBe(true); + }); + + it('rejects preparation without a turn and does not retain a rejected call', async () => { + await expect(runtime.prepare(identity, tool.name, input)).rejects.toThrow( + 'turn has not started', + ); + await runtime.beginTurn(identity); + const prepared = await runtime.prepare(identity, tool.name, input); + expect(prepared.params).toEqual(input); + }); + + it('serializes concurrent preparations and owns copies of their identity and args', async () => { + const gate = deferred<'ask'>(); + tool.setup = (invocation) => + invocation.getDefaultPermission.mockReturnValue(gate.promise); + await runtime.beginTurn(identity); + const mutableIdentity = { ...identity }; + const mutableArgs = { ...input }; + const first = runtime.prepare(mutableIdentity, tool.name, mutableArgs); + const second = runtime.prepare(identity, tool.name, input); + mutableIdentity.callId = 'changed'; + mutableArgs.value = 'changed'; + await vi.waitFor(() => expect(tool.invocations).toHaveLength(1)); + gate.resolve('ask'); + const [a, b] = await Promise.all([first, second]); + expect(a).toEqual(b); + expect(a.callId).toBe(identity.callId); + expect(a.params).toEqual(input); + }); + + it('does not let a conflicting prepare erase the original idempotency record', async () => { + const prepared = await prepare(); + await expect( + runtime.prepare(identity, tool.name, { value: 'changed' }), + ).rejects.toThrow('already owns'); + expect( + reference(await runtime.prepare(identity, tool.name, input)), + ).toEqual(prepared); + expect(tool.invocations).toHaveLength(1); + }); + + it('allows queued retry after a rejected build', async () => { + await runtime.beginTurn(identity); + const bad = runtime.prepare(identity, tool.name, {}); + const retry = runtime.prepare(identity, tool.name, input); + await expect(bad).rejects.toThrow(); + expect((await retry).params).toEqual(input); + }); + + it('build, confirmation, preflight and execution carry the original invocation context', async () => { + const assertContext = () => { + expect(getInvocationContext()).toEqual({ + version: 1, + sessionId, + promptId: identity.promptId, + }); + expect(promptIdContext.getStore()).toBe(identity.promptId); + }; + tool.setup = (invocation) => { + assertContext(); + invocation.getDefaultPermission.mockImplementation(async () => { + assertContext(); + return 'ask'; + }); + invocation.onConfirm.mockImplementation(async () => { + assertContext(); + }); + invocation.execute.mockImplementation(async () => { + assertContext(); + return rawResult; + }); + }; + hooks.pre.mockImplementation(async () => { + assertContext(); + return { shouldProceed: true }; + }); + hooks.post.mockImplementation(async () => { + assertContext(); + return { shouldStop: false }; + }); + const ref = await prepare(); + const dto = await runtime.confirmation(ref); + expect(dto).not.toHaveProperty('onConfirm'); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce); + await runtime.preflight(ref); + await runtime.execute(ref); + }); + + it('leaves an always choice to the Harness and approves the invocation once locally', async () => { + tool.setup = (invocation) => { + invocation.getDefaultPermission.mockResolvedValue('ask'); + }; + const ref = await prepare(); + await runtime.confirmation(ref); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedAlways); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + undefined, + ); + // The decision itself is still recorded as the choice the Harness made. + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce), + ).rejects.toThrow('already decided'); + }); + + it('requires preflight and supports trusted automatic allow without onConfirm', async () => { + const ref = await prepare(); + expect(() => runtime.execute(ref)).toThrow('preflight'); + await runtime.preflight(ref); + expect(await runtime.execute(ref)).toMatchObject({ + executionStatus: 'success', + result: rawResult, + }); + expect(tool.invocations[0].onConfirm).not.toHaveBeenCalled(); + expect(events).toEqual(['snapshot', 'preflight', 'post']); + }); + + it('runs the preflight hook once across the second confirmation bounce', async () => { + hooks.pre.mockResolvedValue({ shouldProceed: false, blockType: 'ask' }); + const ref = await prepare(); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce); + await runtime.preflight(ref); + expect(() => runtime.execute(ref)).toThrow('preflight'); + await runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ); + await runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ); + await runtime.preflight(ref); + expect((await runtime.execute(ref)).executionStatus).toBe('success'); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(2); + expect(hooks.pre).toHaveBeenCalledTimes(1); + expect(hooks.pre.mock.calls[0][3]).toBe(hooks.post.mock.calls[0][4]); + }); + + it.each(['denied', 'stop'])( + 'cannot confirm away a preflight %s', + async (blockType) => { + hooks.pre.mockResolvedValue({ shouldProceed: false, blockType }); + const ref = await prepare(); + await runtime.preflight(ref); + await expect( + runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ), + ).rejects.toThrow('did not request'); + expect(() => runtime.execute(ref)).toThrow('preflight'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }, + ); + + it('waits for permission callback before preflight and rejects contradictory confirmation', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.onConfirm.mockReturnValue(gate.promise); + const ref = await prepare(); + const confirming = runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + ); + const flight = runtime.preflight(ref); + await vi.waitFor(() => + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1), + ); + expect(hooks.pre).not.toHaveBeenCalled(); + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedAlways), + ).rejects.toThrow('already decided'); + gate.resolve(); + await confirming; + await flight; + await runtime.execute(ref); + }); + + it('invalidates modified arguments and permits exactly one replacement build', async () => { + const ref = await prepare(); + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce, { + updatedInput: { value: 'changed' }, + }), + ).rejects.toThrow('new preparation'); + const [a, b] = await Promise.all([ + runtime.prepare(identity, tool.name, { value: 'changed' }), + runtime.prepare(identity, tool.name, { value: 'changed' }), + ]); + expect(a.invocationId).not.toBe(ref.invocationId); + expect(a).toEqual(b); + expect(() => runtime.execute(ref)).toThrow('cancelled'); + expect(tool.invocations[0].onConfirm).not.toHaveBeenCalled(); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + await runtime.preflight(reference(a)); + await runtime.execute(reference(a)); + expect(tool.invocations[1].execute).toHaveBeenCalledTimes(1); + }); + + it('rejects parameters mutated after prepare before any side effect', async () => { + const ref = await prepare(); + await runtime.preflight(ref); + tool.invocations[0].params.value = 'modified'; + expect(() => runtime.execute(ref)).toThrow('parameters changed'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it.each([ + 'sessionId', + 'promptId', + 'callId', + 'invocationId', + 'capabilityDigest', + 'policyRevision', + 'argsDigest', + ] as const)( + 'rejects a forged %s for cancel/status/execute', + async (field) => { + const ref = await prepare(); + const forged = { ...ref, [field]: 'forged' }; + expect(() => runtime.cancel(forged)).toThrow('identity'); + expect(() => runtime.status(forged)).toThrow('identity'); + expect(() => runtime.execute(forged)).toThrow('identity'); + expect(runtime.status(ref).cancelRequested).toBe(false); + }, + ); + + it('rejects stale policy for new execution but preserves a completed result', async () => { + const first = await prepare(); + await runtime.preflight(first); + await runtime.execute(first); + const second = reference( + await runtime.prepare( + { ...identity, callId: 'call-2' }, + tool.name, + input, + ), + ); + await runtime.preflight(second); + revision = 'generation-2'; + expect(() => runtime.execute(second)).toThrow('capability changed'); + expect((await runtime.execute(first)).executionStatus).toBe('success'); + expect(runtime.status(first).result?.executionStatus).toBe('success'); + }); + + it('executes at most once across concurrent delivery and a retry after a lost response', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const first = runtime.execute(ref); + const duplicate = runtime.execute(ref); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + expect(runtime.status(ref).state).toBe('executing'); + gate.resolve(rawResult); + const [a, b] = await Promise.all([first, duplicate]); + expect(a).toEqual(b); + a.result!.llmContent = 'modified client copy'; + expect((await runtime.execute(ref)).result?.llmContent).toBe('done'); + expect(runtime.status(ref).result?.result?.llmContent).toBe('done'); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + }); + + it('does not turn a successful side effect into a failure when a post hook stops or fails', async () => { + hooks.post + .mockResolvedValueOnce({ shouldStop: true, stopReason: 'policy stop' }) + .mockRejectedValueOnce(new Error('hook failed')); + const first = await prepare(); + await runtime.preflight(first); + expect(await runtime.execute(first)).toMatchObject({ + executionStatus: 'success', + postHook: { shouldStop: true }, + }); + const second = reference( + await runtime.prepare( + { ...identity, callId: 'call-2' }, + tool.name, + input, + ), + ); + await runtime.preflight(second); + expect(await runtime.execute(second)).toMatchObject({ + executionStatus: 'success', + postHook: { hookError: 'hook failed' }, + }); + expect(hooks.failure).not.toHaveBeenCalled(); + }); + + it('records failure once and drops model control fields from workspace output', async () => { + tool.setup = (invocation) => + invocation.execute.mockResolvedValue({ + ...rawResult, + modelOverride: 'foreign-model', + terminateTurn: true, + persistedOutputFiles: [], + resultFilePaths: ['/scratch/output'], + error: { message: 'write failed' }, + }); + const ref = await prepare(); + await runtime.preflight(ref); + const result = await runtime.execute(ref); + expect(result.executionStatus).toBe('error'); + expect(result.result).toMatchObject({ + persistedOutputFiles: [], + resultFilePaths: ['/scratch/output'], + }); + expect(result.result).not.toHaveProperty('modelOverride'); + expect(result.result).not.toHaveProperty('terminateTurn'); + await runtime.execute(ref); + expect(hooks.failure).toHaveBeenCalledTimes(1); + expect(hooks.post).not.toHaveBeenCalled(); + }); + + it('reports progress gaps and keeps the final result after dropping oversized progress', async () => { + tool.setup = (invocation) => + invocation.execute.mockImplementation(async (_signal, update) => { + update!('first'); + update!('x'.repeat(1024 * 1024)); + update!('last'); + return rawResult; + }); + const ref = await prepare(); + await runtime.preflight(ref); + await runtime.execute(ref); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + lastSeq: 3, + firstAvailableSeq: 3, + progressGap: true, + progress: [{ seq: 3, output: 'last' }], + }); + expect(runtime.status(ref, 2).progressGap).toBe(false); + expect(() => runtime.status(ref, -1)).toThrow('cursor'); + }); + + it('separates a cancel acknowledgment from underlying execution settlement and shutdown', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const executing = runtime.execute(ref); + expect(runtime.cancel(ref)).toMatchObject({ + state: 'cancel_requested', + cancelRequested: true, + }); + expect(runtime.hasActiveWork()).toBe(true); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + expect(tool.invocations[0].execute.mock.calls[0][0].aborted).toBe(true); + gate.resolve({ ...rawResult, error: { message: 'cancelled' } }); + expect((await executing).executionStatus).toBe('cancelled'); + await disposal; + expect(disposed).toBe(true); + expect(hooks.failure).toHaveBeenCalledTimes(1); + }); + + it.each(['cancelled', 'not_started'] as const)( + 'preserves explicit Shell %s without inventing a tool error', + async (executionStatus) => { + tool.setup = (invocation) => + invocation.execute.mockResolvedValue({ ...rawResult, executionStatus }); + const ref = await prepare(); + await runtime.preflight(ref); + expect(await runtime.execute(ref)).toMatchObject({ + executionStatus, + result: rawResult, + }); + expect(hooks.post).not.toHaveBeenCalled(); + expect(hooks.failure).toHaveBeenCalledTimes( + executionStatus === 'cancelled' ? 1 : 0, + ); + }, + ); + + it('does not fabricate cancellation when an already running write actually succeeds', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const executing = runtime.execute(ref); + runtime.cancel(ref); + gate.resolve(rawResult); + expect((await executing).executionStatus).toBe('success'); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + cancelRequested: true, + }); + }); + + it('keeps a prepared cancellation un-settled until its confirmation callback returns', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.onConfirm.mockReturnValue(gate.promise); + const ref = await prepare(); + const confirming = runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + ); + const rejection = confirming.catch((error: unknown) => error); + await vi.waitFor(() => + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1), + ); + expect(runtime.cancel(ref).state).toBe('cancel_requested'); + expect(runtime.hasActiveWork()).toBe(true); + gate.resolve(); + expect(await rejection).toMatchObject({ + message: expect.stringContaining('cancelled'), + }); + await vi.waitFor(() => expect(runtime.status(ref).state).toBe('settled')); + expect(runtime.status(ref).result?.executionStatus).toBe('not_started'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('replays cancel confirmation idempotently and never runs the tool', async () => { + const ref = await prepare(); + await runtime.confirm(ref, ToolConfirmationOutcome.Cancel); + await runtime.confirm(ref, ToolConfirmationOutcome.Cancel); + await vi.waitFor(() => expect(runtime.status(ref).state).toBe('settled')); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('does not switch turns around queued preparations or pending snapshots', async () => { + const gate = deferred(); + snapshot.mockReturnValue(gate.promise); + const starting = runtime.beginTurn(identity); + expect(() => runtime.beginTurn({ ...identity, promptId: 'other' })).toThrow( + 'unfinished', + ); + expect(runtime.hasActiveWork()).toBe(true); + gate.resolve(); + await starting; + const pending = runtime.prepare(identity, tool.name, input); + expect(() => runtime.beginTurn({ ...identity, promptId: 'other' })).toThrow( + 'unfinished', + ); + await pending; + }); + + it('seals admission immediately and waits for a build that has not yet returned an id', async () => { + const gate = deferred<'ask'>(); + tool.setup = (invocation) => + invocation.getDefaultPermission.mockReturnValue(gate.promise); + await runtime.beginTurn(identity); + const preparing = runtime.prepare(identity, tool.name, input); + const rejection = preparing.catch((error: unknown) => error); + await vi.waitFor(() => expect(tool.invocations).toHaveLength(1)); + runtime.seal(); + expect(() => runtime.beginTurn(identity)).toThrow('closed'); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + gate.resolve('ask'); + expect(await rejection).toMatchObject({ + message: expect.stringContaining('closed'), + }); + await disposal; + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tools/managed-tool-runtime.ts b/packages/core/src/tools/managed-tool-runtime.ts new file mode 100644 index 00000000000..cc57406062f --- /dev/null +++ b/packages/core/src/tools/managed-tool-runtime.ts @@ -0,0 +1,961 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { canUseRipgrep } from '../utils/ripgrepUtils.js'; +import { deriveConfig, type Config } from '../config/config.js'; +import { + firePreToolUseHook, + firePostToolUseHook, + firePostToolUseFailureHook, + generateToolUseId, + type PreToolUseHookResult, + type PostToolUseHookResult, + type PostToolUseFailureHookResult, +} from '../core/toolHookTriggers.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import { runWithInvocationContext } from '../utils/invocation-context.js'; +import { + ToolConfirmationOutcome, + type AnyDeclarativeTool, + type AnyToolInvocation, + type ToolCallConfirmationDetails, + type ToolConfirmationPayload, + type ToolResult, + type ToolResultDisplay, +} from './tools.js'; +import { ToolNames } from './tool-names.js'; +import { isModifiableDeclarativeTool } from './modifiable-tool.js'; +import { ToolErrorType } from './tool-error.js'; +import { + managedToolDigest, + parseManagedToolContentModification, + parseManagedToolMediaContext, + type ManagedToolMediaContext, + type ManagedToolContentModification, + serializeManagedToolConfirmation, + type ManagedToolCallIdentity, + type ManagedToolInvocationReference, + type ManagedToolPrepareResponse, + type ManagedToolDescriptor, +} from './managed-tool-protocol.js'; + +import type { ManagedToolFileHistoryClient } from './managed-tool-file-history-protocol.js'; + +export type ManagedToolConfirmationPhase = 'permission' | 'preflight'; + +export type ManagedToolV2Client = { + [K in + | 'manifest' + | 'beginTurn' + | 'prepare' + | 'confirmation' + | 'confirm' + | 'preflight' + | 'execute' + | 'status' + | 'cancel']: ( + ...args: Parameters + ) => Promise>>; +} & { + fileHistory?: ManagedToolFileHistoryClient; + prepareExecution?: ( + reference: ManagedToolInvocationReference, + ) => Promise; + startExecution?: ( + reference: ManagedToolInvocationReference, + executionCallId: string, + ) => Promise; +}; + +export interface ManagedToolExecutionReservation { + executionCallId: string; + invocationBindingId: string; +} + +export interface ManagedToolExecutionResult { + executionStatus: 'not_started' | 'success' | 'error' | 'cancelled'; + result?: ToolResult; + error?: { message: string; type?: ToolErrorType }; + postHook?: PostToolUseHookResult; + failureHook?: PostToolUseFailureHookResult; +} + +export interface ManagedToolRuntimeFileHistory { + prepareTurn(identity: ManagedToolCallIdentity): Promise; + execute( + operation: () => Promise, + ): Promise; +} + +export interface ManagedToolProgress { + seq: number; + output: ToolResultDisplay; +} + +export interface ManagedToolInvocationStatus { + state: 'prepared' | 'executing' | 'cancel_requested' | 'settled'; + cancelRequested: boolean; + lastSeq: number; + firstAvailableSeq: number; + progressGap: boolean; + progress: ManagedToolProgress[]; + result?: ManagedToolExecutionResult; +} + +interface Decision { + digest: string; + promise: Promise; + settled: boolean; +} + +interface CallSlot { + current?: Entry; + pending?: Promise; +} + +interface Entry { + readonly reference: ManagedToolInvocationReference; + readonly inputDigest: string; + readonly tool: AnyDeclarativeTool; + readonly invocation: AnyToolInvocation; + readonly controller: AbortController; + readonly toolUseId: string; + readonly prepared: ManagedToolPrepareResponse; + confirmation?: Promise; + decisions: Map; + preflight?: Promise; + preflightResult?: PreToolUseHookResult; + hookConfirmed: boolean; + execution?: Promise; + cancellation?: Promise; + result?: ManagedToolExecutionResult; + progress: Array; + progressBytes: number; + sequence: number; +} + +const MAX_INVOCATIONS = 1024; +const MAX_PROGRESS_BYTES = 1024 * 1024; + +/** Owned by one Tool-only Session; only the authenticated parent may dispatch. */ +export class ManagedToolRuntime { + private readonly entries = new Map(); + private readonly calls = new Map(); + private readonly lifetime = new AbortController(); + private activePrompt?: { id: string; snapshot: Promise }; + private pendingBuilds = 0; + private pendingPreparations = 0; + private snapshotPending = false; + private readonly startedPrompts = new Set(); + private disposal?: Promise; + + constructor( + private readonly config: Config, + private readonly tools: () => AnyDeclarativeTool[], + private readonly policyRevision: () => string, + private readonly sharedFileHistory?: ManagedToolRuntimeFileHistory, + private readonly bindMediaTool?: ( + tool: AnyDeclarativeTool, + context: ManagedToolMediaContext, + ) => AnyDeclarativeTool, + ) {} + + manifest(): { + tools: ManagedToolDescriptor[]; + capabilityDigest: string; + policyRevision: string; + } { + this.lifetime.signal.throwIfAborted(); + const tools = this.tools().map( + (tool): ManagedToolDescriptor => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + kind: tool.kind, + schema: tool.schema, + canUpdateOutput: tool.canUpdateOutput, + isOutputMarkdown: tool.isOutputMarkdown, + shouldDefer: tool.shouldDefer, + alwaysLoad: tool.alwaysLoad, + ...(tool.searchHint === undefined + ? {} + : { searchHint: tool.searchHint }), + ...(tool.maxOutputChars === undefined + ? {} + : { + maxOutputChars: + tool.maxOutputChars === Infinity + ? ('unlimited' as const) + : tool.maxOutputChars, + }), + truncateKeep: tool.truncateKeep, + }), + ); + return { + tools: structuredClone(tools), + capabilityDigest: managedToolDigest(tools, 1024 * 1024), + policyRevision: this.policyRevision(), + }; + } + + private assertCurrent(identity: ManagedToolCallIdentity): void { + this.lifetime.signal.throwIfAborted(); + const current = this.manifest(); + if ( + identity.sessionId !== this.config.getSessionId().toLowerCase() || + identity.capabilityDigest !== current.capabilityDigest || + identity.policyRevision !== current.policyRevision + ) + throw new Error('Managed tool Session or capability changed.'); + } + + private get(reference: ManagedToolInvocationReference): Entry { + const entry = this.entries.get(reference.invocationId); + if ( + !entry || + managedToolDigest(reference) !== managedToolDigest(entry.reference) + ) { + throw new Error('Managed tool invocation identity does not match.'); + } + return entry; + } + + private scoped(identity: ManagedToolCallIdentity, action: () => T): T { + return promptIdContext.run(identity.promptId, () => + runWithInvocationContext( + { + version: 1, + sessionId: identity.sessionId, + promptId: identity.promptId, + }, + action, + ), + ); + } + + beginTurn(identity: ManagedToolCallIdentity): Promise { + this.assertCurrent(identity); + if (this.activePrompt?.id === identity.promptId) + return this.activePrompt.snapshot; + if ( + this.snapshotPending || + this.pendingPreparations || + [...this.entries.values()].some((entry) => !entry.result) + ) { + throw new Error( + 'Managed Runtime still owns an unfinished tool invocation.', + ); + } + if (this.startedPrompts.has(identity.promptId)) { + throw new Error('Managed Runtime cannot reopen a previous tool turn.'); + } + identity = structuredClone(identity); + this.snapshotPending = true; + this.startedPrompts.add(identity.promptId); + const snapshot = this.scoped(identity, async () => { + try { + if (this.sharedFileHistory) { + await this.sharedFileHistory.prepareTurn(identity); + } else { + await this.config + .getFileHistoryService() + .makeSnapshot(identity.promptId); + } + this.assertCurrent(identity); + } finally { + this.snapshotPending = false; + } + }); + this.activePrompt = { id: identity.promptId, snapshot }; + void snapshot.catch(() => {}); + return snapshot; + } + + prepare( + identity: ManagedToolCallIdentity, + toolName: string, + input: Record, + modification?: ManagedToolContentModification, + mediaContext?: ManagedToolMediaContext, + ): Promise { + this.assertCurrent(identity); + managedToolDigest(input); + identity = structuredClone(identity); + const copied = structuredClone(input); + const media = + mediaContext === undefined + ? undefined + : parseManagedToolMediaContext(mediaContext); + const contentModification = + modification === undefined + ? undefined + : parseManagedToolContentModification(modification); + const inputDigest = managedToolDigest( + { + identity, + toolName, + input: copied, + ...(media === undefined ? {} : { mediaContext: media }), + ...(contentModification === undefined + ? {} + : { modification: contentModification }), + }, + 1024 * 1024, + ); + const key = JSON.stringify([identity.promptId, identity.callId]); + const slot = this.calls.get(key) ?? {}; + const previous = slot.pending; + this.pendingPreparations++; + const pending = (async () => { + // A rejected preparation owns no invocation; queued retries still build. + await previous?.catch(() => {}); + const entry = slot.current; + if (entry) { + if (entry.inputDigest === inputDigest) return entry; + if (!entry.controller.signal.aborted || entry.execution) { + throw new Error('Managed tool call already owns another invocation.'); + } + await entry.cancellation; + } + this.assertCurrent(identity); + if (this.activePrompt?.id !== identity.promptId) { + throw new Error('Managed Runtime tool turn has not started.'); + } + if (this.entries.size + this.pendingBuilds >= MAX_INVOCATIONS) { + throw new Error('Managed tool invocation capacity is exhausted.'); + } + this.pendingBuilds++; + try { + await this.activePrompt.snapshot; + this.assertCurrent(identity); + const built = await this.scoped(identity, () => + this.build( + identity, + toolName, + copied, + inputDigest, + contentModification, + entry, + media, + ), + ); + slot.current = built; + return built; + } finally { + this.pendingBuilds--; + } + })(); + void pending.then( + () => { + this.pendingPreparations--; + }, + () => { + this.pendingPreparations--; + }, + ); + slot.pending = pending; + this.calls.set(key, slot); + void pending.catch(() => { + if (slot.pending === pending && !slot.current) this.calls.delete(key); + }); + return pending.then((entry) => structuredClone(entry.prepared)); + } + + private async build( + identity: ManagedToolCallIdentity, + toolName: string, + input: Record, + inputDigest: string, + modification?: ManagedToolContentModification, + source?: Entry, + mediaContext?: ManagedToolMediaContext, + ): Promise { + let tool = this.tools().find((candidate) => candidate.name === toolName); + if (!tool) throw new Error('Managed Runtime tool is unavailable.'); + if (mediaContext !== undefined) { + if (!this.bindMediaTool) + throw new Error('Managed Runtime tool does not support media context.'); + tool = this.bindMediaTool(tool, mediaContext); + } + if (modification) { + if ( + !source || + this.get(modification.source) !== source || + source.tool !== tool || + tool.name !== ToolNames.NOTEBOOK_EDIT || + !isModifiableDeclarativeTool(tool) || + !source.controller.signal.aborted || + source.execution || + managedToolDigest(input) !== source.reference.argsDigest + ) + throw new Error('Managed content modification source does not match.'); + const details = await source.confirmation; + if ( + details?.type !== 'edit' || + typeof details.originalContent !== 'string' + ) + throw new Error( + 'Managed content modification requires an edit confirmation.', + ); + this.assertCurrent(identity); + input = tool + .getModifyContext(this.lifetime.signal) + .createUpdatedParams( + details.originalContent, + modification.newContent, + source.invocation.params, + ) as Record; + } + const invocation = tool.build(input); + const aware = invocation as { + setCallId?: (id: string) => void; + setPromptId?: (id: string) => void; + }; + aware.setCallId?.(identity.callId); + aware.setPromptId?.(identity.promptId); + const defaultPermission = await invocation.getDefaultPermission(); + this.assertCurrent(identity); + const reference: ManagedToolInvocationReference = { + ...identity, + invocationId: randomUUID(), + argsDigest: managedToolDigest(invocation.params), + }; + const toolUseId = generateToolUseId(); + const entry: Entry = { + reference, + inputDigest, + tool, + invocation, + controller: new AbortController(), + toolUseId, + prepared: { + ...reference, + params: structuredClone(invocation.params) as Record, + description: invocation.getDescription(), + locations: invocation.toolLocations(), + defaultPermission, + requiresUserInteraction: + invocation.requiresUserInteraction?.() ?? false, + toolUseId, + }, + decisions: new Map(), + hookConfirmed: false, + progress: [], + progressBytes: 0, + sequence: 0, + }; + this.entries.set(reference.invocationId, entry); + return entry; + } + + async confirmation(reference: ManagedToolInvocationReference) { + const entry = this.get(reference); + this.assertExecutable(entry); + entry.confirmation ??= this.scoped(reference, async () => { + const details = await entry.invocation.getConfirmationDetails( + entry.controller.signal, + ); + serializeManagedToolConfirmation(details); + return details; + }); + const details = await entry.confirmation; + this.assertExecutable(entry); + return serializeManagedToolConfirmation(details); + } + + async confirm( + reference: ManagedToolInvocationReference, + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + phase: ManagedToolConfirmationPhase = 'permission', + ): Promise { + const entry = this.get(reference); + reference = entry.reference; + payload = payload === undefined ? undefined : structuredClone(payload); + if (phase !== 'permission' && phase !== 'preflight') + throw new Error('Invalid managed confirmation phase.'); + if ( + !Object.values(ToolConfirmationOutcome).includes(outcome) || + outcome === ToolConfirmationOutcome.RestorePrevious + ) { + throw new Error('Unsupported managed tool confirmation outcome.'); + } + const digest = managedToolDigest({ + outcome, + ...(payload ? { payload } : {}), + }); + const previous = entry.decisions.get(phase); + if (previous) { + if (previous.digest !== digest) + throw new Error('Managed tool confirmation already decided.'); + return previous.promise; + } + this.assertExecutable(entry); + if (phase === 'permission' && entry.preflight) + throw new Error('Managed tool preflight already started.'); + if (phase === 'preflight' && entry.preflightResult?.blockType !== 'ask') + throw new Error('Managed tool preflight did not request confirmation.'); + if ( + payload?.updatedInput !== undefined || + payload?.newContent !== undefined || + outcome === ToolConfirmationOutcome.ModifyWithEditor + ) { + this.cancel(reference); + throw new Error('Modified tool arguments require a new preparation.'); + } + const pending = (async () => { + await this.confirmation(reference); + this.assertExecutable(entry); + const details = await entry.confirmation!; + await this.scoped(reference, () => + details.onConfirm(runtimeLocalOutcome(outcome), payload), + ); + if (outcome === ToolConfirmationOutcome.Cancel) { + this.requestCancel(entry); + return; + } + this.assertExecutable(entry); + if (phase === 'preflight') entry.hookConfirmed = true; + })(); + const decision: Decision = { digest, promise: pending, settled: false }; + entry.decisions.set(phase, decision); + void pending.then( + () => { + decision.settled = true; + }, + () => { + decision.settled = true; + }, + ); + return pending; + } + + private assertExecutable(entry: Entry): void { + this.assertCurrent(entry.reference); + entry.controller.signal.throwIfAborted(); + if (entry.execution) + throw new Error('Managed tool invocation already dispatched.'); + if ( + managedToolDigest(entry.invocation.params) !== entry.reference.argsDigest + ) { + throw new Error('Managed tool parameters changed after preparation.'); + } + } + + async preflight( + reference: ManagedToolInvocationReference, + ): Promise { + const entry = this.get(reference); + this.assertExecutable(entry); + entry.preflight ??= this.scoped(reference, async () => { + for (const decision of entry.decisions.values()) await decision.promise; + this.assertExecutable(entry); + const result = await firePreToolUseHook( + this.config.getDisableAllHooks() + ? undefined + : this.config.getMessageBus(), + entry.tool.name, + entry.invocation.params as Record, + entry.toolUseId, + this.config.getApprovalMode(), + entry.controller.signal, + entry.reference.callId, + ); + this.assertExecutable(entry); + entry.preflightResult = structuredClone(result); + return result; + }); + return structuredClone(await entry.preflight); + } + + /** The private parent calls this only after permission and its final guard. */ + execute( + reference: ManagedToolInvocationReference, + ): Promise { + const entry = this.get(reference); + if (entry.execution) + return entry.execution.then((result) => structuredClone(result)); + this.assertExecutable(entry); + const preflight = entry.preflightResult; + if ( + !preflight || + (!preflight.shouldProceed && + !(preflight.blockType === 'ask' && entry.hookConfirmed)) + ) { + throw new Error('Managed tool preflight has not permitted execution.'); + } + if ([...entry.decisions.values()].some((decision) => !decision.settled)) { + throw new Error('Managed tool confirmation is still pending.'); + } + entry.execution = this.scoped(reference, () => + this.sharedFileHistory + ? this.sharedFileHistory.execute(() => this.run(entry)) + : this.run(entry), + ); + void entry.execution.catch(() => {}); + return entry.execution.then((result) => structuredClone(result)); + } + + private progress(entry: Entry, output: ToolResultDisplay): void { + const seq = ++entry.sequence; + const event = { seq, output: structuredClone(output) }; + const bytes = Buffer.byteLength(JSON.stringify(event)); + if (bytes > MAX_PROGRESS_BYTES) { + entry.progress = []; + entry.progressBytes = 0; + return; + } + entry.progress.push({ ...event, bytes }); + entry.progressBytes += bytes; + while (entry.progressBytes > MAX_PROGRESS_BYTES) { + entry.progressBytes -= entry.progress.shift()!.bytes; + } + } + + private async run(entry: Entry): Promise { + const signal = entry.controller.signal; + const messageBus = this.config.getDisableAllHooks() + ? undefined + : this.config.getMessageBus(); + let result: ManagedToolExecutionResult; + let executionStarted = false; + try { + signal.throwIfAborted(); + this.assertCurrent(entry.reference); + executionStarted = true; + const raw = await entry.invocation.execute( + signal, + (output) => this.progress(entry, output), + { + ...this.config.getShellExecutionConfig(), + requireProcessGroupExit: true, + }, + ); + result = { + executionStatus: + raw.executionStatus ?? + (raw.error ? (signal.aborted ? 'cancelled' : 'error') : 'success'), + }; + try { + result.result = structuredClone({ + llmContent: raw.llmContent, + returnDisplay: raw.returnDisplay, + ...(raw.error === undefined ? {} : { error: raw.error }), + ...(raw.resultFilePaths === undefined + ? {} + : { resultFilePaths: raw.resultFilePaths }), + ...(raw.persistedOutputFiles === undefined + ? {} + : { persistedOutputFiles: raw.persistedOutputFiles }), + ...(raw.artifacts === undefined ? {} : { artifacts: raw.artifacts }), + }); + } catch (error) { + result.error = { + message: `Cannot serialize managed tool result: ${String(error)}`, + type: ToolErrorType.EXECUTION_FAILED, + }; + } + } catch (error) { + result = { + executionStatus: !executionStarted + ? 'not_started' + : signal.aborted + ? 'cancelled' + : 'error', + error: { + message: error instanceof Error ? error.message : String(error), + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + try { + if ( + result.executionStatus === 'error' || + result.executionStatus === 'cancelled' || + result.result?.error !== undefined + ) { + result.failureHook = await firePostToolUseFailureHook( + messageBus, + entry.toolUseId, + entry.tool.name, + entry.invocation.params as Record, + result.result?.error?.message ?? + result.error?.message ?? + 'Tool execution failed.', + result.executionStatus === 'cancelled' || signal.aborted, + this.config.getApprovalMode(), + undefined, + entry.reference.callId, + ); + } else if (result.executionStatus === 'success' && result.result) { + result.postHook = await firePostToolUseHook( + messageBus, + entry.tool.name, + entry.invocation.params as Record, + { + llmContent: result.result.llmContent, + returnDisplay: result.result.returnDisplay, + }, + entry.toolUseId, + this.config.getApprovalMode(), + undefined, + entry.reference.callId, + ); + } + } catch (error) { + const hookError = error instanceof Error ? error.message : String(error); + if (result.executionStatus === 'success') + result.postHook = { shouldStop: false, hookError }; + else result.failureHook = { hookError }; + } + entry.result = structuredClone(result); + return entry.result; + } + + status( + reference: ManagedToolInvocationReference, + afterSeq = 0, + ): ManagedToolInvocationStatus { + if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) + throw new Error('Invalid managed tool progress cursor.'); + const entry = this.get(reference); + const firstAvailableSeq = entry.progress[0]?.seq ?? entry.sequence + 1; + return { + state: entry.result + ? 'settled' + : entry.controller.signal.aborted + ? 'cancel_requested' + : entry.execution + ? 'executing' + : 'prepared', + cancelRequested: entry.controller.signal.aborted, + lastSeq: entry.sequence, + firstAvailableSeq, + progressGap: afterSeq + 1 < firstAvailableSeq, + progress: structuredClone( + entry.progress + .filter((event) => event.seq > afterSeq) + .map(({ seq, output }) => ({ seq, output })), + ), + ...(entry.result ? { result: structuredClone(entry.result) } : {}), + }; + } + + private requestCancel(entry: Entry): void { + entry.controller.abort(new Error('Managed tool invocation cancelled.')); + if (!entry.execution) { + entry.cancellation ??= (async () => { + await Promise.allSettled([ + entry.confirmation, + entry.preflight, + ...[...entry.decisions.values()].map((decision) => decision.promise), + ]); + entry.result = { executionStatus: 'not_started' }; + })(); + } + } + + cancel( + reference: ManagedToolInvocationReference, + ): ManagedToolInvocationStatus { + const entry = this.get(reference); + this.requestCancel(entry); + return this.status(reference); + } + + hasActiveWork(): boolean { + return ( + this.snapshotPending || + this.pendingPreparations > 0 || + [...this.entries.values()].some((entry) => !entry.result) + ); + } + + seal(): void { + this.lifetime.abort(new Error('Managed tool Runtime is closed.')); + for (const entry of this.entries.values()) this.requestCancel(entry); + } + + dispose(): Promise { + this.disposal ??= (async () => { + this.seal(); + await Promise.allSettled([ + ...[...this.calls.values()].map((slot) => slot.pending), + this.activePrompt?.snapshot, + ]); + await Promise.allSettled( + [...this.entries.values()].flatMap((entry) => [ + entry.confirmation, + entry.preflight, + entry.execution, + entry.cancellation, + ...[...entry.decisions.values()].map((decision) => decision.promise), + ]), + ); + this.entries.clear(); + this.calls.clear(); + })(); + return this.disposal; + } +} + +/** + * The Harness applies what an "always" choice means to its own Config -- the + * approval mode, persisted rules -- when it forwards the decision. The Runtime + * only carries out this one invocation, and its derived tool Config refuses an + * approval-mode change, so the tool's own callback sees a single approval. + */ +function runtimeLocalOutcome( + outcome: ToolConfirmationOutcome, +): ToolConfirmationOutcome { + switch (outcome) { + case ToolConfirmationOutcome.ProceedAlways: + case ToolConfirmationOutcome.ProceedAlwaysServer: + case ToolConfirmationOutcome.ProceedAlwaysTool: + case ToolConfirmationOutcome.ProceedAlwaysProject: + case ToolConfirmationOutcome.ProceedAlwaysUser: + return ToolConfirmationOutcome.ProceedOnce; + default: + return outcome; + } +} + +export async function createBuiltinManagedToolRuntime( + config: Config, + fileHistory?: ManagedToolRuntimeFileHistory, + toolConfig: Config = config, +): Promise { + const [ + { ReadFileTool }, + { WriteFileTool }, + { EditTool }, + { NotebookEditTool }, + { ShellTool }, + { GlobTool }, + { LSTool }, + { GrepTool }, + { RipGrepTool }, + { ZoomImageTool }, + ] = await Promise.all([ + import('./read-file.js'), + import('./write-file.js'), + import('./edit.js'), + import('./notebook-edit.js'), + import('./shell.js'), + import('./glob.js'), + import('./ls.js'), + import('./grep.js'), + import('./ripGrep.js'), + import('./zoom-image.js'), + ]); + const registry = config.getToolRegistry(); + await registry.ensureTool(ZoomImageTool.Name); + if ( + toolConfig !== config && + toolConfig.isLsToolEnabled() && + !config.isLsToolEnabled() && + !registry.getAllToolNames().includes(LSTool.Name) + ) { + const permissionManager = config.getPermissionManager(); + if (!permissionManager) + throw new Error( + 'Managed LS registration requires initialized permissions.', + ); + const status = await permissionManager.getToolRegistrationStatus( + LSTool.Name, + ); + if ( + status !== 'disabled' && + !registry.getAllToolNames().includes(LSTool.Name) + ) { + const factory = async () => new LSTool(config); + if (status === 'deferred') + registry.registerPermissionDeferredFactory(LSTool.Name, factory); + else registry.registerFactory(LSTool.Name, factory); + await registry.ensureTool(LSTool.Name); + } + } + const constructors = [ + ReadFileTool, + WriteFileTool, + EditTool, + NotebookEditTool, + ShellTool, + GlobTool, + LSTool, + ZoomImageTool, + ].filter( + (Constructor) => Constructor !== LSTool || toolConfig.isLsToolEnabled(), + ); + const revision = randomUUID(); + const admittedGrep = registry.getTool(GrepTool.Name); + let grep: AnyDeclarativeTool | undefined; + if ( + admittedGrep?.constructor === GrepTool || + admittedGrep?.constructor === RipGrepTool + ) { + let useRipgrep = false; + if (toolConfig.getUseRipgrep()) { + try { + useRipgrep = await canUseRipgrep(toolConfig.getUseBuiltinRipgrep(), { + requireProcessGroupExit: true, + cwd: toolConfig.getTargetDir(), + }); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code === + 'ERR_OWNED_COMMAND_UNSUPPORTED' + ) + throw error; + } + } + grep = useRipgrep ? new RipGrepTool(toolConfig) : new GrepTool(toolConfig); + } + const boundTools = + toolConfig === config + ? undefined + : constructors.flatMap((Constructor): AnyDeclarativeTool[] => { + const tool = config.getToolRegistry().getTool(Constructor.Name); + return tool?.constructor === Constructor + ? [new Constructor(toolConfig)] + : []; + }); + return new ManagedToolRuntime( + toolConfig, + () => [ + ...(boundTools?.filter( + (tool) => + config.getToolRegistry().getTool(tool.name)?.constructor === + tool.constructor, + ) ?? + constructors.flatMap((Constructor): AnyDeclarativeTool[] => { + const tool = config.getToolRegistry().getTool(Constructor.Name); + return tool?.constructor === Constructor ? [tool] : []; + })), + ...(grep && + config.getToolRegistry().getTool(GrepTool.Name) === admittedGrep + ? [grep] + : []), + ], + () => revision, + fileHistory, + (tool, context) => { + const Constructor = [ReadFileTool, ZoomImageTool].find( + (candidate) => tool.constructor === candidate, + ); + if (!Constructor) { + throw new Error('Managed Runtime tool does not support media context.'); + } + const view = deriveConfig(toolConfig, { + getEffectiveInputModalities: () => ({ ...context.inputModalities }), + getFileReadCache: () => toolConfig.getFileReadCache(), + getFileService: () => toolConfig.getFileService(), + }); + return new Constructor(view); + }, + ); +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java new file mode 100644 index 00000000000..0004d8685d6 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java @@ -0,0 +1,518 @@ +package com.alibaba.qwen.code.runtimebroker; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; + +/** + * Private HTTP face of the merged Runtime Broker for a Hosted Harness. + * + *

Prepare and create both call {@link RuntimeBrokerService#createExecution}, + * which records the call and drives dispatch. Start reads that record back. + * Resolve asks {@link RuntimeBrokerService#reconcileExecution} for evidence; + * the caller's resolution label is accepted and not applied as a result. + */ +public final class RuntimeBrokerHttpServer implements AutoCloseable { + public static final String ROUTE_PREFIX = "/internal/runtime-broker/v1"; + private static final int MAXIMUM_REQUEST_BYTES = 8 * 1024 * 1024; + private final RuntimeBrokerService service; + private final byte[] authorization; + private final HttpServer server; + private final ExecutorService executor; + + public RuntimeBrokerHttpServer(InetSocketAddress address, String token, + RuntimeBrokerService service) throws IOException { + if (address == null || service == null) { + throw new IllegalArgumentException( + "address and service are required"); + } + this.authorization = ("Bearer " + + BrokerValues.requireId(token, "token")) + .getBytes(StandardCharsets.UTF_8); + this.service = service; + this.server = HttpServer.create(address, 0); + this.executor = Executors.newCachedThreadPool(task -> { + Thread thread = new Thread(task, "runtime-broker-http"); + thread.setDaemon(true); + return thread; + }); + server.setExecutor(executor); + server.createContext(ROUTE_PREFIX, this::handle); + } + + public void start() { + server.start(); + } + + public URI getBaseUri() { + InetSocketAddress address = server.getAddress(); + String host = address.getAddress().getHostAddress(); + if (host.indexOf(':') >= 0) { + host = "[" + host + "]"; + } + return URI.create("http://" + host + ":" + address.getPort() + "/"); + } + + @Override + public void close() { + server.stop(0); + executor.shutdownNow(); + service.close(); + } + + private void handle(HttpExchange exchange) { + try { + authorize(exchange); + String path = exchange.getRequestURI().getRawPath(); + if (!path.startsWith(ROUTE_PREFIX)) { + throw notFound(); + } + String relative = path.substring(ROUTE_PREFIX.length()); + if ("POST".equals(exchange.getRequestMethod()) + && "/tool-sessions:acquire".equals(relative)) { + acquire(exchange); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && ("/executions:prepare".equals(relative) + || "/executions".equals(relative))) { + executionRequest(exchange); + return; + } + if (relative.startsWith("/tool-sessions/")) { + toolSession(exchange, relative.substring( + "/tool-sessions/".length())); + return; + } + if (relative.startsWith("/executions/")) { + execution(exchange, relative.substring("/executions/" + .length())); + return; + } + throw notFound(); + } catch (Throwable error) { + sendError(exchange, error); + } + } + + private void acquire(HttpExchange exchange) throws IOException { + Map body = requestBody(exchange, "acquire request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "acquire request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "acquire request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "acquire request"); + String turnKind = JsonCodec.requiredString(body, "turnKind", + "acquire request"); + complete(exchange, service.acquire(harnessSessionId, runtimeSessionId, + turnKind), ignored -> envelope(harnessSessionId, + runtimeSessionId, "acquired", true)); + } + + private void toolSession(HttpExchange exchange, String suffix) + throws IOException { + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith("/control")) { + String runtimeSessionId = pathId(suffix.substring(0, + suffix.length() - "/control".length())); + Map body = requestBody(exchange, + "control request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "control request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "control request"); + Map operation = requiredObject(body, "operation", + "control request"); + complete(exchange, service.control(harnessSessionId, + runtimeSessionId, operation), result -> envelope( + harnessSessionId, runtimeSessionId, "result", + result)); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":release")) { + String runtimeSessionId = pathId(suffix.substring(0, + suffix.length() - ":release".length())); + Map body = requestBody(exchange, + "release request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "release request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "release request"); + complete(exchange, service.release(harnessSessionId, + runtimeSessionId), released -> envelope(harnessSessionId, + runtimeSessionId, "released", released)); + return; + } + throw notFound(); + } + + private void executionRequest(HttpExchange exchange) throws IOException { + Map body = requestBody(exchange, "execution request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "execution request"); + String idempotencyKey = JsonCodec.requiredString(body, + "idempotencyKey", "execution request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "execution request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "execution request"); + JsonCodec.requiredString(body, "turnId", "execution request"); + JsonCodec.requiredString(body, "toolCallId", "execution request"); + JsonCodec.requiredString(body, "requestDigest", "execution request"); + requiredObject(body, "reference", "execution request"); + complete(exchange, service.createExecution(harnessSessionId, + runtimeSessionId, idempotencyKey, requiredObject(body, + "reference", "execution request")), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + } + + private void execution(HttpExchange exchange, String suffix) + throws IOException { + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":start")) { + String executionCallId = pathId(suffix.substring(0, + suffix.length() - ":start".length())); + Map body = requestBody(exchange, "start request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "start request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "start request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "start request"); + complete(exchange, service.getExecution(harnessSessionId, + runtimeSessionId, executionCallId), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":cancel")) { + String executionCallId = pathId(suffix.substring(0, + suffix.length() - ":cancel".length())); + Map body = requestBody(exchange, + "cancel request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "cancel request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "cancel request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "cancel request"); + complete(exchange, service.cancelExecution(harnessSessionId, + runtimeSessionId, executionCallId), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":resolve")) { + String executionCallId = pathId(suffix.substring(0, + suffix.length() - ":resolve".length())); + Map body = requestBody(exchange, + "resolve request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "resolve request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "resolve request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "resolve request"); + requireResolution(JsonCodec.requiredString(body, "resolution", + "resolve request")); + complete(exchange, service.reconcileExecution(harnessSessionId, + runtimeSessionId, executionCallId), + reconciliation -> executionEnvelope(harnessSessionId, + runtimeSessionId, reconciliation.getRecord())); + return; + } + if ("GET".equals(exchange.getRequestMethod()) + && suffix.indexOf('/') < 0) { + String executionCallId = pathId(suffix); + Map query = query(exchange.getRequestURI()); + requireQuery(query, "requestId"); + String harnessSessionId = requireQuery(query, "harnessSessionId"); + String runtimeSessionId = requireQuery(query, "runtimeSessionId"); + if (query.containsKey("afterSeq")) { + parseSequence(query.get("afterSeq")); + } + complete(exchange, service.getExecution(harnessSessionId, + runtimeSessionId, executionCallId), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + return; + } + throw notFound(); + } + + private void authorize(HttpExchange exchange) { + String supplied = exchange.getRequestHeaders().getFirst( + "Authorization"); + byte[] bytes = supplied == null ? new byte[0] + : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(authorization, bytes)) { + throw new RuntimeBrokerException(401, + "runtime_broker_unauthorized", + "Runtime Broker authentication failed.", false); + } + } + + private static void requireResolution(String raw) { + if (!"confirmed_not_executed".equals(raw) + && !"accepted_unknown".equals(raw)) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker resolution is unsupported.", false); + } + } + + private static void requireProtocol(Map body) { + Object version = body.get("protocolVersion"); + if (!(version instanceof Number) + || ((Number) version).intValue() != 1 + || ((Number) version).doubleValue() != 1.0d) { + throw new RuntimeBrokerException(409, + "runtime_broker_protocol_conflict", + "Runtime Broker protocol version changed.", false); + } + } + + private static Map requiredObject( + Map body, String field, String context) { + Object value = body.get(field); + if (!(value instanceof Map)) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + context + "." + field + " must be an object.", false); + } + @SuppressWarnings("unchecked") + Map object = (Map) value; + return object; + } + + private static Map requestBody(HttpExchange exchange, + String context) throws IOException { + String rawLength = exchange.getRequestHeaders().getFirst( + "Content-Length"); + if (rawLength != null) { + int contentLength; + try { + contentLength = Integer.parseInt(rawLength); + } catch (NumberFormatException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Content-Length is invalid.", false); + } + if (contentLength < 0 || contentLength > MAXIMUM_REQUEST_BYTES) { + throw tooLarge(); + } + } + byte[] bytes; + try (InputStream input = exchange.getRequestBody()) { + bytes = input.readNBytes(MAXIMUM_REQUEST_BYTES + 1); + } + if (bytes.length > MAXIMUM_REQUEST_BYTES) { + throw tooLarge(); + } + return JsonCodec.parseObject(bytes, context); + } + + private static RuntimeBrokerException tooLarge() { + return new RuntimeBrokerException(413, + "runtime_broker_request_too_large", + "Runtime Broker request exceeded its limit.", false); + } + + private static RuntimeBrokerException notFound() { + return new RuntimeBrokerException(404, + "runtime_broker_route_not_found", + "Runtime Broker route was not found.", false); + } + + private static String pathId(String raw) { + String decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8); + if (decoded.indexOf('/') >= 0) { + throw notFound(); + } + return BrokerValues.requireId(decoded, "path id"); + } + + private static Map query(URI uri) { + Map result = new LinkedHashMap<>(); + String raw = uri.getRawQuery(); + if (raw == null || raw.isEmpty()) { + return result; + } + for (String pair : raw.split("&", -1)) { + int separator = pair.indexOf('='); + String key = URLDecoder.decode(separator < 0 ? pair + : pair.substring(0, separator), StandardCharsets.UTF_8); + String value = URLDecoder.decode(separator < 0 ? "" + : pair.substring(separator + 1), StandardCharsets.UTF_8); + if (result.putIfAbsent(key, value) != null) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker query contains duplicate fields.", + false); + } + } + return result; + } + + private static String requireQuery(Map query, + String field) { + String value = query.get(field); + try { + return BrokerValues.requireId(value, field); + } catch (IllegalArgumentException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker query is incomplete.", false); + } + } + + private static long parseSequence(String raw) { + try { + long value = Long.parseLong(raw); + if (value < 0) { + throw new NumberFormatException(); + } + return value; + } catch (NumberFormatException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "afterSeq must be a non-negative integer.", false); + } + } + + private static Map envelope(String harnessSessionId, + String runtimeSessionId, String field, Object value) { + Map response = new LinkedHashMap<>(); + response.put("protocolVersion", 1); + response.put("harnessSessionId", harnessSessionId); + response.put("runtimeSessionId", runtimeSessionId); + response.put(field, value); + return response; + } + + private static Map executionEnvelope( + String harnessSessionId, String runtimeSessionId, + ToolExecutionRecord record) { + Map response = envelope(harnessSessionId, + runtimeSessionId, "executionCallId", + record.getExecutionCallId()); + response.put("status", status(record)); + return response; + } + + private static Map status(ToolExecutionRecord record) { + Map status = new LinkedHashMap<>(); + status.put("state", wireState(record.getState())); + status.put("cancelRequested", record.isCancelRequested()); + status.put("lastSeq", record.getLastSequence()); + boolean gap = record.getLastSequence() > 0; + status.put("firstAvailableSeq", gap ? record.getLastSequence() : 0L); + status.put("progressGap", gap); + status.put("progress", List.of()); + if (record.isSettled()) { + status.put("result", record.getResult()); + } + return status; + } + + private static String wireState(ToolExecutionRecord.State state) { + switch (state) { + case PREPARED: + return "prepared"; + case CANCEL_REQUESTED: + return "cancel_requested"; + case SETTLED: + return "settled"; + case DISPATCHING: + case EXECUTING: + case UNKNOWN: + default: + return "executing"; + } + } + + private static void complete(HttpExchange exchange, + CompletionStage operation, + Function> response) { + operation.whenComplete((value, error) -> { + if (error != null) { + sendError(exchange, error); + return; + } + try { + sendJson(exchange, 200, response.apply(value)); + } catch (Throwable failure) { + sendError(exchange, failure); + } + }); + } + + private static void sendError(HttpExchange exchange, Throwable error) { + Throwable actual = unwrap(error); + RuntimeBrokerException failure; + if (actual instanceof RuntimeBrokerException brokerFailure) { + failure = brokerFailure; + } else if (actual instanceof IllegalArgumentException) { + failure = new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker request is invalid.", false); + } else if (actual instanceof IllegalStateException + && actual.getMessage() != null + && actual.getMessage().contains("closed")) { + failure = new RuntimeBrokerException(503, + "runtime_broker_closed", + "Runtime Broker is closed.", false); + } else { + failure = new RuntimeBrokerException(500, + "runtime_broker_internal_error", + "Runtime Broker operation failed.", false); + } + Map body = new LinkedHashMap<>(); + body.put("error", failure.getMessage()); + body.put("code", failure.getCode()); + body.put("retryable", failure.isRetryable()); + try { + sendJson(exchange, failure.getStatusCode(), body); + } catch (IOException ignored) { + exchange.close(); + } + } + + private static Throwable unwrap(Throwable error) { + Throwable current = error; + while (current instanceof CompletionException + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static void sendJson(HttpExchange exchange, int status, + Map body) throws IOException { + byte[] bytes = JsonCodec.encode(body); + exchange.getResponseHeaders().set("Content-Type", + "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java new file mode 100644 index 00000000000..9c0c8017406 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java @@ -0,0 +1,33 @@ +package com.alibaba.qwen.code.runtimebroker; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Returns one pre-attested lease. Static placements do not keep a + * recoverable identity, matching {@code requiresDurableIdentity}. + */ +public final class StaticRuntimeProvisioner implements RuntimeProvisioner { + private final RuntimeLease lease; + + public StaticRuntimeProvisioner(RuntimeLease lease) { + if (lease == null) { + throw new IllegalArgumentException("lease is required"); + } + this.lease = lease; + } + + @Override + public CompletionStage provision( + RuntimeProvisionRequest request) { + if (request == null) { + throw new IllegalArgumentException("request is required"); + } + return CompletableFuture.completedFuture(lease); + } + + @Override + public String kind() { + return "static"; + } +} From e27511fc37928fc8aecee4190113dce754f548f5 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Fri, 25 Sep 2026 19:41:37 +0800 Subject: [PATCH 4/6] fix(serve): guard incomplete hosted runtime foundations --- .../hosted-runtime-foundation-boundary.md | 86 ++ ...osted-runtime-foundation-boundary.zh-CN.md | 73 ++ packages/acp-bridge/src/bridgeTypes.ts | 14 +- .../acp-bridge/src/process-registry.test.ts | 3 +- packages/acp-bridge/src/process-registry.ts | 5 +- .../src/acp-integration/managed-tool-media.ts | 6 +- packages/cli/src/commands/serve.ts | 8 +- .../broker-managed-runtime-provider.test.ts | 61 ++ .../serve/broker-managed-runtime-provider.ts | 40 +- .../src/serve/hosted-harness-profile.test.ts | 54 ++ .../cli/src/serve/hosted-harness-profile.ts | 62 +- .../serve/managed-runtime-provider.test.ts | 806 +----------------- .../cli/src/serve/managed-runtime-provider.ts | 24 +- packages/cli/src/serve/run-qwen-serve.ts | 28 +- packages/core/src/config/config.ts | 2 + ...managed-tool-file-history-protocol.test.ts | 1 - .../managed-tool-file-history-protocol.ts | 3 +- .../src/tools/managed-tool-protocol.test.ts | 26 + .../core/src/tools/managed-tool-protocol.ts | 4 +- .../src/tools/managed-tool-runtime.test.ts | 21 +- .../core/src/tools/managed-tool-runtime.ts | 65 +- .../LocalProcessRuntimeProvisioner.java | 8 +- .../RuntimeBrokerHttpServer.java | 63 +- .../LocalProcessRuntimeProvisionerTest.java | 4 +- .../RuntimeBrokerHttpServerTest.java | 153 ++++ 25 files changed, 597 insertions(+), 1023 deletions(-) create mode 100644 docs/design/hosted-runtime-foundation-boundary.md create mode 100644 docs/design/hosted-runtime-foundation-boundary.zh-CN.md create mode 100644 packages/cli/src/serve/hosted-harness-profile.test.ts create mode 100644 packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java diff --git a/docs/design/hosted-runtime-foundation-boundary.md b/docs/design/hosted-runtime-foundation-boundary.md new file mode 100644 index 00000000000..fcb7b336fb9 --- /dev/null +++ b/docs/design/hosted-runtime-foundation-boundary.md @@ -0,0 +1,86 @@ +# Hosted Runtime foundation boundary + +[English](hosted-runtime-foundation-boundary.md) | [简体中文](hosted-runtime-foundation-boundary.zh-CN.md) + +## Status and problem + +PR #12691 contains foundations extracted from #12358, not a runnable Hosted +Harness. Review found missing session-loop and worker-route integration, native +execution APIs that do not exist on main, and incompatible Java and TypeScript +execution contracts. The agreed scope is to make the foundations buildable and +fail closed, without importing the preview architecture. + +## Scope and decisions + +`qwen serve --profile hosted-harness` fails before opening a listener, even with +valid credentials. Experimental Managed Gateway/worker/auto-local settings also +fail. Default serve remains available; explicit broker options require the +unavailable hosted profile. Environment-only broker settings have no effect in +default mode. Help text describes this restriction. + +Bridge managed-tool methods are optional capabilities. A local provider rejects a +bridge without these capabilities before creating a session. This does not add a +working ACP worker implementation. Unused declarations for durable session-store +and continuation metadata are not evidence of persistence or recovery support. + +The built-in managed factory admits ReadFile, WriteFile, Edit, NotebookEdit, +Glob, LS (when enabled), and ZoomImage only when already admitted by the source +registry. Shell and both Grep implementations are excluded until physical process +ownership and cancellation settlement are available. Native optional undefined +parameters are projected to JSON for response and integrity digests; incoming +requests retain strict validation. Edit confirmations have a bounded response +budget independent of the smaller request budget. + +ProcessRegistry distinguishes a proven nonzero/signal exit with ProcessExitError +from failures to establish process-tree cleanup. Only the proven-exit error is +accepted during local worker reclamation. Other shutdown consumers retain the +existing Error behavior and messages. + +## Broker contract and ownership + +All HTTP Broker operations are scoped by the resolved Harness session and its +Runtime session; tenant/workspace claims come from the service resolver. These +are not primary-daemon or process-global workspace routes. + +The Java service supports immediate execution creation. Its HTTP adapter returns +501 runtime_broker_operation_unsupported for deferred prepare/start and operator +resolution, with no dispatch or resolution side effects. UNKNOWN records return +409 runtime_broker_execution_unknown, never a fabricated executing status. The +TypeScript two-phase client therefore fails against unsupported operations; it +must not be mounted as a production closed loop yet. + +The Java HTTP transport still supports attestation and its existing tool +execute/status/cancel format. Acquire/control/release fail closed. Its reference +and result formats differ from the TypeScript ManagedToolV2Client format; this PR +does not claim interoperability between them. + +The local-process provisioner recognizes only processes it owns. An unowned or +unreachable process is UNKNOWN. Only a locally observed dead Process supports +NOT_FOUND. Broker restart adoption remains unavailable, preventing a transient +attestation failure from authorizing a replacement worker beside a live one. + +Broker clients can retry failed acquisition, invalidate issued clients on release, +and retain terminal release intent across concurrent release calls. + +## Validation and acceptance + +Build and typecheck all packages. Run focused Core, Bridge, CLI and Java tests. +Test real ReadFile preparation, oversized-but-bounded edit confirmations, acquire +retry, released-client rejection, unsupported profile startup, forced worker +termination and Java HTTP unsupported/UNKNOWN/authentication boundaries. + +Tests imported from the preview for absent worker routes are removed; provider +unit and transport-fixture tests remain. Those removed tests cannot establish +worker coverage until an actual worker implementation is included. + +Acceptance requires ordinary serve to load, unsupported modes to open no listener, +prepare to dispatch nothing, unknown outcomes to remain unknown, and all included +code to compile. Live model/tool round trips and restart recovery are not claimed. + +## Follow-up requirements + +A later integration must add the resident session loop, owned ACP worker routes, +a shared wire format, durable reserve/start and explicit resolution APIs, session +store/continuation wiring, process containment for Shell/Grep, and a real cold-start +E2E test. Enabling the hosted profile depends on those behaviors, not solely on +configuration validation or mocked transport tests. diff --git a/docs/design/hosted-runtime-foundation-boundary.zh-CN.md b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md new file mode 100644 index 00000000000..35f2f9342aa --- /dev/null +++ b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md @@ -0,0 +1,73 @@ +# Hosted Runtime 基础设施边界 + +[English](hosted-runtime-foundation-boundary.md) | [简体中文](hosted-runtime-foundation-boundary.zh-CN.md) + +## 状态与问题 + +PR #12691 是从 #12358 提取的基础设施,不是可运行的 Hosted Harness。 +评审发现缺少会话循环和 worker 路由接线、引用了 main 尚不存在的原生执行 API, +以及 Java 与 TypeScript 执行契约不兼容。本次约定范围是恢复构建并在能力不足时 +明确拒绝操作,不引入预览分支的完整架构。 + +## 范围与决策 + +`qwen serve --profile hosted-harness` 在监听端口之前失败,即使凭据完整也不例外。 +实验性 Managed Gateway、worker 和 auto-local 设置同样失败。默认 serve 保持可用; +显式 Broker 参数要求当前不可用的 hosted profile。默认模式下仅设置 Broker 环境 +变量不会生效。帮助文本说明这一限制。 + +Bridge 的 managed-tool 方法是可选能力。本地 provider 在创建会话之前拒绝缺少 +这些能力的 Bridge。这并未实现 ACP worker。尚未使用的持久化 session-store 和 +continuation 元数据声明不能证明持久化或恢复已可用。 + +内置 managed 工厂只接纳源 registry 已允许的 ReadFile、WriteFile、Edit、 +NotebookEdit、Glob、启用时的 LS 和 ZoomImage。在具备真实进程所有权和取消后 +执行完成判定之前,排除 Shell 及两种 Grep 实现。原生工具的可选 undefined 参数 +先投影为 JSON,再生成响应和完整性摘要;入站请求继续严格验证。编辑确认采用 +独立于较小请求限额的有界响应预算。 + +ProcessRegistry 使用 ProcessExitError 区分已确认的非零退出码或信号退出与 +无法证明进程树清理完成的错误。本地 worker 回收只接受前者。其他关闭流程的 +消费者继续保留已有 Error 行为和错误消息。 + +## Broker 契约与所有权 + +所有 Broker HTTP 操作归属已解析的 Harness 会话及其 Runtime 会话;tenant 和 +workspace 来自服务端 resolver。这些不是主 daemon 或进程全局的工作区路由。 + +Java 服务支持立即创建并执行。HTTP 适配器对延迟 prepare/start 和操作员手工 +resolution 返回 501 runtime_broker_operation_unsupported,不产生执行或裁决 +副作用。UNKNOWN 记录返回 409 runtime_broker_execution_unknown,不伪装成 +executing。因此 TypeScript 两阶段客户端会在不支持的操作上失败,目前不能挂载 +为生产闭环。 + +Java HTTP transport 仍支持 attestation 及其已有格式的 execute/status/cancel。 +Acquire/control/release 明确拒绝。其引用与结果格式不同于 TypeScript 的 +ManagedToolV2Client;本 PR 不宣称两端已经互通。 + +local-process provisioner 只认领自身持有的进程。未持有或不可达的进程是 UNKNOWN; +只有本地观测到 Process 已死亡才支持 NOT_FOUND。Broker 重启后的进程认领仍不可用, +避免临时 attestation 故障允许在存活 worker 旁创建替代 worker。 + +Broker 客户端允许重试失败的 acquire,在 release 时使已发放客户端失效,并在 +并发 release 调用之间保留 terminal 意图。 + +## 验证与验收 + +对所有包执行 build 和 typecheck,并运行定向 Core、Bridge、CLI 和 Java 测试。 +覆盖真实 ReadFile prepare、超过请求限额但仍在响应预算内的编辑确认、acquire +重试、已释放客户端拒绝、未实现 profile 启动拒绝、强制终止 worker,以及 Java +HTTP 的 unsupported、UNKNOWN 和认证边界。 + +移除从预览带入、依赖不存在 worker 路由的测试;保留 provider 单测和传输 fixture +测试。在真正包含 worker 实现之前,被移除的测试无法证明 worker 覆盖率。 + +验收要求普通 serve 能加载、未实现模式不监听端口、prepare 不派发执行、未知结果 +保持未知,且所有纳入代码可编译。不宣称已验证真实模型/工具往返和重启恢复。 + +## 后续要求 + +后续集成需要补充常驻会话循环、自有 ACP worker 路由、统一 wire 格式、持久化 +reserve/start 与显式 resolution API、session-store/continuation 接线、 +Shell/Grep 进程管控,以及真实冷启动 E2E 测试。启用 hosted profile 必须建立在 +这些行为之上,不能仅依靠配置校验或 mock 传输测试。 diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index b45977a58c4..95ca01b974c 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -1933,19 +1933,19 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { ): Promise; /** Private owned-worker transport; the caller must hold the Runtime client id. */ - getManagedToolV2Client( + getManagedToolV2Client?( sessionId: string, context: BridgeClientRequestContext, ): ManagedToolV2Client; /** Read the safe Tool-only capability set pinned by a Managed Runtime. */ - getManagedRuntimeToolManifest( + getManagedRuntimeToolManifest?( sessionId: string, context?: BridgeClientRequestContext, ): Promise; /** Execute one safe Tool Call in the Runtime without invoking its model. */ - executeManagedRuntimeTool( + executeManagedRuntimeTool?( sessionId: string, request: BridgeManagedRuntimeToolExecuteRequest, signal: AbortSignal, @@ -1953,7 +1953,7 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { ): Promise; /** Cancel one matching Tool-only Runtime execution best-effort. */ - cancelManagedRuntimeTool( + cancelManagedRuntimeTool?( sessionId: string, executionId: string, context?: BridgeClientRequestContext, @@ -2849,12 +2849,6 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { /** Test/inspection hook: number of live sessions. */ readonly sessionCount: number; - /** - * User-facing live sessions. Internal Tool Runtime (`managed-gateway`) - * workers are omitted so they do not occupy `maxSessions`. - */ - readonly userFacingSessionCount: number; - /** * Whether an ACP channel is currently live (spawned and not dying). * Distinct from `sessionCount > 0`: a channel can be live with zero diff --git a/packages/acp-bridge/src/process-registry.test.ts b/packages/acp-bridge/src/process-registry.test.ts index c9c9f493a15..c64d85e5e34 100644 --- a/packages/acp-bridge/src/process-registry.test.ts +++ b/packages/acp-bridge/src/process-registry.test.ts @@ -19,7 +19,7 @@ vi.mock('node:child_process', async (importOriginal) => { }; }); -import { ProcessRegistry } from './process-registry.js'; +import { ProcessRegistry, ProcessExitError } from './process-registry.js'; const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; const PS = '/bin/ps'; @@ -256,6 +256,7 @@ describe('ProcessRegistry', () => { expect(killSpy).toHaveBeenCalledWith(-1234, 'SIGTERM'); await vi.advanceTimersByTimeAsync(5_000); + await expect(terminating).resolves.toBeInstanceOf(ProcessExitError); await expect(terminating).resolves.toMatchObject({ message: expect.stringContaining('exited uncleanly during shutdown'), }); diff --git a/packages/acp-bridge/src/process-registry.ts b/packages/acp-bridge/src/process-registry.ts index 3e6714e81e9..7917acbdc72 100644 --- a/packages/acp-bridge/src/process-registry.ts +++ b/packages/acp-bridge/src/process-registry.ts @@ -29,6 +29,9 @@ const STRING_EXEC_OPTIONS: ExecFileOptionsWithStringEncoding = { windowsHide: true, }; +/** A proven exit with a nonzero code or signal, not a failure to prove cleanup. */ +export class ProcessExitError extends Error {} + export interface ProcessAttachmentOptions { /** * The caller owns the complete process tree rooted at this child. POSIX @@ -650,7 +653,7 @@ class TrackedChild implements TrackedChildProcess { private throwForUncleanExit(exitInfo: AcpChannelExitInfo | undefined): void { if (exitInfo && (exitInfo.exitCode !== 0 || exitInfo.signalCode !== null)) { - throw new Error( + throw new ProcessExitError( `ACP child pid=${this.child.pid ?? 'unknown'} exited uncleanly during shutdown ` + `(code=${exitInfo.exitCode ?? 'none'}, signal=${exitInfo.signalCode ?? 'none'})`, ); diff --git a/packages/cli/src/acp-integration/managed-tool-media.ts b/packages/cli/src/acp-integration/managed-tool-media.ts index 836e55cbdcd..f80217490b1 100644 --- a/packages/cli/src/acp-integration/managed-tool-media.ts +++ b/packages/cli/src/acp-integration/managed-tool-media.ts @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - DAEMON_ACP_NDJSON_LIMITS, - hasBoundedJsonStructure, -} from '@qwen-code/acp-bridge/ndJsonStream'; +import { hasBoundedJsonStructure } from '@qwen-code/acp-bridge/ndJsonStream'; +import { DAEMON_ACP_NDJSON_LIMITS } from '@qwen-code/acp-bridge/spawnChannel'; // Leave room for the JSON-RPC/HTTP envelopes without changing ACP capacity. export const MAX_MANAGED_MEDIA_RESPONSE_BYTES = diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e0ce017ab89..44fbb95c9e7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -285,7 +285,7 @@ export const serveCommand: CommandModule = { choices: ['default', 'hosted-harness'] as const, default: 'default' as const, description: - 'Deployment profile. hosted-harness runs the resident model loop and delegates every Managed Tool operation through a Java Runtime Broker.', + 'Deployment profile. hosted-harness is reserved and currently rejects startup; Broker session wiring is not implemented.', }) .option('token', { type: 'string', @@ -605,19 +605,19 @@ export const serveCommand: CommandModule = { type: 'boolean', default: false, description: - 'Enable the experimental resident Managed Gateway with durable admission and Tool-only workspace Runtimes.', + 'Reserved experimental mode; not implemented and rejects startup.', }) .option('experimental-managed-runtime-worker', { type: 'boolean', default: false, description: - 'Expose the private authenticated Managed Runtime worker protocol. Requires a daemon bearer token.', + 'Reserved experimental mode; not implemented and rejects startup.', }) .option('experimental-managed-runtime-auto-local', { type: 'boolean', default: false, description: - 'Automatically start and manage local Tool-only Runtimes. Requires --experimental-managed-agents.', + 'Reserved experimental mode; not implemented and rejects startup.', }) .option('experimental-managed-runtime-url', { type: 'string', diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.test.ts b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts index 23a15a9e049..e7515972832 100644 --- a/packages/cli/src/serve/broker-managed-runtime-provider.test.ts +++ b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts @@ -102,6 +102,67 @@ describe('BrokerManagedRuntimeProvider', () => { expect(bodies[0]).not.toHaveProperty('workspaceCwd'); }); + it('retries a failed acquisition and invalidates issued clients after release', async () => { + let acquisitions = 0; + const fetchImpl = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('tool-sessions:acquire')) { + acquisitions++; + return acquisitions === 1 + ? new Response('{}', { status: 503 }) + : json(envelope({ acquired: true })); + } + if (url.endsWith(':release')) return json(envelope({ released: true })); + throw new Error(`Unexpected request ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('503'); + const client = await provider.getToolV2Client(request(), { + harnessSessionId, + }); + expect(acquisitions).toBe(2); + await provider.release(runtimeSessionId, request(), { terminal: true }); + const requests = fetchImpl.mock.calls.length; + expect(() => client.manifest()).toThrow('closed'); + await expect(client.execute(reference())).rejects.toThrow('closed'); + expect(fetchImpl).toHaveBeenCalledTimes(requests); + provider.dispose(); + }); + + it('keeps cleanup reachable when the acquire response is lost', async () => { + const fetchImpl = vi.fn(async (input) => { + if (String(input).endsWith('tool-sessions:acquire')) { + throw new TypeError('connection closed after acquire'); + } + if (String(input).endsWith(':release')) { + return json(envelope({ released: true })); + } + throw new Error('Unexpected request'); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('connection closed'); + await expect( + provider.release(runtimeSessionId, request(), { terminal: true }), + ).resolves.toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('permanently closed'); + provider.dispose(); + }); + it('reserves a durable execution identity before starting it', async () => { const calls: Array<{ url: string; method: string; body?: unknown }> = []; let droppedExecutionResponse = false; diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.ts b/packages/cli/src/serve/broker-managed-runtime-provider.ts index 1673d1e26f2..a2fecd57e71 100644 --- a/packages/cli/src/serve/broker-managed-runtime-provider.ts +++ b/packages/cli/src/serve/broker-managed-runtime-provider.ts @@ -69,10 +69,12 @@ interface BrokerEntry { readonly request: ManagedRuntimePrepareRequest; readonly harnessSessionId: string; readonly acquisition: Promise; + acquisitionFailed?: boolean; readonly executions: Map; client?: ManagedToolV2Client; release?: Promise; releasing?: boolean; + terminal?: boolean; } interface BrokerExecution { @@ -664,6 +666,10 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { false, ); } + if (entry?.acquisitionFailed) { + this.entries.delete(request.sessionId); + entry = undefined; + } if (!entry) { const immutableRequest = structuredClone(request); const acquisition = this.client.acquire( @@ -681,6 +687,10 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { executions: new Map(), }; this.entries.set(request.sessionId, entry); + const acquiredEntry = entry; + void acquisition.catch(() => { + acquiredEntry.acquisitionFailed = true; + }); } await entry.acquisition; this.lifetime.signal.throwIfAborted(); @@ -859,6 +869,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { ); } entry.releasing = true; + entry.terminal ||= options?.terminal === true; if (entry.release) return entry.release; const release = (async () => { await entry.acquisition.catch(() => {}); @@ -873,7 +884,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { ); if (released) { this.entries.delete(sessionId); - if (options?.terminal === true) { + if (entry.terminal) { this.closedSessions.set(sessionId, entry.request); } } @@ -893,8 +904,22 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { } private createToolClient(entry: BrokerEntry): ManagedToolV2Client { - const control = (operation: Record) => - this.client.control( + const assertEntry = (allowDraining = false) => { + this.lifetime.signal.throwIfAborted(); + if ( + this.entries.get(entry.request.sessionId) !== entry || + (!allowDraining && entry.releasing) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Broker Session is being released or is closed.', + false, + ); + } + }; + const control = (operation: Record) => { + assertEntry(operation['kind'] === 'history'); + return this.client.control( entry.request.sessionId, entry.harnessSessionId, operation, @@ -903,9 +928,12 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), ]), ); + }; const ensureExecution = ( reference: ManagedToolInvocationReference, + allowDraining = false, ): BrokerExecution => { + assertEntry(allowDraining); this.assertReference(entry, reference); const referenceDigest = managedToolDigest(reference); let execution = entry.executions.get(reference.invocationId); @@ -917,6 +945,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { ); } if (!execution) { + assertEntry(); execution = { referenceDigest, reserved: this.client.prepareExecution( @@ -937,7 +966,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { reference: ManagedToolInvocationReference, afterSeq?: number, ) => { - const execution = ensureExecution(reference); + const execution = ensureExecution(reference, true); const reserved = await execution.reserved; if (reserved.status.state === 'settled' && afterSeq === undefined) { return reserved.status; @@ -970,6 +999,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { ); } execution.started ??= (async () => { + assertEntry(); let status = await this.client.startExecution( entry.request.sessionId, entry.harnessSessionId, @@ -1031,7 +1061,7 @@ export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { execute: (reference) => startExecution(reference), status: (reference, afterSeq) => readExecution(reference, afterSeq), cancel: async (reference) => { - const reserved = await ensureExecution(reference).reserved; + const reserved = await ensureExecution(reference, true).reserved; return this.client.cancelExecution( entry.request.sessionId, entry.harnessSessionId, diff --git a/packages/cli/src/serve/hosted-harness-profile.test.ts b/packages/cli/src/serve/hosted-harness-profile.test.ts new file mode 100644 index 00000000000..3dc6022703e --- /dev/null +++ b/packages/cli/src/serve/hosted-harness-profile.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { validateHostedHarnessProfile } from './hosted-harness-profile.js'; + +const options = { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge' as const, +}; + +describe('unimplemented deployment profiles', () => { + it('leaves the ordinary daemon available', () => { + expect(() => validateHostedHarnessProfile(options)).not.toThrow(); + }); + + it('rejects hosted mode even with complete deployment credentials', () => { + expect(() => + validateHostedHarnessProfile({ + ...options, + profile: 'hosted-harness', + token: 'harness-secret', + serveWebShell: false, + managedRuntimeBrokerUrl: 'http://127.0.0.1:8080', + managedRuntimeBrokerToken: 'broker-secret', + hostedHarnessCapabilityDigest: `sha256:${'a'.repeat(64)}`, + }), + ).toThrow('Broker-backed session loop is not implemented'); + }); + + it.each([ + { experimentalManagedAgents: true }, + { experimentalManagedRuntimeWorker: true }, + { experimentalManagedRuntimeAutoLocal: true }, + { experimentalManagedRuntimeUrl: 'http://127.0.0.1:8080' }, + { experimentalManagedRuntimeToken: '' }, + ])('rejects an unsupported experimental option: %j', (option) => { + expect(() => + validateHostedHarnessProfile({ ...options, ...option }), + ).toThrow('not implemented'); + }); + + it('rejects broker options outside hosted mode', () => { + expect(() => + validateHostedHarnessProfile({ + ...options, + managedRuntimeBrokerToken: 'secret', + }), + ).toThrow('require --profile hosted-harness'); + }); +}); diff --git a/packages/cli/src/serve/hosted-harness-profile.ts b/packages/cli/src/serve/hosted-harness-profile.ts index b39510042cf..9882c899edf 100644 --- a/packages/cli/src/serve/hosted-harness-profile.ts +++ b/packages/cli/src/serve/hosted-harness-profile.ts @@ -5,49 +5,13 @@ */ import type { ServeOptions } from './types.js'; -import { resolveManagedRuntimeBrokerBaseUrl } from './broker-managed-runtime-provider.js'; -import { isHostedHarnessCapabilityDigest } from './hosted-harness-contract.js'; -import { isLoopbackBind } from './loopback-binds.js'; export function validateHostedHarnessProfile( - opts: ServeOptions, - environment: { - readonly serverToken: string; - readonly brokerUrl: string; - readonly brokerToken: string; - readonly capabilityDigest: string; - }, + opts: Omit, ): void { - if (opts.profile !== 'hosted-harness') return; - if (!isLoopbackBind(opts.hostname)) { - throw new Error('--profile hosted-harness requires a loopback --hostname.'); - } - if (!opts.token?.trim()) { - throw new Error( - `--profile hosted-harness requires a Harness bearer token. Set ` + - `${environment.serverToken} or pass --token.`, - ); - } - if (opts.serveWebShell !== false) { - throw new Error('--profile hosted-harness requires --no-web.'); - } - if (opts.enableSessionShell === true) { + if (opts.profile === 'hosted-harness') { throw new Error( - '--profile hosted-harness conflicts with --enable-session-shell.', - ); - } - if (opts.allowOrigins && opts.allowOrigins.length > 0) { - throw new Error( - '--profile hosted-harness does not accept browser origins.', - ); - } - if ( - opts.clientMcpOverWs === true || - opts.cdpTunnelOverWs === true || - opts.channelSelection !== undefined - ) { - throw new Error( - '--profile hosted-harness conflicts with client MCP, CDP tunnel, and channel hosting.', + '--profile hosted-harness is not available: the Broker-backed session loop is not implemented.', ); } if ( @@ -58,26 +22,16 @@ export function validateHostedHarnessProfile( opts.experimentalManagedRuntimeToken !== undefined ) { throw new Error( - '--profile hosted-harness conflicts with the experimental Managed Gateway and Runtime worker options.', - ); - } - if (!opts.managedRuntimeBrokerUrl?.trim()) { - throw new Error( - `--profile hosted-harness requires --managed-runtime-broker-url or ${environment.brokerUrl}.`, - ); - } - if (!opts.managedRuntimeBrokerToken?.trim()) { - throw new Error( - `--profile hosted-harness requires --managed-runtime-broker-token or ${environment.brokerToken}.`, + 'Experimental Managed Gateway and Runtime worker modes are not implemented.', ); } - resolveManagedRuntimeBrokerBaseUrl(opts.managedRuntimeBrokerUrl); if ( - !opts.hostedHarnessCapabilityDigest || - !isHostedHarnessCapabilityDigest(opts.hostedHarnessCapabilityDigest) + opts.managedRuntimeBrokerUrl !== undefined || + opts.managedRuntimeBrokerToken !== undefined || + opts.hostedHarnessCapabilityDigest !== undefined ) { throw new Error( - `--profile hosted-harness requires ${environment.capabilityDigest}=sha256:<64 lowercase hex characters>.`, + 'Managed Runtime Broker options require --profile hosted-harness, which is not available yet.', ); } } diff --git a/packages/cli/src/serve/managed-runtime-provider.test.ts b/packages/cli/src/serve/managed-runtime-provider.test.ts index bfa13ad0377..73cbd38632b 100644 --- a/packages/cli/src/serve/managed-runtime-provider.test.ts +++ b/packages/cli/src/serve/managed-runtime-provider.test.ts @@ -9,13 +9,9 @@ import { createHash } from 'node:crypto'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; -import express, { type RequestHandler } from 'express'; -import request from 'supertest'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ManagedToolV2Client } from '@qwen-code/acp-bridge/bridgeTypes'; -import type { ManagedToolFileHistoryState } from '@qwen-code/qwen-code-core'; -import type { ManagedWorkerBoot } from './managed-runtime-activator.js'; -import type { ManagedWorkerFileBoot } from './managed-runtime-worker-bootstrap.js'; +import type { ManagedToolFileHistoryState } from '@qwen-code/qwen-code-core/tools/managed-tool-file-history.js'; import type { AcpSessionBridge } from './acp-session-bridge.js'; import { LocalManagedRuntimeProvider, @@ -23,12 +19,10 @@ import { } from './managed-runtime-provider.js'; import { MANAGED_RUNTIME_PROTOCOL_VERSION, - MANAGED_RUNTIME_ROUTE_PREFIX, parseManagedRuntimeExecuteRequest, parseManagedRuntimePrepareRequest, type ManagedRuntimePrepareRequest, } from './managed-runtime-protocol.js'; -import { registerManagedRuntimeWorkerRoutes } from './routes/managed-runtime-worker.js'; import type { WorkspaceRegistry, WorkspaceRuntime, @@ -111,28 +105,6 @@ function fakeRuntime(): { return { bridge, registry, execute, cancel, close }; } -function workerApp( - provider: LocalManagedRuntimeProvider, - owned?: ManagedWorkerBoot | ManagedWorkerFileBoot, - beforeRoutes?: RequestHandler, -) { - const app = express(); - app.use(express.json({ limit: '10mb' })); - if (beforeRoutes) app.use(beforeRoutes); - registerManagedRuntimeWorkerRoutes(app, { - provider, - owned, - authorize: (req, res, next) => { - if (req.headers.authorization !== `Bearer ${token}`) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - next(); - }, - }); - return app; -} - async function listen(server: Server, port = 0): Promise { await new Promise((resolve, reject) => { server.once('error', reject); @@ -182,31 +154,6 @@ describe('Managed Runtime providers', () => { }; } - function ownedBoot(): ManagedWorkerBoot { - return { - type: 'boot', - version: 1, - gatewayIncarnation: 'gateway', - leaseId: 'lease', - epoch: 1, - ...prepareRequest, - token, - outputRoot: '/tmp/owned-output', - cliEntry: '/cli.js', - }; - } - - function fileOwnedBoot(): ManagedWorkerFileBoot { - return { - ...ownedBoot(), - runtimeInstanceId: 'runtime-instance', - provisionRequestId: 'provision-request', - workspaceGeneration: 'workspace-generation', - capabilityDigest: 'capability-digest', - isolationClass: 'workspace', - }; - } - function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { @@ -215,100 +162,12 @@ describe('Managed Runtime providers', () => { return { promise, resolve }; } - it('attests the exact durable worker identity before private use', async () => { + it('rejects a bridge without managed tool capabilities before spawning', () => { const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const owned = fileOwnedBoot(); - const app = workerApp(local, owned); - const body = { - protocolVersion: 2, - provisionRequestId: owned.provisionRequestId, - tenantId: owned.tenantId, - workspaceId: owned.workspaceId, - workspaceGeneration: owned.workspaceGeneration, - workspaceCwd: owned.workspaceCwd, - capabilityDigest: owned.capabilityDigest, - isolationClass: owned.isolationClass, - }; - const attest = (value: Record) => - request(app) - .post('/internal/managed-runtime/v2/attest') - .set('Authorization', `Bearer ${token}`) - .set('X-Qwen-Managed-Lease-Id', owned.leaseId) - .set('X-Qwen-Managed-Lease-Epoch', String(owned.epoch)) - .send(value); - - await request(app) - .post('/internal/managed-runtime/v2/attest') - .send(body) - .expect(401); - await attest({ ...body, provisionRequestId: 'another' }).expect(409); - const response = await attest(body).expect(200); - expect(response.body).toEqual({ - protocolVersion: 2, - runtimeInstanceId: owned.runtimeInstanceId, - runtimeIncarnation: owned.gatewayIncarnation, - leaseId: owned.leaseId, - epoch: owned.epoch, - provisionRequestId: owned.provisionRequestId, - tenantId: owned.tenantId, - workspaceId: owned.workspaceId, - workspaceGeneration: owned.workspaceGeneration, - workspaceCwd: owned.workspaceCwd, - capabilityDigest: owned.capabilityDigest, - isolationClass: owned.isolationClass, - }); + delete runtime.bridge.executeManagedRuntimeTool; + const provider = new LocalManagedRuntimeProvider(runtime.registry); + expect(() => provider.prepare(prepareRequest)).toThrow('does not support'); expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); - local.dispose(); - }); - - it('fences a parsed first v2 request that arrives after release is acknowledged', async () => { - const runtime = fakeRuntime(); - runtime.bridge.getManagedToolV2Client = vi.fn( - () => toolV2Client() as unknown as ManagedToolV2Client, - ); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const entered = deferred(); - const dispatch = deferred(); - const owned = ownedBoot(); - const server = createServer( - workerApp(local, owned, (req, _res, next) => { - if (req.path.endsWith('/v2/manifest')) { - entered.resolve(); - void dispatch.promise.then(() => next()); - } else next(); - }), - ); - servers.push(server); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${await listen(server)}`, - token, - lease: owned, - }); - try { - const client = await remote.getToolV2Client(prepareRequest); - const manifest = client.manifest().catch((error: unknown) => error); - await entered.promise; - await expect( - remote.release(prepareRequest.sessionId, prepareRequest), - ).resolves.toBe(true); - expect(await manifest).toBeInstanceOf(Error); - expect(runtime.close).toHaveBeenCalledOnce(); - const late = vi.spyOn(local, 'getToolV2Client'); - dispatch.resolve(); - await vi.waitFor(() => expect(late).toHaveBeenCalledOnce()); - await expect(late.mock.results[0].value).rejects.toThrow(); - expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledOnce(); - expect(() => local.prepare(prepareRequest)).toThrow('closing'); - expect(() => remote.prepare(prepareRequest)).toThrow(); - await expect( - remote.release(prepareRequest.sessionId, prepareRequest), - ).resolves.toBe(true); - } finally { - dispatch.resolve(); - remote.dispose(); - local.dispose(); - } }); it('upgrades a pending local v1 release to a terminal identity before completing cleanup', async () => { @@ -390,7 +249,7 @@ describe('Managed Runtime providers', () => { const remote = new RemoteManagedRuntimeProvider({ baseUrl: 'http://127.0.0.1:4181', token, - lease: ownedBoot(), + lease: { leaseId: 'lease', epoch: 1 }, fetch: vi.fn(async (url: RequestInfo | URL) => { const path = new URL(String(url)).pathname; if (path.endsWith('/prepare')) @@ -434,7 +293,7 @@ describe('Managed Runtime providers', () => { const remote = new RemoteManagedRuntimeProvider({ baseUrl: 'http://127.0.0.1:4181', token, - lease: ownedBoot(), + lease: { leaseId: 'lease', epoch: 1 }, fetch: vi.fn(async (url: RequestInfo | URL) => { versions.push(new URL(String(url)).pathname); return Response.json({ @@ -480,214 +339,6 @@ describe('Managed Runtime providers', () => { remote.dispose(); }); - it('exposes terminal release only with owned auth, lease and strict workspace identity', async () => { - const runtime = fakeRuntime(); - vi.mocked(runtime.bridge.resumeSession).mockRejectedValue( - Object.assign(new Error('not found'), { code: 'session_not_found' }), - ); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const path = '/internal/managed-runtime/v2/release'; - const body = { ...prepareRequest, protocolVersion: 2 }; - await request(workerApp(local)).post(path).send(body).expect(404); - const app = workerApp(local, ownedBoot()); - await request(app).post(path).send(body).expect(401); - await request(app) - .post(path) - .set('authorization', `Bearer ${token}`) - .send(body) - .expect(409); - const authorized = () => - request(app) - .post(path) - .set('authorization', `Bearer ${token}`) - .set('X-Qwen-Managed-Lease-Id', 'lease') - .set('X-Qwen-Managed-Lease-Epoch', '1'); - await authorized() - .send({ ...body, tenantId: 'other' }) - .expect(409); - await authorized() - .send({ ...body, extra: true }) - .expect(400); - await authorized() - .send({ ...body, protocolVersion: 1 }) - .expect(400); - const result = await authorized().send(body).expect(200); - expect(result.body).toEqual({ protocolVersion: 2, released: true }); - expect(() => local.prepare(prepareRequest)).toThrow(); - local.dispose(); - }); - - it('connects v2 invocations and file history through the owned HTTP listener without losing optional fields', async () => { - const runtime = fakeRuntime(); - const client = toolV2Client(); - runtime.bridge.getManagedToolV2Client = vi.fn( - () => client as unknown as ManagedToolV2Client, - ); - vi.mocked(runtime.bridge.getSessionSummary).mockReturnValue({ - workspaceCwd, - sourceType: 'managed-gateway', - sourceId: prepareRequest.sessionId, - } as never); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const owned: ManagedWorkerBoot = { - type: 'boot', - version: 1, - gatewayIncarnation: 'gateway', - leaseId: 'lease', - epoch: 1, - ...prepareRequest, - token, - outputRoot: '/tmp/owned-output', - cliEntry: '/cli.js', - }; - const server = createServer(workerApp(local, owned)); - servers.push(server); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${await listen(server)}`, - token, - lease: owned, - }); - try { - const connected = await remote.getToolV2Client(prepareRequest); - const trackedFileBackups = Object.fromEntries( - Array.from({ length: 100 }, (_, i) => [ - `${'directory/'.repeat(5)}file-${i}.txt`, - { - backupFileName: '0123456789abcdef@v1', - version: 1, - backupTime: '2026-09-09T00:00:00.000Z', - }, - ]), - ); - const snapshots = Array.from({ length: 100 }, (_, i) => ({ - promptId: `turn-${i}`, - timestamp: '2026-09-09T00:00:00.000Z', - trackedFileBackups, - })); - expect(Buffer.byteLength(JSON.stringify(snapshots))).toBeGreaterThan( - 1024 * 1024, - ); - client.fileHistory.bind.mockResolvedValueOnce({ - ownerSessionId: prepareRequest.sessionId, - revision: 0, - snapshots, - }); - const binding = { - ownerSessionId: prepareRequest.sessionId, - ownerRuntimeSessionId: prepareRequest.sessionId, - executionCwd: workspaceCwd, - snapshots, - }; - await expect(connected.fileHistory!.bind(binding)).resolves.toEqual({ - ownerSessionId: prepareRequest.sessionId, - revision: 0, - snapshots, - }); - await connected.fileHistory!.checkpoint('parent-turn'); - await connected.fileHistory!.snapshot(); - expect(client.fileHistory.bind).toHaveBeenCalledExactlyOnceWith(binding); - expect(client.fileHistory.checkpoint).toHaveBeenCalledExactlyOnceWith( - 'parent-turn', - ); - expect(client.fileHistory.snapshot).toHaveBeenCalledExactlyOnceWith(); - client.fileHistory.snapshot.mockResolvedValueOnce({ - ownerSessionId: prepareRequest.sessionId, - revision: -1, - snapshots: [], - }); - await expect(connected.fileHistory!.snapshot()).rejects.toThrow(); - await connected.manifest(); - const identity = { - sessionId: invocation.sessionId, - promptId: invocation.promptId, - callId: invocation.callId, - capabilityDigest: invocation.capabilityDigest, - policyRevision: invocation.policyRevision, - }; - await connected.beginTurn(identity); - await connected.prepare(identity, 'read_file', { - file_path: 'proof.txt', - }); - const mediaContext = { inputModalities: { image: true, pdf: false } }; - await connected.prepare( - identity, - 'read_file', - { file_path: 'image.png' }, - undefined, - mediaContext, - ); - expect(client.prepare).toHaveBeenLastCalledWith( - identity, - 'read_file', - { file_path: 'image.png' }, - undefined, - mediaContext, - ); - await connected.confirmation(invocation); - await connected.confirm(invocation, 'proceed_once' as never); - await connected.confirm( - invocation, - 'proceed_once' as never, - { answers: { answer: 'yes' } }, - 'preflight', - ); - await connected.preflight(invocation); - await connected.execute(invocation); - await connected.status(invocation); - await connected.cancel(invocation); - expect(client.confirm.mock.calls).toEqual([ - [invocation, 'proceed_once', undefined, 'permission'], - [ - invocation, - 'proceed_once', - { answers: { answer: 'yes' } }, - 'preflight', - ], - ]); - expect(client.execute).toHaveBeenCalledExactlyOnceWith(invocation); - expect(client.status).toHaveBeenCalledExactlyOnceWith(invocation, 0); - const data = Buffer.alloc(7 * 1024 * 1024, 1).toString('base64'); - const mediaResult = { - executionStatus: 'success', - result: { - llmContent: [{ inlineData: { mimeType: 'image/jpeg', data } }], - returnDisplay: 'image', - }, - }; - const mediaStatus = { - state: 'settled', - result: mediaResult, - progress: [], - cancelRequested: false, - firstAvailableSeq: 1, - lastSeq: 0, - progressGap: false, - }; - client.execute.mockResolvedValueOnce(mediaResult); - client.status.mockResolvedValueOnce(mediaStatus); - client.cancel.mockResolvedValueOnce(mediaStatus); - expect(data.length).toBeGreaterThan(8 * 1024 * 1024); - await expect(connected.execute(invocation)).resolves.toEqual(mediaResult); - await expect(connected.status(invocation)).resolves.toEqual(mediaStatus); - await expect(connected.cancel(invocation)).resolves.toEqual(mediaStatus); - await expect( - remote.getToolV2Client({ ...prepareRequest, tenantId: 'other' }), - ).rejects.toThrow(); - await expect( - remote.release(prepareRequest.sessionId, prepareRequest), - ).resolves.toBe(true); - vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( - Object.assign(new Error('not found'), { code: 'session_not_found' }), - ); - await expect( - remote.release(prepareRequest.sessionId, prepareRequest), - ).resolves.toBe(true); - } finally { - remote.dispose(); - local.dispose(); - } - }); - it('applies the media exception only to valid v2 inline bytes over a chunked HTTP response', async () => { let responseText = ''; const server = createServer((req, res) => { @@ -706,7 +357,7 @@ describe('Managed Runtime providers', () => { const remote = new RemoteManagedRuntimeProvider({ baseUrl: `http://127.0.0.1:${await listen(server)}`, token, - lease: ownedBoot(), + lease: { leaseId: 'lease', epoch: 1 }, }); try { const connected = await remote.getToolV2Client(prepareRequest); @@ -786,74 +437,6 @@ describe('Managed Runtime providers', () => { } }); - it('keeps an admitted v2 execute response during release while refusing new execution', async () => { - const runtime = fakeRuntime(); - const client = toolV2Client(); - const started = deferred(); - const closing = deferred(); - const finish = deferred(); - client.execute.mockImplementation(async () => { - started.resolve(); - await finish.promise; - return { executionStatus: 'success' }; - }); - runtime.close.mockImplementation(async () => { - closing.resolve(); - await finish.promise; - }); - runtime.bridge.getManagedToolV2Client = vi.fn( - () => client as unknown as ManagedToolV2Client, - ); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const server = createServer(workerApp(local, ownedBoot())); - servers.push(server); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${await listen(server)}`, - token, - lease: ownedBoot(), - }); - let pending: Promise | undefined; - let release: Promise | undefined; - try { - const connected = await remote.getToolV2Client(prepareRequest); - let settled = false; - pending = connected.execute(invocation).then( - (result) => { - settled = true; - return result; - }, - (error: unknown) => { - settled = true; - return error; - }, - ); - await started.promise; - release = remote.release(prepareRequest.sessionId, prepareRequest); - await closing.promise; - await expect(connected.execute(invocation)).rejects.toThrow('released'); - await expect( - connected.prepare(invocation, 'read_file', {}), - ).rejects.toThrow('released'); - await expect(connected.status(invocation)).resolves.toMatchObject({ - state: 'executing', - }); - await expect(connected.cancel(invocation)).resolves.toMatchObject({ - state: 'cancel_requested', - }); - expect(settled).toBe(false); - finish.resolve(); - await expect(pending).resolves.toEqual({ executionStatus: 'success' }); - await expect(release).resolves.toBe(true); - expect(client.execute).toHaveBeenCalledOnce(); - await expect(connected.execute(invocation)).rejects.toThrow(); - } finally { - finish.resolve(); - await Promise.allSettled([pending, release]); - remote.dispose(); - local.dispose(); - } - }); - it('pins request identity and lease while retrying cold-start preparation', async () => { const lease = { leaseId: 'owned-lease', epoch: 1 }; const input = { ...prepareRequest }; @@ -1118,346 +701,6 @@ describe('Managed Runtime providers', () => { local.dispose(); }); - it('exposes v2 only on an owned listener and forwards strictly bound calls to the issued client', async () => { - const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const execute = vi.fn(async () => ({ - executionStatus: 'success' as const, - result: { llmContent: 'proof', returnDisplay: 'proof' }, - })); - const client = { - manifest: vi.fn(async () => ({ - ...manifest, - policyRevision: 'generation', - })), - execute, - beginTurn: vi.fn(async () => {}), - confirm: vi.fn(async () => {}), - } as unknown as ManagedToolV2Client; - runtime.bridge.getManagedToolV2Client = vi.fn(() => client); - const outer = { ...prepareRequest, protocolVersion: 2 }; - await request(workerApp(local)) - .post('/internal/managed-runtime/v2/manifest') - .set('Authorization', `Bearer ${token}`) - .send(outer) - .expect(404); - const app = express(); - app.use(express.json()); - registerManagedRuntimeWorkerRoutes(app, { - provider: local, - owned: { - type: 'boot', - version: 1, - gatewayIncarnation: 'gateway', - leaseId: 'lease', - epoch: 2, - ...prepareRequest, - token, - outputRoot: '/tmp/owned', - cliEntry: '/tmp/cli.js', - }, - authorize: (req, res, next) => { - if (req.headers.authorization !== `Bearer ${token}`) - res.sendStatus(401); - else next(); - }, - }); - const post = (operation: string, fields: Record = {}) => - request(app) - .post(`/internal/managed-runtime/v2/${operation}`) - .set('Authorization', `Bearer ${token}`) - .set('X-Qwen-Managed-Lease-Id', 'lease') - .set('X-Qwen-Managed-Lease-Epoch', '2') - .send({ ...outer, ...fields }); - for (const operation of [ - 'manifest', - 'begin-turn', - 'prepare', - 'confirmation', - 'confirm', - 'preflight', - 'execute', - 'status', - 'cancel', - ]) { - await request(app) - .post(`/internal/managed-runtime/v2/${operation}`) - .send(outer) - .expect(401); - await request(app) - .post(`/internal/managed-runtime/v2/${operation}`) - .set('Authorization', `Bearer ${token}`) - .send(outer) - .expect(409); - await post(operation, { tenantId: 'foreign' }).expect(409); - } - expect(runtime.bridge.getManagedToolV2Client).not.toHaveBeenCalled(); - await post('manifest').expect(200); - expect(runtime.bridge.getManagedToolV2Client).toHaveBeenCalledWith( - prepareRequest.sessionId, - { clientId: 'runtime-client-p8' }, - ); - const identity = { - sessionId: prepareRequest.sessionId, - promptId: 'prompt-1', - callId: 'call-1', - capabilityDigest: manifest.capabilityDigest, - policyRevision: 'generation', - }; - const reference = { - ...identity, - invocationId: 'invocation-1', - argsDigest: 'b'.repeat(64), - }; - await post('execute', { protocolVersion: 1, reference }).expect(400); - await post('execute', { reference, authorized: true }).expect(400); - await post('execute', { - reference: { - ...reference, - sessionId: '550e8400-e29b-41d4-a716-446655440109', - }, - }).expect(400); - expect(execute).not.toHaveBeenCalled(); - const beginning = await post('begin-turn', { identity }).expect(200); - expect(beginning.body).toEqual({ protocolVersion: 2, result: null }); - const result = await post('execute', { reference }).expect(200); - expect(result.body).toMatchObject({ - protocolVersion: 2, - result: { executionStatus: 'success' }, - }); - expect(execute).toHaveBeenCalledExactlyOnceWith(reference); - expect(runtime.execute).not.toHaveBeenCalled(); - local.dispose(); - }); - - it('requires the immutable owned lease and scope on all private operations', async () => { - const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const app = express(); - app.use(express.json()); - const owned = { - type: 'boot' as const, - version: 1 as const, - gatewayIncarnation: 'gateway', - leaseId: 'lease', - epoch: 2, - ...prepareRequest, - token, - outputRoot: '/tmp/owned', - cliEntry: '/tmp/cli.js', - }; - registerManagedRuntimeWorkerRoutes(app, { - provider: local, - owned, - authorize: (req, res, next) => { - if (req.headers.authorization !== `Bearer ${token}`) { - res.sendStatus(401); - return; - } - next(); - }, - }); - for (const operation of [ - 'prepare', - 'manifest', - 'execute', - 'cancel', - 'release', - ]) { - const url = `${MANAGED_RUNTIME_ROUTE_PREFIX}/${operation}`; - await request(app).post(url).send(prepareRequest).expect(401); - await request(app) - .post(url) - .set('Authorization', `Bearer ${token}`) - .send(prepareRequest) - .expect(409); - await request(app) - .post(url) - .set('Authorization', `Bearer ${token}`) - .set('X-Qwen-Managed-Lease-Id', 'old') - .set('X-Qwen-Managed-Lease-Epoch', '2') - .send(prepareRequest) - .expect(409); - await request(app) - .post(url) - .set('Authorization', `Bearer ${token}`) - .set('X-Qwen-Managed-Lease-Id', 'lease') - .set('X-Qwen-Managed-Lease-Epoch', '2') - .send({ ...prepareRequest, tenantId: 'other' }) - .expect(409); - } - expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); - const server = createServer(app); - servers.push(server); - const port = await listen(server); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${port}`, - token, - lease: owned, - }); - const handle = remote.prepare(prepareRequest); - await handle.ready; - expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledOnce(); - await expect( - handle.getManifest(new AbortController().signal), - ).resolves.toEqual(manifest); - remote.dispose(); - local.dispose(); - }); - - it('keeps Gateway prepare pending until a separate Runtime worker starts', async () => { - const reservation = createServer(); - const port = await listen(reservation); - await close(reservation); - - const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${port}`, - token, - prepareRetryDelayMs: 5, - prepareRetryMaxDelayMs: 10, - prepareRetryWindowMs: 2_000, - }); - const handle = remote.prepare(prepareRequest); - let ready = false; - void handle.ready.then(() => { - ready = true; - }); - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(ready).toBe(false); - expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); - - const server = createServer(workerApp(local)); - servers.push(server); - await listen(server, port); - await handle.ready; - expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); - - await expect( - handle.getManifest(new AbortController().signal), - ).resolves.toEqual(manifest); - await expect( - handle.execute( - { - executionId: 'execution-p8', - turnId: 'turn-p8', - toolCallId: 'call-p8', - capabilityDigest: manifest.capabilityDigest, - toolName: 'read_file', - input: { file_path: 'proof.txt' }, - }, - new AbortController().signal, - ), - ).resolves.toMatchObject({ executionStatus: 'success' }); - expect(runtime.execute).toHaveBeenCalledTimes(1); - - await close(server); - local.dispose(); - vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ - sessionId: prepareRequest.sessionId, - workspaceCwd, - attached: true, - clientId: 'runtime-client-restored-p8', - hasActivePrompt: false, - sourceType: 'managed-gateway', - sourceId: prepareRequest.sessionId, - sourcePersisted: true, - state: {} as never, - }); - const restoredLocal = new LocalManagedRuntimeProvider(runtime.registry); - const restoredServer = createServer(workerApp(restoredLocal)); - servers.push(restoredServer); - await listen(restoredServer, port); - const continuation = remote.prepare({ - ...prepareRequest, - turnKind: 'continuation', - }); - await continuation.ready; - await expect( - continuation.getManifest(new AbortController().signal), - ).resolves.toEqual(manifest); - expect(runtime.bridge.resumeSession).toHaveBeenCalledWith({ - sessionId: prepareRequest.sessionId, - workspaceCwd, - sourceType: 'managed-gateway', - sourceId: prepareRequest.sessionId, - }); - - await close(restoredServer); - restoredLocal.dispose(); - vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ - sessionId: prepareRequest.sessionId, - workspaceCwd, - attached: true, - clientId: 'runtime-client-release-p8', - hasActivePrompt: false, - sourceType: 'managed-gateway', - sourceId: prepareRequest.sessionId, - sourcePersisted: true, - state: {} as never, - }); - const releaseLocal = new LocalManagedRuntimeProvider(runtime.registry); - const releaseServer = createServer(workerApp(releaseLocal)); - servers.push(releaseServer); - await listen(releaseServer, port); - - await expect(remote.release(prepareRequest.sessionId)).resolves.toBe(true); - expect(runtime.close).toHaveBeenCalledWith(prepareRequest.sessionId, { - clientId: 'runtime-client-release-p8', - }); - remote.dispose(); - releaseLocal.dispose(); - }); - - it('sends explicit cancellation without retrying Tool execution', async () => { - const runtime = fakeRuntime(); - let executionStarted!: () => void; - const started = new Promise((resolve) => { - executionStarted = resolve; - }); - runtime.execute.mockImplementation( - (_sessionId, _toolRequest, signal: AbortSignal) => - new Promise((_resolve, reject) => { - executionStarted(); - signal.addEventListener( - 'abort', - () => reject(signal.reason ?? new Error('aborted')), - { once: true }, - ); - }), - ); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const server = createServer(workerApp(local)); - servers.push(server); - const port = await listen(server); - const remote = new RemoteManagedRuntimeProvider({ - baseUrl: `http://127.0.0.1:${port}`, - token, - }); - const handle = remote.prepare(prepareRequest); - await handle.ready; - const controller = new AbortController(); - const execution = handle.execute( - { - executionId: 'execution-cancel-p8', - turnId: 'turn-cancel-p8', - toolCallId: 'call-cancel-p8', - capabilityDigest: manifest.capabilityDigest, - toolName: 'read_file', - input: { file_path: 'proof.txt' }, - }, - controller.signal, - ); - await started; - controller.abort(new Error('deadline')); - await expect(execution).rejects.toThrow('deadline'); - await vi.waitFor(() => expect(runtime.cancel).toHaveBeenCalledTimes(1)); - expect(runtime.execute).toHaveBeenCalledTimes(1); - remote.dispose(); - local.dispose(); - }); - it('closes a restored Session when release races with warmup', async () => { const runtime = fakeRuntime(); let finishResume!: (session: { @@ -1691,39 +934,6 @@ describe('Managed Runtime providers', () => { local.dispose(); }); - it('rejects unauthenticated and model-bearing worker requests', async () => { - const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const app = workerApp(local); - await request(app) - .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) - .send(prepareRequest) - .expect(401); - const forbidden = await request(app) - .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) - .set('authorization', `Bearer ${token}`) - .send({ ...prepareRequest, prompt: 'must not cross the boundary' }); - expect(forbidden.status).toBe(400); - expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); - local.dispose(); - }); - - it('treats a workspace mapping mismatch as a permanent conflict', async () => { - const runtime = fakeRuntime(); - const local = new LocalManagedRuntimeProvider(runtime.registry); - const response = await request(workerApp(local)) - .post(`${MANAGED_RUNTIME_ROUTE_PREFIX}/prepare`) - .set('authorization', `Bearer ${token}`) - .send({ ...prepareRequest, workspaceCwd: '/tmp/wrong-workspace' }); - - expect(response.status).toBe(409); - expect(response.body).toMatchObject({ - code: 'managed_runtime_identity_conflict', - }); - expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); - local.dispose(); - }); - it('rejects cleartext non-loopback Runtime origins', () => { expect( () => diff --git a/packages/cli/src/serve/managed-runtime-provider.ts b/packages/cli/src/serve/managed-runtime-provider.ts index 8e3c5556c07..f98b735f3d5 100644 --- a/packages/cli/src/serve/managed-runtime-provider.ts +++ b/packages/cli/src/serve/managed-runtime-provider.ts @@ -329,6 +329,17 @@ export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { false, ); } + if ( + !runtime.bridge.getManagedRuntimeToolManifest || + !runtime.bridge.executeManagedRuntimeTool || + !runtime.bridge.cancelManagedRuntimeTool + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'The bridge does not support Managed Runtime tools.', + false, + ); + } const readyBinding = this.startWarmup(runtime, request); return { ready: readyBinding.then(() => undefined), @@ -342,7 +353,7 @@ export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { ]); operationSignal.throwIfAborted(); return waitForValue( - binding.runtime.bridge.getManagedRuntimeToolManifest( + binding.runtime.bridge.getManagedRuntimeToolManifest!( request.sessionId, { clientId: binding.runtimeClientId }, ), @@ -357,7 +368,7 @@ export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { this.lifetime.signal, ]); operationSignal.throwIfAborted(); - return binding.runtime.bridge.executeManagedRuntimeTool( + return binding.runtime.bridge.executeManagedRuntimeTool!( request.sessionId, toolRequest, operationSignal, @@ -422,6 +433,13 @@ export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { } }; assertBinding(true); + if (!binding.runtime.bridge.getManagedToolV2Client) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'The bridge does not support Managed Tool v2.', + false, + ); + } const client = binding.runtime.bridge.getManagedToolV2Client( request.sessionId, { @@ -483,7 +501,7 @@ export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { ); } if (!binding) return false; - const result = await binding.runtime.bridge.cancelManagedRuntimeTool( + const result = await binding.runtime.bridge.cancelManagedRuntimeTool!( sessionId, executionId, { clientId: binding.runtimeClientId }, diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 868e5586e12..ab87421d7e1 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -28,7 +28,6 @@ import express, { } from 'express'; import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; import { validateHostedHarnessProfile } from './hosted-harness-profile.js'; -import { HOSTED_HARNESS_CAPABILITY_DIGEST_ENV } from './hosted-harness-contract.js'; import { isWithinRoot } from '../config/path-comparison.js'; import { readSshWorkspace } from './ssh-workspace-store.js'; import { @@ -3365,32 +3364,7 @@ async function runQwenServeImpl( 'serve / ACP / web terminals', ); - const hostedProfile = optsIn.profile ?? 'default'; - if (hostedProfile === 'hosted-harness') { - optsIn.managedRuntimeBrokerUrl ??= - process.env['QWEN_RUNTIME_BROKER_URL']; - optsIn.managedRuntimeBrokerToken ??= - process.env['QWEN_RUNTIME_BROKER_TOKEN']; - optsIn.hostedHarnessCapabilityDigest ??= - process.env[HOSTED_HARNESS_CAPABILITY_DIGEST_ENV]; - validateHostedHarnessProfile( - { ...optsIn, profile: hostedProfile }, - { - serverToken: QWEN_SERVER_TOKEN_ENV, - brokerUrl: 'QWEN_RUNTIME_BROKER_URL', - brokerToken: 'QWEN_RUNTIME_BROKER_TOKEN', - capabilityDigest: HOSTED_HARNESS_CAPABILITY_DIGEST_ENV, - }, - ); - } else if ( - optsIn.managedRuntimeBrokerUrl !== undefined || - optsIn.managedRuntimeBrokerToken !== undefined || - optsIn.hostedHarnessCapabilityDigest !== undefined - ) { - throw new Error( - 'Managed Runtime Broker options require --profile hosted-harness.', - ); - } + validateHostedHarnessProfile(optsIn); const baseEnv: NodeJS.ProcessEnv = { ...process.env }; const launchMemoryProjectScopeValue = baseEnv['QWEN_CODE_MEMORY_PROJECT_SCOPE']; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4cb816c2561..3d9ac075c3f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2266,6 +2266,8 @@ export type DerivedConfigOverrides = Partial< | 'getPlanFilePath' | 'getWorkspaceContext' | 'getFileService' + | 'getEffectiveInputModalities' + | 'getFileReadCache' | 'getToolRegistry' | 'getPermissionManager' | 'getApprovalMode' diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.test.ts b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts index d2c03b92e31..f3799235765 100644 --- a/packages/core/src/tools/managed-tool-file-history-protocol.test.ts +++ b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts @@ -75,7 +75,6 @@ describe('managed file history protocol', () => { getWorkspaceContext: () => ({ getDirectories: () => context.workspaceDirectories, }), - getMemoryBaseDir: () => context.memoryBaseDir, isLsToolEnabled: () => context.lsToolEnabled, getFileFilteringOptions: () => context.fileFilteringOptions, getUseRipgrep: () => false, diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.ts b/packages/core/src/tools/managed-tool-file-history-protocol.ts index dc23fbdb4ff..c8be4ee06d6 100644 --- a/packages/core/src/tools/managed-tool-file-history-protocol.ts +++ b/packages/core/src/tools/managed-tool-file-history-protocol.ts @@ -5,6 +5,7 @@ */ import * as path from 'node:path'; +import { getMemoryBaseDir } from '../memory/paths.js'; import type { Config } from '../config/config.js'; import { normalizeQwenCustomIgnoreFileNames } from '../utils/qwenIgnoreParser.js'; import type { SerializedFileHistorySnapshot } from '../services/fileHistoryService.js'; @@ -42,7 +43,7 @@ export function captureManagedToolExecutionContext( const filtering = config.getFileFilteringOptions(); return { workspaceDirectories: [...config.getWorkspaceContext().getDirectories()], - memoryBaseDir: config.getMemoryBaseDir(), + memoryBaseDir: getMemoryBaseDir(), lsToolEnabled: config.isLsToolEnabled(), grepOptions: { useRipgrep: config.getUseRipgrep(), diff --git a/packages/core/src/tools/managed-tool-protocol.test.ts b/packages/core/src/tools/managed-tool-protocol.test.ts index feca31bcb57..43e9f029b49 100644 --- a/packages/core/src/tools/managed-tool-protocol.test.ts +++ b/packages/core/src/tools/managed-tool-protocol.test.ts @@ -219,6 +219,32 @@ describe('managed tool JSON digests', () => { describe('managed tool confirmation DTOs', () => { const onConfirm = vi.fn(async () => {}); + it('allows a bounded confirmation larger than the request budget', () => { + const content = 'x'.repeat(150 * 1024); + const details: ToolCallConfirmationDetails = { + type: 'edit', + title: 'Edit', + fileName: 'a.txt', + filePath: '/workspace/a.txt', + fileDiff: '-a\n+b', + originalContent: content, + newContent: content + 'b', + onConfirm, + }; + expect(serializeManagedToolConfirmation(details)).toMatchObject({ + originalContent: content, + newContent: content + 'b', + }); + expect(() => managedToolDigest({ content })).not.toThrow(); + expect(() => managedToolDigest({ old: content, new: content })).toThrow(); + expect(() => + serializeManagedToolConfirmation({ + ...details, + originalContent: 'x'.repeat(8 * 1024 * 1024), + }), + ).toThrow('size limit'); + }); + const details: ToolCallConfirmationDetails[] = [ { type: 'edit', diff --git a/packages/core/src/tools/managed-tool-protocol.ts b/packages/core/src/tools/managed-tool-protocol.ts index 1385c24896b..e966dcf1b27 100644 --- a/packages/core/src/tools/managed-tool-protocol.ts +++ b/packages/core/src/tools/managed-tool-protocol.ts @@ -401,7 +401,9 @@ export function serializeManagedToolConfirmation( 'Confirmation belongs to the Gateway.', ); } - return JSON.parse(canonicalJson(result)) as ManagedToolConfirmationDetails; + return JSON.parse( + canonicalJson(result, 8 * 1024 * 1024 - 64 * 1024), + ) as ManagedToolConfirmationDetails; } export function parseManagedToolConfirmationPayload( diff --git a/packages/core/src/tools/managed-tool-runtime.test.ts b/packages/core/src/tools/managed-tool-runtime.test.ts index 32e5c9251a7..d33c5d64951 100644 --- a/packages/core/src/tools/managed-tool-runtime.test.ts +++ b/packages/core/src/tools/managed-tool-runtime.test.ts @@ -24,6 +24,7 @@ import { type ManagedToolExecutionResult, type ManagedToolRuntimeFileHistory, } from './managed-tool-runtime.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; import type { ManagedToolCallIdentity, ManagedToolInvocationReference, @@ -330,6 +331,8 @@ describe('ManagedToolRuntime', () => { await runtime.prepare({ ...call, callId: 'call-2' }, ReadFileTool.Name, { file_path: '/managed-child/b.txt', }); + expect(first.params).toEqual({ file_path: '/managed-child/a.txt' }); + expect(first.argsDigest).toBe(managedToolDigest(first.params)); expect(first.description).toBe('a.txt'); expect(first.defaultPermission).toBe('allow'); expect(first.sessionId).toBe(sessionId); @@ -735,24 +738,6 @@ describe('ManagedToolRuntime', () => { expect(hooks.failure).toHaveBeenCalledTimes(1); }); - it.each(['cancelled', 'not_started'] as const)( - 'preserves explicit Shell %s without inventing a tool error', - async (executionStatus) => { - tool.setup = (invocation) => - invocation.execute.mockResolvedValue({ ...rawResult, executionStatus }); - const ref = await prepare(); - await runtime.preflight(ref); - expect(await runtime.execute(ref)).toMatchObject({ - executionStatus, - result: rawResult, - }); - expect(hooks.post).not.toHaveBeenCalled(); - expect(hooks.failure).toHaveBeenCalledTimes( - executionStatus === 'cancelled' ? 1 : 0, - ); - }, - ); - it('does not fabricate cancellation when an already running write actually succeeds', async () => { const gate = deferred(); tool.setup = (invocation) => diff --git a/packages/core/src/tools/managed-tool-runtime.ts b/packages/core/src/tools/managed-tool-runtime.ts index cc57406062f..73611a85dd8 100644 --- a/packages/core/src/tools/managed-tool-runtime.ts +++ b/packages/core/src/tools/managed-tool-runtime.ts @@ -5,7 +5,6 @@ */ import { randomUUID } from 'node:crypto'; -import { canUseRipgrep } from '../utils/ripgrepUtils.js'; import { deriveConfig, type Config } from '../config/config.js'; import { firePreToolUseHook, @@ -45,6 +44,16 @@ import { import type { ManagedToolFileHistoryClient } from './managed-tool-file-history-protocol.js'; +function invocationParams(params: unknown): Record { + // Native tools add optional undefined fields; hash the same JSON sent on the wire. + const projected = JSON.parse(JSON.stringify(params)) as Record< + string, + unknown + >; + managedToolDigest(projected); + return projected; +} + export type ManagedToolConfirmationPhase = 'permission' | 'preflight'; export type ManagedToolV2Client = { @@ -422,7 +431,7 @@ export class ManagedToolRuntime { const reference: ManagedToolInvocationReference = { ...identity, invocationId: randomUUID(), - argsDigest: managedToolDigest(invocation.params), + argsDigest: managedToolDigest(invocationParams(invocation.params)), }; const toolUseId = generateToolUseId(); const entry: Entry = { @@ -434,7 +443,7 @@ export class ManagedToolRuntime { toolUseId, prepared: { ...reference, - params: structuredClone(invocation.params) as Record, + params: invocationParams(invocation.params), description: invocation.getDescription(), locations: invocation.toolLocations(), defaultPermission, @@ -540,7 +549,8 @@ export class ManagedToolRuntime { if (entry.execution) throw new Error('Managed tool invocation already dispatched.'); if ( - managedToolDigest(entry.invocation.params) !== entry.reference.argsDigest + managedToolDigest(invocationParams(entry.invocation.params)) !== + entry.reference.argsDigest ) { throw new Error('Managed tool parameters changed after preparation.'); } @@ -630,15 +640,14 @@ export class ManagedToolRuntime { const raw = await entry.invocation.execute( signal, (output) => this.progress(entry, output), - { - ...this.config.getShellExecutionConfig(), - requireProcessGroupExit: true, - }, + this.config.getShellExecutionConfig(), ); result = { - executionStatus: - raw.executionStatus ?? - (raw.error ? (signal.aborted ? 'cancelled' : 'error') : 'success'), + executionStatus: raw.error + ? signal.aborted + ? 'cancelled' + : 'error' + : 'success', }; try { result.result = structuredClone({ @@ -834,22 +843,16 @@ export async function createBuiltinManagedToolRuntime( { WriteFileTool }, { EditTool }, { NotebookEditTool }, - { ShellTool }, { GlobTool }, { LSTool }, - { GrepTool }, - { RipGrepTool }, { ZoomImageTool }, ] = await Promise.all([ import('./read-file.js'), import('./write-file.js'), import('./edit.js'), import('./notebook-edit.js'), - import('./shell.js'), import('./glob.js'), import('./ls.js'), - import('./grep.js'), - import('./ripGrep.js'), import('./zoom-image.js'), ]); const registry = config.getToolRegistry(); @@ -884,7 +887,6 @@ export async function createBuiltinManagedToolRuntime( WriteFileTool, EditTool, NotebookEditTool, - ShellTool, GlobTool, LSTool, ZoomImageTool, @@ -892,29 +894,6 @@ export async function createBuiltinManagedToolRuntime( (Constructor) => Constructor !== LSTool || toolConfig.isLsToolEnabled(), ); const revision = randomUUID(); - const admittedGrep = registry.getTool(GrepTool.Name); - let grep: AnyDeclarativeTool | undefined; - if ( - admittedGrep?.constructor === GrepTool || - admittedGrep?.constructor === RipGrepTool - ) { - let useRipgrep = false; - if (toolConfig.getUseRipgrep()) { - try { - useRipgrep = await canUseRipgrep(toolConfig.getUseBuiltinRipgrep(), { - requireProcessGroupExit: true, - cwd: toolConfig.getTargetDir(), - }); - } catch (error) { - if ( - (error as NodeJS.ErrnoException).code === - 'ERR_OWNED_COMMAND_UNSUPPORTED' - ) - throw error; - } - } - grep = useRipgrep ? new RipGrepTool(toolConfig) : new GrepTool(toolConfig); - } const boundTools = toolConfig === config ? undefined @@ -936,10 +915,6 @@ export async function createBuiltinManagedToolRuntime( const tool = config.getToolRegistry().getTool(Constructor.Name); return tool?.constructor === Constructor ? [tool] : []; })), - ...(grep && - config.getToolRegistry().getTool(GrepTool.Name) === admittedGrep - ? [grep] - : []), ], () => revision, fileHistory, diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java index 02c97c124c7..dc9ee991deb 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java @@ -235,7 +235,10 @@ private RuntimeObservation observe(RuntimeProvisionRequest request, return RuntimeObservation.unknown(handle); } OwnedProcess process = owned.get(lastLease.getRuntimeInstanceId()); - if (process != null && !process.process.isAlive()) { + if (process == null) { + return RuntimeObservation.unknown(handle); + } + if (!process.process.isAlive()) { return RuntimeObservation.notFound(); } try { @@ -246,9 +249,6 @@ private RuntimeObservation observe(RuntimeProvisionRequest request, failure.getCode())) { return RuntimeObservation.conflict(handle); } - if (process == null) { - return RuntimeObservation.notFound(); - } return RuntimeObservation.unknown(handle); } RuntimeResourceHandle readyHandle = handle != null ? handle diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java index 0004d8685d6..32d9ed81a06 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java @@ -21,10 +21,8 @@ /** * Private HTTP face of the merged Runtime Broker for a Hosted Harness. * - *

Prepare and create both call {@link RuntimeBrokerService#createExecution}, - * which records the call and drives dispatch. Start reads that record back. - * Resolve asks {@link RuntimeBrokerService#reconcileExecution} for evidence; - * the caller's resolution label is accepted and not applied as a result. + *

The service supports immediate dispatch. Deferred prepare/start and + * operator resolution are unavailable until they have durable service APIs. */ public final class RuntimeBrokerHttpServer implements AutoCloseable { public static final String ROUTE_PREFIX = "/internal/runtime-broker/v1"; @@ -88,8 +86,11 @@ private void handle(HttpExchange exchange) { return; } if ("POST".equals(exchange.getRequestMethod()) - && ("/executions:prepare".equals(relative) - || "/executions".equals(relative))) { + && "/executions:prepare".equals(relative)) { + throw unsupported(); + } + if ("POST".equals(exchange.getRequestMethod()) + && "/executions".equals(relative)) { executionRequest(exchange); return; } @@ -187,20 +188,7 @@ private void execution(HttpExchange exchange, String suffix) throws IOException { if ("POST".equals(exchange.getRequestMethod()) && suffix.endsWith(":start")) { - String executionCallId = pathId(suffix.substring(0, - suffix.length() - ":start".length())); - Map body = requestBody(exchange, "start request"); - requireProtocol(body); - JsonCodec.requiredString(body, "requestId", "start request"); - String harnessSessionId = JsonCodec.requiredString(body, - "harnessSessionId", "start request"); - String runtimeSessionId = JsonCodec.requiredString(body, - "runtimeSessionId", "start request"); - complete(exchange, service.getExecution(harnessSessionId, - runtimeSessionId, executionCallId), - record -> executionEnvelope(harnessSessionId, - runtimeSessionId, record)); - return; + throw unsupported(); } if ("POST".equals(exchange.getRequestMethod()) && suffix.endsWith(":cancel")) { @@ -222,23 +210,7 @@ record -> executionEnvelope(harnessSessionId, } if ("POST".equals(exchange.getRequestMethod()) && suffix.endsWith(":resolve")) { - String executionCallId = pathId(suffix.substring(0, - suffix.length() - ":resolve".length())); - Map body = requestBody(exchange, - "resolve request"); - requireProtocol(body); - JsonCodec.requiredString(body, "requestId", "resolve request"); - String harnessSessionId = JsonCodec.requiredString(body, - "harnessSessionId", "resolve request"); - String runtimeSessionId = JsonCodec.requiredString(body, - "runtimeSessionId", "resolve request"); - requireResolution(JsonCodec.requiredString(body, "resolution", - "resolve request")); - complete(exchange, service.reconcileExecution(harnessSessionId, - runtimeSessionId, executionCallId), - reconciliation -> executionEnvelope(harnessSessionId, - runtimeSessionId, reconciliation.getRecord())); - return; + throw unsupported(); } if ("GET".equals(exchange.getRequestMethod()) && suffix.indexOf('/') < 0) { @@ -271,13 +243,11 @@ private void authorize(HttpExchange exchange) { } } - private static void requireResolution(String raw) { - if (!"confirmed_not_executed".equals(raw) - && !"accepted_unknown".equals(raw)) { - throw new RuntimeBrokerException(400, - "runtime_broker_invalid_request", - "Runtime Broker resolution is unsupported.", false); - } + private static RuntimeBrokerException unsupported() { + return new RuntimeBrokerException(501, + "runtime_broker_operation_unsupported", + "Deferred execution and operator resolution are not implemented.", + false); } private static void requireProtocol(Map body) { @@ -412,6 +382,11 @@ private static Map envelope(String harnessSessionId, private static Map executionEnvelope( String harnessSessionId, String runtimeSessionId, ToolExecutionRecord record) { + if (record.getState() == ToolExecutionRecord.State.UNKNOWN) { + throw new RuntimeBrokerException(409, + "runtime_broker_execution_unknown", + "Runtime execution outcome is unknown.", false); + } Map response = envelope(harnessSessionId, runtimeSessionId, "executionCallId", record.getExecutionCallId()); diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java index 96118e7b881..11b7161e15d 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java @@ -22,7 +22,7 @@ class LocalProcessRuntimeProvisionerTest { private static final String DIGEST = "sha256:" + "a".repeat(64); @Test - void reportsADurableKindAndLosesAMissingProcess() throws Exception { + void reportsUnknownWhenTheProcessIsNotOwned() throws Exception { HttpRuntimeTransport transport = new HttpRuntimeTransport(); try (LocalProcessRuntimeProvisioner provisioner = new LocalProcessRuntimeProvisioner(List.of("node"), @@ -47,7 +47,7 @@ void reportsADurableKindAndLosesAMissingProcess() throws Exception { RuntimeObservation observation = provisioner .reconcile(request, seed, handle, missing) .toCompletableFuture().get(5, TimeUnit.SECONDS); - assertEquals(RuntimeObservation.Outcome.NOT_FOUND, + assertEquals(RuntimeObservation.Outcome.UNKNOWN, observation.getOutcome()); } } diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java new file mode 100644 index 00000000000..8df5636a24e --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java @@ -0,0 +1,153 @@ +package com.alibaba.qwen.code.runtimebroker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.fastjson2.JSON; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RuntimeBrokerHttpServerTest { + @Test + void unsupportedOperationsNeverDispatchOrClaimResolution() throws Exception { + try (Fixture fixture = new Fixture()) { + assertEquals(200, fixture.post("/tool-sessions:acquire", Map.of( + "protocolVersion", 1, "requestId", "acquire", + "harnessSessionId", "harness", "runtimeSessionId", "runtime", + "turnKind", "bootstrap")).statusCode()); + for (String path : new String[] {"/executions:prepare", + "/executions/call:start", "/executions/call:resolve"}) { + HttpResponse response = fixture.post(path, Map.of( + "protocolVersion", 1, "requestId", "request", + "idempotencyKey", "key", "harnessSessionId", "harness", + "runtimeSessionId", "runtime", "turnId", "turn", + "toolCallId", "call", "requestDigest", "digest", + "reference", reference(), "resolution", "accepted_unknown")); + assertEquals(501, response.statusCode(), response.body()); + assertTrue(response.body().contains("runtime_broker_operation_unsupported")); + } + assertEquals(0, fixture.transport.executions.get()); + } + } + + @Test + void unknownExecutionDoesNotBecomeKnownExecuting() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.service.acquire("harness", "runtime", "bootstrap") + .toCompletableFuture().join(); + ToolExecutionRecord created = fixture.service.createExecution( + "harness", "runtime", "key", reference()) + .toCompletableFuture().join(); + String path = "/executions/" + created.getExecutionCallId() + + "?requestId=read&harnessSessionId=harness&runtimeSessionId=runtime"; + HttpRequest request = HttpRequest.newBuilder(fixture.uri(path)) + .header("Authorization", "Bearer secret").GET().build(); + HttpResponse response = fixture.client.send(request, + HttpResponse.BodyHandlers.ofString()); + assertEquals(409, response.statusCode(), response.body()); + assertTrue(response.body().contains("runtime_broker_execution_unknown")); + assertEquals(1, fixture.transport.executions.get()); + } + } + + @Test + void authenticatesBeforeProcessingUnsupportedOperations() throws Exception { + try (Fixture fixture = new Fixture()) { + HttpRequest request = HttpRequest.newBuilder(fixture.uri("/executions:prepare")) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + assertEquals(401, fixture.client.send(request, + HttpResponse.BodyHandlers.ofString()).statusCode()); + assertEquals(0, fixture.transport.executions.get()); + } + } + + private static Map reference() { + return Map.of("sessionId", "runtime", "promptId", "turn", + "callId", "call", "argsDigest", "digest"); + } + + private static final class Fixture implements AutoCloseable { + private final FailingTransport transport = new FailingTransport(); + private final HttpClient client = HttpClient.newHttpClient(); + private final RuntimeBrokerService service; + private final RuntimeBrokerHttpServer server; + + private Fixture() throws Exception { + RuntimeScope scope = new RuntimeScope("tenant", "workspace", + "generation", "/workspace", "capability", "workspace"); + service = new RuntimeBrokerService( + id -> CompletableFuture.completedFuture(scope), + new StaticRuntimeProvisioner(new RuntimeLease("instance", + URI.create("http://127.0.0.1:1234"), "token", "lease", 1)), + transport, new InMemoryRuntimeBindingRepository(), + new InMemoryRuntimeSessionRepository(), + new InMemoryToolExecutionRepository(Clock.systemUTC()), + "broker", Duration.ofMinutes(1), Duration.ofMinutes(1)); + server = new RuntimeBrokerHttpServer(new InetSocketAddress("127.0.0.1", 0), + "secret", service); + server.start(); + } + + private URI uri(String path) { + return server.getBaseUri().resolve(RuntimeBrokerHttpServer.ROUTE_PREFIX + path); + } + + private HttpResponse post(String path, Map body) throws Exception { + return client.send(HttpRequest.newBuilder(uri(path)) + .header("Authorization", "Bearer secret") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(JSON.toJSONString(body))) + .build(), HttpResponse.BodyHandlers.ofString()); + } + + @Override + public void close() { + server.close(); + client.close(); + } + } + + private static final class FailingTransport implements RuntimeTransport { + private final AtomicInteger executions = new AtomicInteger(); + + @Override + public CompletionStage acquire(RuntimeLease lease, RuntimeSession session) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage control(RuntimeLease lease, RuntimeSession session, + Map operation) { + return CompletableFuture.completedFuture(Map.of()); + } + + @Override + public CompletionStage> execute(RuntimeLease lease, + RuntimeSession session, Map reference) { + executions.incrementAndGet(); + return CompletableFuture.failedFuture(new IllegalStateException("connection lost")); + } + + @Override + public CompletionStage> cancel(RuntimeLease lease, + RuntimeSession session, Map reference) { + return CompletableFuture.completedFuture(Map.of("state", "unknown")); + } + + @Override + public CompletionStage release(RuntimeLease lease, RuntimeSession session) { + return CompletableFuture.completedFuture(true); + } + } +} From 06130ca95b8c1a759d294e268ce856d7e3cde1c4 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 25 Sep 2026 21:15:16 +0800 Subject: [PATCH 5/6] fix(serve): close hosted runtime review gaps --- .../hosted-runtime-foundation-boundary.md | 37 +++++ ...osted-runtime-foundation-boundary.zh-CN.md | 28 ++++ packages/cli/src/commands/serve.ts | 8 +- packages/cli/src/serve/fast-path.test.ts | 34 +++++ packages/cli/src/serve/run-qwen-serve.test.ts | 33 +++++ packages/cli/src/serve/types.ts | 2 - .../src/tools/managed-tool-runtime.test.ts | 66 +++++++++ .../core/src/tools/managed-tool-runtime.ts | 2 + .../LocalProcessRuntimeProvisioner.java | 30 +++- .../runtimebroker/RuntimeProvisioner.java | 2 +- .../LocalProcessRuntimeProvisionerTest.java | 133 ++++++++++++++++++ .../resources/fake-attestation-worker.mjs | 4 +- 12 files changed, 365 insertions(+), 14 deletions(-) diff --git a/docs/design/hosted-runtime-foundation-boundary.md b/docs/design/hosted-runtime-foundation-boundary.md index fcb7b336fb9..2d5ed65d217 100644 --- a/docs/design/hosted-runtime-foundation-boundary.md +++ b/docs/design/hosted-runtime-foundation-boundary.md @@ -36,6 +36,13 @@ from failures to establish process-tree cleanup. Only the proven-exit error is accepted during local worker reclamation. Other shutdown consumers retain the existing Error behavior and messages. +Managed-tool invocation records are retained for retries within the active turn. +Starting a new turn requires all prior work to settle, then discards the previous +turn's invocation records and call slots. The 1,024-invocation bound therefore +applies to one turn, rather than the lifetime of the session. Old references fail +after the turn advances; completed calls cannot be prepared again under an old +prompt ID. + ## Broker contract and ownership All HTTP Broker operations are scoped by the resolved Harness session and its @@ -59,6 +66,15 @@ unreachable process is UNKNOWN. Only a locally observed dead Process supports NOT_FOUND. Broker restart adoption remains unavailable, preventing a transient attestation failure from authorizing a replacement worker beside a live one. +Process ownership is keyed by the complete lease, including its attested endpoint. +The binding ID survives generation changes, and retrying even the same seed can +start distinct workers. Releasing one attempt must not remove, kill, confirm, or +report usability for another attempt's process. +Issued identities remain reserved for the provisioner's lifetime, including +after release. If a worker exits and a retry reuses its seed and endpoint, the +new candidate is destroyed and rejected with a non-retryable identity conflict. +This prevents a delayed old release from targeting a replacement process. + Broker clients can retry failed acquisition, invalidate issued clients on release, and retain terminal release intent across concurrent release calls. @@ -69,6 +85,16 @@ Test real ReadFile preparation, oversized-but-bounded edit confirmations, acquir retry, released-client rejection, unsupported profile startup, forced worker termination and Java HTTP unsupported/UNKNOWN/authentication boundaries. +Also verify more than 1,024 completed invocations across turns, current-turn retry +deduplication, and rejection of expired references. Provision two workers for the +same binding using different generations and again using an identical seed; +releasing the losing lease must preserve the winner and leave no orphan processes +after both leases are released. Every serve option must either be parsed by the +fast path or explicitly fall back to the full parser. +Exercise endpoint reuse after both a worker crash and normal release. Pin the +actual serve entry point's rejection before listening, and exclude Shell and +both Grep implementations even when registered in the source tool registry. + Tests imported from the preview for absent worker routes are removed; provider unit and transport-fixture tests remain. Those removed tests cannot establish worker coverage until an actual worker implementation is included. @@ -84,3 +110,14 @@ a shared wire format, durable reserve/start and explicit resolution APIs, sessio store/continuation wiring, process containment for Shell/Grep, and a real cold-start E2E test. Enabling the hosted profile depends on those behaviors, not solely on configuration validation or mocked transport tests. + +Before mounting, bind file-history relative paths and `executionCwd` to the +authenticated, resolved workspace rather than trusting a supplied binding. Unify +the worker/client wire schemas and reconcile the experimental client's 8 MiB +response budget (including its separate media allowance) with the owned-route +contract's 1 MiB tool-result limit. Keep the Hosted handshake middleware unmounted +until its capability digest is validated; ordinary capabilities do not advertise +it. Specify and test the Broker server's loopback/TLS deployment boundary and +bounded request concurrency, and validate the static provisioner through its +eventual integration caller. These are prerequisites for enabling the profile, +not capabilities provided by this PR. diff --git a/docs/design/hosted-runtime-foundation-boundary.zh-CN.md b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md index 35f2f9342aa..0d9b907dbf4 100644 --- a/docs/design/hosted-runtime-foundation-boundary.zh-CN.md +++ b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md @@ -30,6 +30,11 @@ ProcessRegistry 使用 ProcessExitError 区分已确认的非零退出码或信 无法证明进程树清理完成的错误。本地 worker 回收只接受前者。其他关闭流程的 消费者继续保留已有 Error 行为和错误消息。 +Managed-tool 调用记录保留至当前轮次结束,以支持轮次内重试。开始新轮次前必须 +等待此前所有工作完成,随后丢弃上一轮的调用记录及调用槽。因此 1,024 次调用的 +上限针对单个轮次,而不是会话的整个生命周期。轮次推进后旧引用失效;已完成的 +调用不能使用旧 prompt ID 再次 prepare。 + ## Broker 契约与所有权 所有 Broker HTTP 操作归属已解析的 Harness 会话及其 Runtime 会话;tenant 和 @@ -49,6 +54,13 @@ local-process provisioner 只认领自身持有的进程。未持有或不可达 只有本地观测到 Process 已死亡才支持 NOT_FOUND。Broker 重启后的进程认领仍不可用, 避免临时 attestation 故障允许在存活 worker 旁创建替代 worker。 +进程所有权以包含已证明 endpoint 的完整 lease 为键。binding ID 跨 generation +保持不变,即使重试同一 seed,也可能启动不同 worker。释放某次尝试不得删除、 +终止、确认另一尝试的进程,也不得将另一进程的存活状态报告为自身的可用性。 +已发放身份在 provisioner 的整个生命周期内保持保留,release 后也不删除。 +若 worker 退出后重试复用了其 seed 与 endpoint,则销毁新的候选进程,并以 +不可重试的身份冲突拒绝发放,避免延迟到达的旧 release 指向替代进程。 + Broker 客户端允许重试失败的 acquire,在 release 时使已发放客户端失效,并在 并发 release 调用之间保留 terminal 意图。 @@ -59,6 +71,14 @@ Broker 客户端允许重试失败的 acquire,在 release 时使已发放客 重试、已释放客户端拒绝、未实现 profile 启动拒绝、强制终止 worker,以及 Java HTTP 的 unsupported、UNKNOWN 和认证边界。 +另外验证跨轮次完成超过 1,024 次调用、当前轮次重试去重以及过期引用拒绝。 +对同一 binding 分别使用不同 generation 和完全相同的 seed 启动两个 worker; +释放失败尝试的 lease 必须保留胜出的 worker,两份 lease 都释放后不得遗留孤儿 +进程。每个 serve 参数必须由 fast path 解析,或明确回退到完整解析器。 +覆盖 worker 崩溃和正常 release 后的 endpoint 复用。测试实际 serve 入口在监听 +之前拒绝启动,并验证源工具 registry 即使注册了 Shell 及两种 Grep 实现,也 +不会将它们纳入 managed 能力。 + 移除从预览带入、依赖不存在 worker 路由的测试;保留 provider 单测和传输 fixture 测试。在真正包含 worker 实现之前,被移除的测试无法证明 worker 覆盖率。 @@ -71,3 +91,11 @@ HTTP 的 unsupported、UNKNOWN 和认证边界。 reserve/start 与显式 resolution API、session-store/continuation 接线、 Shell/Grep 进程管控,以及真实冷启动 E2E 测试。启用 hosted profile 必须建立在 这些行为之上,不能仅依靠配置校验或 mock 传输测试。 + +挂载前,必须将文件历史中的相对路径及 `executionCwd` 绑定到已认证且已解析的 +工作区,不能信任传入的 binding。统一 worker/client wire schema,并协调实验 +客户端的 8 MiB 响应预算(含独立媒体例外)与自有路由契约的 1 MiB 工具结果上限。 +Hosted 握手中间件在 capability digest 完成验证前不得挂载;普通 capabilities +不宣称该能力。明确并测试 Broker 服务端的 loopback/TLS 部署边界和有界请求并发, +通过未来实际集成的调用方验证 static provisioner。这些都是启用 profile 的 +前置条件,不是本 PR 已提供的能力。 diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 44fbb95c9e7..93c1fde5a0f 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -623,25 +623,25 @@ export const serveCommand: CommandModule = { type: 'string', requiresArg: true, description: - 'Use a separate Managed Runtime worker at this HTTP(S) origin. Requires --experimental-managed-agents.', + 'Reserved experimental Runtime URL; not implemented and rejects startup.', }) .option('experimental-managed-runtime-token', { type: 'string', requiresArg: true, description: - 'Bearer token for the separate Managed Runtime worker. Falls back to QWEN_MANAGED_RUNTIME_TOKEN, then the daemon token.', + 'Reserved experimental Runtime credential; not implemented and rejects startup.', }) .option('managed-runtime-broker-url', { type: 'string', requiresArg: true, description: - 'Java Runtime Broker HTTP(S) origin for --profile hosted-harness. Falls back to QWEN_RUNTIME_BROKER_URL.', + 'Reserved Broker URL for --profile hosted-harness; not implemented and rejects startup.', }) .option('managed-runtime-broker-token', { type: 'string', requiresArg: true, description: - 'Harness-to-Broker bearer credential. Falls back to QWEN_RUNTIME_BROKER_TOKEN.', + 'Reserved Broker credential for --profile hosted-harness; not implemented and rejects startup.', }) .option('writer-idle-timeout-ms', { type: 'number', diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index c1e7e40dc10..32da4addc1c 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -731,6 +731,32 @@ describe('serve fast path argument parsing', () => { ['rate-limit-read', ['--rate-limit-read', '120']], ['rate-limit-window-ms', ['--rate-limit-window-ms', '60000']], ['experimental-lsp', ['--experimental-lsp']], + ['profile', ['--profile', 'hosted-harness']], + ['experimental-managed-agents', ['--experimental-managed-agents']], + [ + 'experimental-managed-runtime-worker', + ['--experimental-managed-runtime-worker'], + ], + [ + 'experimental-managed-runtime-auto-local', + ['--experimental-managed-runtime-auto-local'], + ], + [ + 'experimental-managed-runtime-url', + ['--experimental-managed-runtime-url', 'http://127.0.0.1:3001'], + ], + [ + 'experimental-managed-runtime-token', + ['--experimental-managed-runtime-token', 'test-token'], + ], + [ + 'managed-runtime-broker-url', + ['--managed-runtime-broker-url', 'http://127.0.0.1:3002'], + ], + [ + 'managed-runtime-broker-token', + ['--managed-runtime-broker-token', 'test-token'], + ], ['restore-ask-user-question', ['--restore-ask-user-question']], ['external-tool-guard-mode', ['--external-tool-guard-mode', 'off']], [ @@ -747,6 +773,14 @@ describe('serve fast path argument parsing', () => { ]); const expectedFallbackOptions = new Set([ 'channel', + 'profile', + 'experimental-managed-agents', + 'experimental-managed-runtime-worker', + 'experimental-managed-runtime-auto-local', + 'experimental-managed-runtime-url', + 'experimental-managed-runtime-token', + 'managed-runtime-broker-url', + 'managed-runtime-broker-token', 'external-tool-guard-endpoint', 'external-tool-guard-mode', 'external-tool-guard-timeout-ms', diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 83d5f099e4f..1622a3ec9cb 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -5885,6 +5885,39 @@ describe('runQwenServe telemetry validation', () => { }); }); +describe('runQwenServe unsupported deployment profiles', () => { + it.each([ + { profile: 'hosted-harness' as const }, + { experimentalManagedAgents: true }, + { experimentalManagedRuntimeWorker: true }, + { experimentalManagedRuntimeAutoLocal: true }, + { experimentalManagedRuntimeUrl: 'http://127.0.0.1:8080' }, + { experimentalManagedRuntimeToken: 'test-token' }, + { managedRuntimeBrokerUrl: 'http://127.0.0.1:8080' }, + { managedRuntimeBrokerToken: 'test-token' }, + ])('rejects before listening: %j', async (option) => { + const listen = vi + .spyOn(net.Server.prototype, 'listen') + .mockImplementation(() => { + throw new Error('Unexpected listener for an unavailable mode.'); + }); + try { + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: isolatedTestRuntimeDir, + ...option, + }), + ).rejects.toThrow(/not (available|implemented)/); + expect(listen).not.toHaveBeenCalled(); + } finally { + listen.mockRestore(); + } + }); +}); + /** * Boot validation for the embedded `runQwenServe` API: a non-finite * `permissionResponseTimeoutMs` (e.g. config- or NaN-derived) must fail diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index dfbae6b4316..087e2fca853 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -465,8 +465,6 @@ export interface CapabilitiesEnvelope { qwenCodeVersion?: string; /** Process-wide live-state polling interval in milliseconds; older daemons omit it. */ sessionLiveStatePollIntervalMs?: number; - /** Private Hosted Harness handshake. Present only in that deployment profile. */ - hostedHarness?: HostedHarnessCapabilities; mode: ServeMode; features: string[]; /** diff --git a/packages/core/src/tools/managed-tool-runtime.test.ts b/packages/core/src/tools/managed-tool-runtime.test.ts index d33c5d64951..86e7d01c144 100644 --- a/packages/core/src/tools/managed-tool-runtime.test.ts +++ b/packages/core/src/tools/managed-tool-runtime.test.ts @@ -353,6 +353,36 @@ describe('ManagedToolRuntime', () => { } }); + it('excludes Shell and both Grep implementations even when registered', async () => { + const { ReadFileTool } = await import('./read-file.js'); + const { ShellTool } = await import('./shell.js'); + const { GrepTool } = await import('./grep.js'); + const { RipGrepTool } = await import('./ripGrep.js'); + config.isLsToolEnabled = () => false; + config.isTruncateToolOutputThresholdExplicit = () => false; + for (const Grep of [GrepTool, RipGrepTool]) { + const admitted = [ + new ReadFileTool(config), + new ShellTool(config), + new Grep(config), + ]; + config.getToolRegistry = () => + ({ + getTool: (name: string) => + admitted.find((candidate) => candidate.name === name), + ensureTool: vi.fn(async () => undefined), + }) as unknown as ReturnType; + const builtin = await createBuiltinManagedToolRuntime(config); + try { + expect(builtin.manifest().tools.map(({ name }) => name)).toEqual([ + ReadFileTool.Name, + ]); + } finally { + await builtin.dispose(); + } + } + }); + it('prepares once without execution and checkpoints at the explicit turn boundary', async () => { await runtime.beginTurn(identity); const prepared = await runtime.prepare(identity, tool.name, input); @@ -487,6 +517,42 @@ describe('ManagedToolRuntime', () => { expect(events).toEqual(['snapshot', 'preflight', 'post']); }); + it('reclaims settled invocation capacity when the next turn begins', async () => { + await runtime.beginTurn(identity); + for (let index = 0; index < 1024; index++) { + const ref = reference( + await runtime.prepare( + { ...identity, callId: `call-${index}` }, + tool.name, + input, + ), + ); + await runtime.preflight(ref); + await runtime.execute(ref); + } + const prior = { ...identity, callId: 'call-0' }; + const old = reference(await runtime.prepare(prior, tool.name, input)); + expect(runtime.status(old).state).toBe('settled'); + await runtime.execute(old); + expect(tool.invocations).toHaveLength(1024); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + const next = { ...identity, promptId: 'prompt-2' }; + await runtime.beginTurn(next); + expect(() => runtime.status(old)).toThrow('identity does not match'); + expect(() => runtime.execute(old)).toThrow('identity does not match'); + await expect(runtime.prepare(prior, tool.name, input)).rejects.toThrow( + 'turn has not started', + ); + expect(() => runtime.beginTurn(identity)).toThrow('previous tool turn'); + const ref = reference(await runtime.prepare(next, tool.name, input)); + await runtime.preflight(ref); + expect(await runtime.execute(ref)).toMatchObject({ + executionStatus: 'success', + result: rawResult, + }); + expect(tool.invocations).toHaveLength(1025); + }); + it('runs the preflight hook once across the second confirmation bounce', async () => { hooks.pre.mockResolvedValue({ shouldProceed: false, blockType: 'ask' }); const ref = await prepare(); diff --git a/packages/core/src/tools/managed-tool-runtime.ts b/packages/core/src/tools/managed-tool-runtime.ts index 73611a85dd8..88fc00a04c6 100644 --- a/packages/core/src/tools/managed-tool-runtime.ts +++ b/packages/core/src/tools/managed-tool-runtime.ts @@ -262,6 +262,8 @@ export class ManagedToolRuntime { if (this.startedPrompts.has(identity.promptId)) { throw new Error('Managed Runtime cannot reopen a previous tool turn.'); } + this.entries.clear(); + this.calls.clear(); identity = structuredClone(identity); this.snapshotPending = true; this.startedPrompts.add(identity.promptId); diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java index dc9ee991deb..96223f57b2a 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java @@ -12,6 +12,7 @@ import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -45,8 +46,9 @@ public final class LocalProcessRuntimeProvisioner thread.setDaemon(true); return thread; }); - private final ConcurrentMap owned = + private final ConcurrentMap, OwnedProcess> owned = new ConcurrentHashMap<>(); + private final Set> issued = ConcurrentHashMap.newKeySet(); public LocalProcessRuntimeProvisioner(List command, Path workingDirectory, HttpRuntimeTransport transport) { @@ -127,12 +129,12 @@ public CompletionStage release(RuntimeProvisionRequest request, @Override public boolean isUsable(RuntimeLease lease) { - OwnedProcess process = owned.get(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.get(ownershipKey(lease)); return process != null && process.process.isAlive(); } void stop(RuntimeLease lease) { - OwnedProcess process = owned.remove(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.remove(ownershipKey(lease)); if (process != null) { process.process.destroy(); } @@ -212,7 +214,16 @@ private RuntimeLease start(RuntimeProvisionRequest request, RuntimeLease lease = new RuntimeLease(runtimeInstanceId, endpoint, token, leaseId, epoch); attest(request, ownedProcess.seed, lease); - owned.put(runtimeInstanceId, ownedProcess); + List key = ownershipKey(lease); + // A dead worker's port can be reused, but its old lease may still + // arrive for release. Never issue that identity again. + if (!issued.add(key)) { + throw new RuntimeBrokerException(409, + "runtime_broker_resource_conflict", + "Managed Runtime lease identity was already issued.", + false); + } + owned.put(key, ownedProcess); adopted = true; return lease; } catch (IOException exception) { @@ -234,7 +245,7 @@ private RuntimeObservation observe(RuntimeProvisionRequest request, if (lastLease == null || seed == null) { return RuntimeObservation.unknown(handle); } - OwnedProcess process = owned.get(lastLease.getRuntimeInstanceId()); + OwnedProcess process = owned.get(ownershipKey(lastLease)); if (process == null) { return RuntimeObservation.unknown(handle); } @@ -273,13 +284,20 @@ private static RuntimeProvisionSeed newSeed() { private void attestOwned(RuntimeProvisionRequest request, RuntimeLease lease) { - OwnedProcess process = owned.get(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.get(ownershipKey(lease)); if (process == null || !process.process.isAlive()) { throw failed("Managed Runtime process is not alive."); } attest(request, process.seed, lease); } + private static List ownershipKey(RuntimeLease lease) { + // A binding id survives generations; even the same seed can start + // distinct workers. The attested endpoint distinguishes those attempts. + return List.of(lease.getRuntimeInstanceId(), lease.getEndpoint(), + lease.getLeaseId(), lease.getEpoch(), lease.getToken()); + } + private void attest(RuntimeProvisionRequest request, RuntimeProvisionSeed seed, RuntimeLease lease) { try { diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java index d93d5c65e51..d0dc31af20a 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java @@ -65,7 +65,7 @@ default CompletionStage confirm(RuntimeProvisionRequest request, /** * Tears down the resource behind a lease the caller has decided to - * discard, keyed by the lease's runtime instance so a fenced loser can + * discard, keyed by the complete lease identity so a fenced loser can * never kill the winning resource for the same request. The default * has nothing to release. */ diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java index 11b7161e15d..20c9c5ccdf7 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -119,6 +121,137 @@ provisioner, new AcceptingTransport(), bindings, } } + @Test + void releasingAnOlderGenerationPreservesTheWinningWorker() + throws Exception { + assertReleasePreservesOtherAttempt(false); + } + + @Test + void releasingARetriedSeedPreservesTheWinningWorker() + throws Exception { + assertReleasePreservesOtherAttempt(true); + } + + private static void assertReleasePreservesOtherAttempt(boolean sameSeed) + throws Exception { + requireNode(); + Path script = Path.of("src/test/resources/fake-attestation-worker.mjs") + .toAbsolutePath(); + assumeTrue(Files.isRegularFile(script)); + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", + "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, LocalProcessRuntimeProvisioner.KIND); + RuntimeProvisionSeed firstSeed = RuntimeProvisionSeed.create( + "binding-1", 1); + RuntimeProvisionSeed nextSeed = sameSeed ? firstSeed + : RuntimeProvisionSeed.create("binding-1", 2); + Set before = childPids(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner( + List.of("node", script.toString()), + Path.of("").toAbsolutePath(), + new HttpRuntimeTransport())) { + RuntimeLease first = provisioner.provision(request, firstSeed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + RuntimeLease winner = provisioner.provision(request, nextSeed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + provisioner.confirm(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertTrue(provisioner.isUsable(winner), + "releasing a losing attempt must preserve the winner"); + provisioner.confirm(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + provisioner.release(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertNoNewChildren(before); + } finally { + ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .forEach(ProcessHandle::destroyForcibly); + assertNoNewChildren(before); + } + } + + @Test + void rejectsReusingADeadWorkersLeaseIdentity() throws Exception { + assertRejectsReusedWorkerIdentity(false); + } + + @Test + void rejectsReusingAReleasedWorkersLeaseIdentity() throws Exception { + assertRejectsReusedWorkerIdentity(true); + } + + private static void assertRejectsReusedWorkerIdentity(boolean release) + throws Exception { + requireNode(); + Path script = Path.of("src/test/resources/fake-attestation-worker.mjs") + .toAbsolutePath(); + assumeTrue(Files.isRegularFile(script)); + int port; + try (ServerSocket socket = new ServerSocket(0, 0, + InetAddress.getByName("127.0.0.1"))) { + port = socket.getLocalPort(); + } + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", + "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, LocalProcessRuntimeProvisioner.KIND); + RuntimeProvisionSeed seed = RuntimeProvisionSeed.create( + "binding-1", 1); + Set before = childPids(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner( + List.of("node", script.toString(), "--port=" + port), + Path.of("").toAbsolutePath(), + new HttpRuntimeTransport())) { + RuntimeLease first = provisioner.provision(request, seed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + assertEquals(port, first.getEndpoint().getPort()); + ProcessHandle worker = ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .findFirst().orElseThrow(); + if (release) { + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + } else { + worker.destroyForcibly(); + } + worker.onExit().get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + if (release) { + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + } + ExecutionException failure = org.junit.jupiter.api.Assertions + .assertThrows(ExecutionException.class, + () -> provisioner.provision(request, seed) + .toCompletableFuture() + .get(30, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof RuntimeBrokerException); + RuntimeBrokerException error = + (RuntimeBrokerException) failure.getCause(); + assertEquals(409, error.getStatusCode()); + assertEquals("runtime_broker_resource_conflict", error.getCode()); + assertFalse(error.isRetryable()); + assertNoNewChildren(before); + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + assertNoNewChildren(before); + } finally { + ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .forEach(ProcessHandle::destroyForcibly); + assertNoNewChildren(before); + } + } + @Test void rejectsMalformedCapabilityDigestBeforeSpawning() { RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", diff --git a/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs b/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs index a0c983f494f..60acf621ba5 100644 --- a/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs +++ b/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs @@ -18,6 +18,8 @@ const boot = JSON.parse( const args = process.argv.slice(2); const chatty = args.includes('--chatty'); const foreignUrl = args.includes('--foreign-url'); +const portArg = args.find((arg) => arg.startsWith('--port=')); +const port = portArg ? Number(portArg.slice('--port='.length)) : 0; const probeArg = args.find((arg) => arg.startsWith('--probe=')); const probePath = probeArg ? probeArg.slice('--probe='.length) : ''; if (args.includes('--big-ready')) { @@ -77,7 +79,7 @@ const server = createServer((request, response) => { }); }); -server.listen(0, '127.0.0.1', () => { +server.listen(port, '127.0.0.1', () => { const address = server.address(); const host = foreignUrl ? '172.16.1.234' : '127.0.0.1'; process.stdout.write( From 928004d08c79af7a99241b0d377968acb817a290 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 25 Sep 2026 21:51:59 +0800 Subject: [PATCH 6/6] test(core): make exited PID assertion deterministic --- .../core/src/tools/pid-descendants.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tools/pid-descendants.test.ts b/packages/core/src/tools/pid-descendants.test.ts index 748bece442b..dafd19b2b37 100644 --- a/packages/core/src/tools/pid-descendants.test.ts +++ b/packages/core/src/tools/pid-descendants.test.ts @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { listDescendantPids, sigtermPids } from './pid-descendants.js'; describe('pid-descendants', () => { @@ -50,14 +50,17 @@ describe('pid-descendants', () => { }); it('tolerates already-exited pids (ESRCH swallowed)', () => { - // Pick a pid almost certainly not in use. process.kill with - // SIGTERM to a non-existent pid throws ESRCH which sigtermPids - // catches. - const result = sigtermPids([999999, 999998]); - // The function returns the count of "successfully signaled" pids - // (where process.kill didn't throw). For non-existent pids, - // it throws ESRCH which is caught, so the count is 0. - expect(result).toBe(0); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + try { + expect(sigtermPids([999999, 999998])).toBe(0); + expect(kill).toHaveBeenCalledTimes(2); + expect(kill).toHaveBeenNthCalledWith(1, 999999, 'SIGTERM'); + expect(kill).toHaveBeenNthCalledWith(2, 999998, 'SIGTERM'); + } finally { + kill.mockRestore(); + } }); });