fix(managed-agent): address A3 review R1 findings

R1-1: replace the broker-swap guard with close-and-rebuild so a config revision bump that keeps the Hook catalog no longer parks a mount-held apply at recovery_required; revert the delegate overload. R1-2: cap the spawn-time preread at the shell 1 MiB budget with an overflow flag folded into outputCaptureLimitExceeded. R1-3: add a tool_stage domain-refusal load test. R1-4: batch the package-config retention walk (BFS + IN(...) + delta insert) with a delta witness. R1-5: precompile the Action-id Pattern at four sites. R1-6/7/8: anchor the crash/final evidence matrices and the package hook-command contract. R1-9: drop the inert admission-failure acknowledge. R1-10: read back both command-action decisions in the A3 arm. Verified by red/green regressions, CLI typecheck, the affected suites, and packaged arm owner-r46 on real MySQL 8.4/cgroup v2.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
wenshaoandQwen-Coder committed 2026-11-03 10:55:21 +08:00
1 parent 06e97abac6
commit 6d15829e52
15 files changed
+597 -138

No files matched your search

@@ -23851,6 +23851,259 @@ describe('Hosted Harness tool approvals', () => {
).expect(204);
});
it('rebuilds the Hook session on the new owner when a revision bump keeps the catalog while the mount is held', async () => {
vi.spyOn(
HostedWorkspaceBroker.prototype,
'capabilityDigest',
'get',
).mockReturnValue(MANAGED_EXTENSION_DEVELOPER_CAPABILITY_DIGEST);
vi.mocked(HostedWorkspaceBroker.prototype.warm).mockImplementation(
async function (this: HostedWorkspaceBroker) {
this.runtime = {
bindingId: 'original-binding',
generation: '1',
workspaceGeneration: '1',
};
},
);
const catalog: ManagedHookCatalog = {
...hookPin,
hooks: [
{
hookId: 'Notification',
eventName: HookEventName.Notification,
sequential: false,
async: false,
failClosed: true,
onceKey: null,
config: { type: 'command' },
},
],
};
vi.spyOn(HostedWorkspaceBroker.prototype, 'hookControl').mockImplementation(
async (operation) => {
if (operation.kind === 'hook-catalog')
return {
operationId: operation.operationId,
state: 'settled',
catalog,
};
return {
operationId: operation.operationId,
state: 'settled',
result: {
success: true,
outcome: 'success',
duration: 0,
output: { hookSpecificOutput: { additionalContext: 'checked' } },
},
};
},
);
const runtimeStates = new Map<string, string>();
let currentRevision = 0;
vi.spyOn(
HostedWorkspaceBroker.prototype,
'extensionControl',
).mockImplementation(async (operation) => {
const common = {
operationId: operation.operationId,
executionMode: 'developer-process-group',
};
if (operation.kind === 'extension-prepare') {
runtimeStates.set(operation.operationId, 'prepared');
currentRevision = operation.expectedRevision + 1;
return {
...common,
state: 'prepared',
expectedRevision: operation.expectedRevision,
revision: currentRevision,
packages: operation.extensions.map((pin) => ({
pin,
assetRoot: `extension://${pin.extensionId}/`,
contributions: {
skills: [],
commands: [],
agents: [],
context: [],
mcpServers: {},
hooks: {},
settings: [],
},
})),
mcpServers: [],
hookCatalog: hookPin,
};
}
if (operation.kind === 'extension-activate')
runtimeStates.set(operation.operationId, 'active');
return {
...common,
state: runtimeStates.get(operation.operationId),
revision: currentRevision,
};
});
const selection = (expectedRevision: number) => ({
operationId: randomUUID(),
actorKey: 'a'.repeat(64),
expectedRevision,
extensions: [
{
extensionId: 'sample',
packageRevision: 1,
packageDigest: 'b'.repeat(64),
},
],
});
const definition = {
managedSessionStore: store(),
toolProfile: 'hosted-workspace-shell/1',
publicChildAgentsVersion: 1,
agentDefinition: {
agentId: `agent_${'c'.repeat(32)}`,
revision: '1',
digest: 'd'.repeat(64),
},
initialSelection: selection(0),
};
const server = await app(true);
const created = await headers(supertest(server).post('/session'))
.send({ ...definition, sessionId: SESSION_ID, sessionScope: 'thread' })
.expect(200);
const authorize = (request: supertest.Test) =>
headers(request).set('X-Qwen-Client-Id', created.body.clientId);
// The initial selection already booted the extension to revision 1 with
// the Hook catalog bound. Fire a Hook so the session acquires and holds
// its Workspace mount.
await authorize(
supertest(server).post(`/session/${SESSION_ID}/hooks/operations`),
)
.send({
operationId: randomUUID(),
event: 'Notification',
input: { message: 'ready', notification_type: 'test' },
})
.expect(200);
// A revision bump that keeps the Hook catalog pin must close and rebuild
// the Hook session on the new owner — not park the apply at
// recovery_required because the mount is held.
const second = await authorize(
supertest(server).post(`/session/${SESSION_ID}/extensions/management`),
)
.send(selection(1))
.expect(202);
expect(second.body).toMatchObject({
revision: 2,
operation: { state: 'active' },
});
await authorize(
supertest(server).post(`/session/${SESSION_ID}/detach`),
).expect(204);
});
it('refuses the tool_stage recovery domain when extensions are disabled', async () => {
const key = {
tenantId: 'tenant',
workspaceId: 'workspace',
sessionId: SESSION_ID,
};
const journalStore = new LocalJsonlManagedSessionJournalStore({
runtimeBaseDir: state.root,
sessionId: SESSION_ID,
transcriptPath: path.join(state.root, `${SESSION_ID}.jsonl`),
});
const resources = LocalManagedSessionResourceStore.create({
runtimeBaseDir: state.root,
sessionKey: key,
});
const managed = await openManagedSession({
runtimeBaseDir: state.root,
transcriptPath: '',
sessionId: SESSION_ID,
sessionKey: key,
cwd: state.root,
version: 'hosted-harness/1',
workerId: BOOT_ID,
activationLeaseDurationMs: 60_000,
journalStore,
resourceStore: resources,
create: {
definitionRef: await resources.publish(
'managed-definition',
Buffer.from(
JSON.stringify({
engine: 'managed',
sessionId: SESSION_ID,
toolProfile: 'hosted-workspace-shell/1',
}),
),
),
rootSnapshotRef: await resources.publish(
'managed-root',
Buffer.from(JSON.stringify({ cwd: state.root })),
),
createdBy: 'hosted-harness',
},
});
const inputRef = await resources.publish(
'managed-extension-command-input',
Buffer.from(JSON.stringify({ command: 'printf x', timeout: 120000 })),
);
const toolStageId = `command-step-${'a'.repeat(64)}`;
await managed.authority.commitExtensionRecord(
{
operation: 'commandStep.stage',
commandId: randomUUID(),
sessionKey: key,
contentDigest: 'f'.repeat(64),
},
{
domain: 'tool_stage',
record: {
kind: 'tool_stage',
toolStageId,
stepIndex: 0,
toolName: 'shell',
expansionId: `expansion-${'b'.repeat(32)}`,
inputRef,
permissionRef: null,
resultRef: null,
run: {
state: 'admitted',
reason: null,
definition: {
definitionId: 'shell',
definitionRevision: 1,
definitionDigest: 'c'.repeat(64),
},
executionCallId: toolStageId,
effectId: null,
dispatchId: null,
deliveryId: null,
execution: 'intent',
runtime: null,
delivery: null,
},
},
},
{ class: 'trusted_entry' },
);
await managed.close();
// Without extensions the tool_stage domain is unsupported and the load
// must refuse restore verification rather than silently admit it.
const plain = await app(true);
const refused = await headers(
supertest(plain).post(`/session/${SESSION_ID}/load`),
).send({
managedSessionStore: store(),
toolProfile: 'hosted-workspace-shell/1',
});
expect(refused.status).toBe(409);
expect(refused.body.reason).toBe('restore_verification_failed');
await new Promise<void>((resolve) => plain.close(() => resolve()));
});
it.each(['write_file', 'exit_plan_mode'] as const)(
'rechecks live approval after a cold restore while preserving %s approval semantics',
async (toolName) => {
@@ -9087,8 +9087,16 @@ export function registerHostedHarnessSessionRoutes(
session.hooks &&
session.hooks.broker.runtimeSessionId !==
extensionBroker.runtimeSessionId
)
session.hooks.delegateBrokerToMcp(extensionBroker);
) {
// The owner moved (configRevision changed) while the Hook catalog
// pin stayed the same, so drain kept this session. Close it to
// release its mount claim and let the rebuild below bind the new
// owner. Swapping the broker under a held mount would orphan the
// claim, and refusing the swap would park a previously-valid apply
// at recovery_required.
await session.hooks.close();
session.hooks = undefined;
}
if (bundle.hookCatalog && !session.hooks) {
session.hooks = new HostedHookSession(
options,
@@ -920,46 +920,6 @@ it('keeps its mount while a Hook outcome is unknown (#13800)', async () => {
expect(linked.mountHandback()).toBeUndefined();
});
it('refuses to swap its broker while it still holds the mount', async () => {
await hooks.ensureReady();
await hooks.acquire();
// The mount is held with no outstanding Hook work, so the pre-existing
// mountWorkUnproven() guard does NOT fire — only the mount-held clause can
// refuse the swap. This pins the new clause itself rather than letting the
// older guard satisfy the assertion.
expect(hooks.mountHeld).toBe(true);
expect(hooks.mountWorkUnproven()).toBe(false);
const owned = hooks.broker;
const extensionBroker = new HostedWorkspaceBroker(
options,
session.authority.sessionHeader.sessionKey,
'extensions-components-other',
);
expect(() => hooks.delegateBrokerToMcp(extensionBroker)).toThrow(
HostedHookRecoveryRequiredError,
);
expect(hooks.broker).toBe(owned);
expect(hooks.mountHeld).toBe(true);
});
it('swaps to a free extension broker and keeps ownership for close', async () => {
expect(hooks.mountHeld).toBe(false);
const extensionBroker = new HostedWorkspaceBroker(
options,
session.authority.sessionHeader.sessionKey,
'extensions-components-other',
);
hooks.delegateBrokerToMcp(extensionBroker);
expect(hooks.broker).toBe(extensionBroker);
await hooks.fire(HookEventName.PreToolUse, 'call-1', {}, signal());
expect(hooks.mountHeld).toBe(true);
const release = vi.spyOn(HostedWorkspaceBroker.prototype, 'release');
release.mockClear();
await hooks.close();
// Ownership survived the swap: close releases the new owner, not the old.
expect(release.mock.instances).toContain(extensionBroker);
});
it('still disowns a shared broker while holding the mount', async () => {
await hooks.fire(HookEventName.PreToolUse, 'call-1', {}, signal());
expect(hooks.mountHeld).toBe(true);
+3 -11
View File
@@ -348,7 +348,7 @@ export function parseHostedHookPin(value: unknown): ManagedHookCatalogPin {
}
export class HostedHookSession {
broker: HostedWorkspaceBroker;
readonly broker: HostedWorkspaceBroker;
private ownsBroker: boolean;
private acquired = false;
private acquiring?: Promise<void>;
@@ -639,17 +639,9 @@ export class HostedHookSession {
return this.acquired || this.broker.acquired;
}
delegateBrokerToMcp(broker?: HostedWorkspaceBroker): void {
delegateBrokerToMcp(): void {
if (this.mountWorkUnproven()) throw new HostedHookRecoveryRequiredError();
// A different broker carries a different mount claim. Swapping while this
// owner still holds the mount would orphan that claim and leave `acquired`
// pointing at the old broker, so the handback gate refuses until a fresh
// load binds the new owner. The no-argument handover keeps the same broker
// (MCP takes over its release), so it stays valid while the mount is held.
if (broker !== undefined && broker !== this.broker && this.mountHeld)
throw new HostedHookRecoveryRequiredError();
this.broker = broker ?? this.broker;
this.ownsBroker = broker === undefined ? false : this.ownsBroker;
this.ownsBroker = false;
}
/**
@@ -231,7 +231,9 @@ export class ManagedExtensionCommandRuntime {
},
{ mode: 0o600, forceMode: true, noFollow: true },
);
this.dependencies.acknowledge(reference);
// No acknowledge here: reaching this branch means prepareInput threw
// before execute ran (started is false), so no native journal entry
// exists for this reference and there is nothing to release.
}
})
.finally(() => this.running.delete(key));
@@ -12,17 +12,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ManagedRuntimeLedger } from './managed-runtime-ledger.js';
import { ManagedExtensionProcessOwners } from './managed-extension-process.js';
// The Linux preread regression below mocks only the cgroup unit so a real
// The Linux preread regressions below mock only the cgroup unit so a real
// child is spawned through the real start() attach path without a delegated
// cgroup root. Developer-mode tests never touch this module.
// cgroup root. Developer-mode tests never touch this module. The launch script
// is hoisted-mutable so the overflow case can write past the capture budget
// before acknowledging membership.
const cgroupMock = vi.hoisted(() => ({
script:
"require('fs').writeSync(3,'joined\\n');process.stdout.write('stdout-sentinel');process.stderr.write('stderr-sentinel');setInterval(()=>{},1000)",
}));
vi.mock('@qwen-code/qwen-code-core/hooks/hook-command-cgroup.js', () => {
const unit = {
launch: (executable: string, _args: string[], env: NodeJS.ProcessEnv) => ({
executable,
args: [
'-e',
"require('fs').writeSync(3,'joined\\n');process.stdout.write('stdout-sentinel');process.stderr.write('stderr-sentinel');setInterval(()=>{},1000)",
],
args: ['-e', cgroupMock.script],
env,
}),
terminate: async () => {},
@@ -406,4 +409,47 @@ describe('ManagedExtensionProcessOwners', () => {
}
},
);
it.skipIf(process.platform !== 'linux')(
'caps the spawn-time preread at the shell budget and reports overflow',
async () => {
const directory = await fs.mkdtemp(
path.join(os.tmpdir(), 'extension-preread-cap-'),
);
directories.push(directory);
const owners = new ManagedExtensionProcessOwners('linux-cgroup', {
cgroupRoot: directory,
directory: path.join(directory, 'owners'),
});
// Write past the 1 MiB budget BEFORE acknowledging membership, so the
// overflow happens inside the pre-handoff preread window. An unbounded
// preread would retain all 2 MiB and never flag overflow.
const defaultScript = cgroupMock.script;
cgroupMock.script =
'process.stdout.write("x".repeat(2 * 1024 * 1024));require(\'fs\').writeSync(3,"joined\\n");setInterval(()=>{},1000)';
let spawned: ChildProcess | undefined;
try {
const started = await owners.start({
sessionId: 'root',
configRevision: 1,
executable: process.execPath,
args: ['-e', 'ignored-by-mock-launch'],
env: { PATH: '/usr/bin:/bin' },
cwd: directory,
prereadOutput: true,
});
spawned = started.child;
started.detachPreread!();
const retainedBytes =
started.preread!.stdout.reduce((n, b) => n + b.length, 0) +
started.preread!.stderr.reduce((n, b) => n + b.length, 0);
expect(retainedBytes).toBeLessThanOrEqual(1024 * 1024);
expect(started.preread!.overflow).toBe(true);
} finally {
cgroupMock.script = defaultScript;
spawned?.kill('SIGKILL');
await owners.stopAll();
}
},
);
});
@@ -127,7 +127,7 @@ export class ManagedExtensionProcessOwners {
}): Promise<{
ownerId: string;
child: ChildProcess;
preread?: { stdout: Buffer[]; stderr: Buffer[] };
preread?: { stdout: Buffer[]; stderr: Buffer[]; overflow: boolean };
detachPreread?: () => void;
}> {
await this.recovering;
@@ -170,12 +170,33 @@ export class ManagedExtensionProcessOwners {
// Attaching here, synchronously after spawn, leaves no window for bytes to
// be drained unread; the caller detaches and replays these buffers before
// wiring its live capture.
let preread: { stdout: Buffer[]; stderr: Buffer[] } | undefined;
let preread:
| { stdout: Buffer[]; stderr: Buffer[]; overflow: boolean }
| undefined;
let detachPreread: (() => void) | undefined;
if (input.prereadOutput) {
const buffers = { stdout: [] as Buffer[], stderr: [] as Buffer[] };
const onStdout = (chunk: Buffer) => buffers.stdout.push(chunk);
const onStderr = (chunk: Buffer) => buffers.stderr.push(chunk);
const buffers = {
stdout: [] as Buffer[],
stderr: [] as Buffer[],
overflow: false,
};
// Bound the preread against the shell's own 1 MiB capture budget. Keep
// draining once the budget is spent (the listener stays attached) so the
// child never blocks on a full pipe, but flag the overflow so the shell
// can report outputCaptureLimitExceeded truthfully instead of silently
// dropping bytes.
let retained = 0;
const capture = (id: 'stdout' | 'stderr') => (chunk: Buffer) => {
const remaining = Math.max(0, 1024 * 1024 - retained);
if (chunk.length > remaining) buffers.overflow = true;
if (remaining) {
const prefix = chunk.subarray(0, remaining);
buffers[id].push(prefix);
retained += prefix.length;
}
};
const onStdout = capture('stdout');
const onStderr = capture('stderr');
child.stdout?.on('data', onStdout);
child.stderr?.on('data', onStderr);
preread = buffers;
@@ -77,6 +77,9 @@ export async function runManagedExtensionShell(
if (preread) {
for (const chunk of preread.stdout) consume('stdout', chunk);
for (const chunk of preread.stderr) consume('stderr', chunk);
// The preread capped itself at the same 1 MiB budget; bytes beyond it were
// drained but not retained, so report the truncation truthfully.
if (preread.overflow) exceeded = true;
}
for (const id of ['stdout', 'stderr'] as const)
child[id]?.on('data', (chunk: Buffer) => consume(id, chunk));
@@ -11,9 +11,11 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
public interface ManagedActionStore {
String ACTION_ID_PATTERN = "(?:tool_approval_[0-9a-f]{32}|command_approval_[0-9a-f]{48})";
Pattern ACTION_ID = Pattern.compile(ACTION_ID_PATTERN);
public record Action(
String id,
@@ -126,7 +126,7 @@ public class ManagedActionService {
String[] parts = decoded.substring(scope.length()).split(":", -1);
if (parts.length != 3 || !parts[0].matches("0|[1-9][0-9]{0,18}")
|| !parts[1].matches("[0-9a-f-]{36}")
|| !parts[2].matches(ManagedActionStore.ACTION_ID_PATTERN)) {
|| !ManagedActionStore.ACTION_ID.matcher(parts[2]).matches()) {
throw new IllegalArgumentException();
}
before = Long.parseLong(parts[0]);
@@ -278,10 +278,11 @@ public class ManagedActionService {
}
String decoded =
new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
if (!decoded.matches("(0|[1-9][0-9]{0,18}):" + ManagedActionStore.ACTION_ID_PATTERN)) {
String[] parts = decoded.split(":", 2);
if (parts.length != 2 || !parts[0].matches("0|[1-9][0-9]{0,18}")
|| !ManagedActionStore.ACTION_ID.matcher(parts[1]).matches()) {
throw new IllegalArgumentException();
}
String[] parts = decoded.split(":", 2);
before = Long.parseLong(parts[0]);
beforeId = parts[1];
} catch (IllegalArgumentException error) {
@@ -191,7 +191,7 @@ public class ManagedActionStore
String id = payload.path("requestId").asText();
String state = payload.path("state").asText();
require(
id.matches(ManagedActionStore.ACTION_ID_PATTERN)
ManagedActionStore.ACTION_ID.matcher(id).matches()
&& "permission".equals(payload.path("kind").asText())
&& "tool_call".equals(payload.path("source").asText())
&& List.of("requested", "decided", "expired", "cancelled")
@@ -389,7 +389,7 @@ public class ManagedActionStore
}
public Optional<Action> find(String tenantId, String sessionId, String id) {
if (!id.matches(ManagedActionStore.ACTION_ID_PATTERN)) {
if (!ManagedActionStore.ACTION_ID.matcher(id).matches()) {
return Optional.empty();
}
return jdbc
@@ -45,6 +45,7 @@ import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -718,75 +719,107 @@ public class ManagedExtensionRecordStore
+ " WHERE session_scope_key=? AND record_key=? AND revision=?",
revision, resource.resourceId(), settledAt, scope, key, revision - 1);
}
jdbc.update("DELETE FROM ma_resource_ref WHERE session_scope_key=? AND owner_kind='SESSION_RECORD' AND owner_key=?", scope, key);
jdbc.update("INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key,"
+ " tenant_id, workspace_id, session_id, resource_id, gmt_create) VALUES (?, 'SESSION_RECORD', ?, ?, ?, ?, ?, ?, ?, "
+ ManagedSchemaTime.SQL_NOW + ")", scope, key, revision,
ManagedSchemaKeys.key(scope, resource.resourceId()), tenantId, workspaceId, sessionId, resource.resourceId());
Set<String> retained = new HashSet<>(jdbc.queryForList(
"SELECT resource_id FROM ma_resource_ref WHERE session_scope_key=? AND owner_kind='SESSION_RECORD' AND owner_key=?",
String.class, scope, key));
if (retained.add(resource.resourceId())) {
referenceProjectionResource(scope, key, revision, tenantId, workspaceId,
sessionId, resource.resourceId());
}
if (ManagedConfigInstall.isPackageConfig(settings)) {
// A sealed writer cannot rely on its old journal to retain the
// recovery resources named by the active package configuration.
retainPackageConfiguration(scope, key, revision, tenantId, workspaceId,
sessionId, settings, resources, new HashSet<>(Set.of(resource.resourceId())));
sessionId, settings, resources, retained);
}
}
private static final Set<String> RETAINED_REFERENCE_KINDS = Set.of(
"managed-config_install", "managed-extension-config-bundle",
"managed-extension-contributions", "managed-extension-root",
"managed-extension-stage-ack", "managed-extension-enable-ack");
// Resources are immutable per (scope, resourceId) and every settings revision links its
// predecessor, so a resource retained by an earlier commit already pins its whole reference
// closure: the walk only has to name the resources this commit adds, probing existence once
// per level instead of per resource and leaving the rows it already wrote untouched.
private void retainPackageConfiguration(String scope, String key, long revision,
String tenantId, String workspaceId, String sessionId, JsonNode settings,
Function<String, StoredResource> resources, Set<String> retained) {
for (String field : List.of("previousBundleRef", "restoresBundleRef", "bundleRef",
"rootSnapshotRef", "previousRecordRef")) {
retainPackageReference(scope, key, revision, tenantId, workspaceId, sessionId,
settings.get(field), resources, retained);
}
for (String field : List.of("stageAckRefs", "enableAckRefs")) {
for (JsonNode ref : settings.path(field)) {
retainPackageReference(scope, key, revision, tenantId, workspaceId, sessionId,
ref, resources, retained);
List<JsonNode> level = configurationReferences(settings);
while (!level.isEmpty()) {
Map<String, JsonNode> candidates = new LinkedHashMap<>();
for (JsonNode ref : level) {
if (ref != null && !ref.isNull()
&& retained.add(ref.path("resourceId").asText())) {
candidates.put(ref.path("resourceId").asText(), ref);
}
}
level = new ArrayList<>();
if (candidates.isEmpty()) {
continue;
}
Set<String> existing = existingResources(scope, candidates.keySet());
for (Map.Entry<String, JsonNode> candidate : candidates.entrySet()) {
String resourceId = candidate.getKey();
if (!existing.contains(resourceId)) {
continue;
}
StoredResource resource = resources.apply(resourceId);
requireReference(resource, candidate.getValue());
referenceProjectionResource(scope, key, revision, tenantId, workspaceId,
sessionId, resourceId);
if (RETAINED_REFERENCE_KINDS.contains(resource.kind())) {
level.addAll(packageReferences(resource.kind(), readBody(resource)));
}
}
}
}
private void retainPackageReference(String scope, String key, long revision,
String tenantId, String workspaceId, String sessionId, JsonNode ref,
Function<String, StoredResource> resources, Set<String> retained) {
if (ref == null || ref.isNull() || !retained.add(ref.path("resourceId").asText())) {
return;
private static List<JsonNode> configurationReferences(JsonNode settings) {
List<JsonNode> refs = new ArrayList<>();
for (String field : List.of("previousBundleRef", "restoresBundleRef", "bundleRef",
"rootSnapshotRef", "previousRecordRef")) {
refs.add(settings.get(field));
}
String resourceId = ref.path("resourceId").asText();
if (jdbc.queryForList("SELECT 1 FROM ma_resource WHERE session_scope_key = ? AND resource_id = ?",
scope, resourceId).isEmpty()) {
return;
}
StoredResource resource = resources.apply(resourceId);
requireReference(resource, ref);
referenceProjectionResource(scope, key, revision, tenantId, workspaceId, sessionId,
resource.resourceId());
if (!Set.of("managed-config_install", "managed-extension-config-bundle",
"managed-extension-contributions", "managed-extension-root",
"managed-extension-stage-ack", "managed-extension-enable-ack").contains(resource.kind())) {
return;
}
JsonNode body = readBody(resource);
if ("managed-config_install".equals(resource.kind())) {
retainPackageConfiguration(scope, key, revision, tenantId, workspaceId,
sessionId, body, resources, retained);
} else if ("managed-extension-config-bundle".equals(resource.kind())) {
for (JsonNode entry : body.path("packages")) {
retainPackageReference(scope, key, revision, tenantId, workspaceId,
sessionId, entry.get("contributionsRef"), resources, retained);
for (String field : List.of("stageAckRefs", "enableAckRefs")) {
for (JsonNode ref : settings.path(field)) {
refs.add(ref);
}
} else if ("managed-extension-contributions".equals(resource.kind())) {
}
return refs;
}
private static List<JsonNode> packageReferences(String kind, JsonNode body) {
if ("managed-config_install".equals(kind)) {
return configurationReferences(body);
}
List<JsonNode> refs = new ArrayList<>();
if ("managed-extension-config-bundle".equals(kind)) {
for (JsonNode entry : body.path("packages")) {
refs.add(entry.get("contributionsRef"));
}
} else if ("managed-extension-contributions".equals(kind)) {
for (JsonNode part : body.path("parts")) {
retainPackageReference(scope, key, revision, tenantId, workspaceId,
sessionId, part, resources, retained);
refs.add(part);
}
} else {
retainPackageReference(scope, key, revision, tenantId, workspaceId, sessionId,
body.get("rootSnapshotRef"), resources, retained);
retainPackageReference(scope, key, revision, tenantId, workspaceId, sessionId,
body.get("bundleRef"), resources, retained);
refs.add(body.get("rootSnapshotRef"));
refs.add(body.get("bundleRef"));
}
return refs;
}
private Set<String> existingResources(String scope, Set<String> resourceIds) {
String placeholders = String.join(",",
java.util.Collections.nCopies(resourceIds.size(), "?"));
List<Object> args = new ArrayList<>();
args.add(scope);
args.addAll(resourceIds);
return new HashSet<>(jdbc.queryForList(
"SELECT resource_id FROM ma_resource WHERE session_scope_key = ?"
+ " AND resource_id IN (" + placeholders + ")",
String.class, args.toArray()));
}
private static void requireConfigurationBinding(JsonNode settings, String[] lines) {
@@ -167,6 +167,62 @@ class ManagedExtensionConfigurationAdmissionTest {
ExtensionRecordJournal.resourceId(ExtensionRecordJournal.bytes(committed)));
}
@Test
void packageConfigProjectionGrowsByDeltaWithoutRewritingRetainedRows() throws Exception {
String workspace = registerWorkspace();
String id = UUID.randomUUID().toString();
var journal = publicJournal(id, workspace);
byte[] partBytes = "{\"commands\":[]}".getBytes(StandardCharsets.UTF_8);
var part = new CommitResource("part", "managed-extension-contributions-part", 1,
partBytes.length, DigestUtils.sha256Hex(partBytes), Base64.getEncoder().encodeToString(partBytes));
ObjectNode manifest = json.createObjectNode().put("kind", "managed-extension-contributions")
.put("formatVersion", 1).put("byteLength", partBytes.length).put("digest", part.digest());
manifest.putArray("parts").addObject().put("resourceId", part.resourceId()).put("kind", part.kind())
.put("schemaVersion", 1).put("byteLength", part.byteLength()).put("digest", part.digest());
byte[] manifestBytes = ExtensionRecordJournal.bytes(manifest);
var contributions = new CommitResource("contributions", "managed-extension-contributions", 1,
manifestBytes.length, DigestUtils.sha256Hex(manifestBytes), Base64.getEncoder().encodeToString(manifestBytes));
ObjectNode bundle = json.createObjectNode();
bundle.putArray("packages").addObject().putObject("contributionsRef")
.put("resourceId", contributions.resourceId()).put("kind", contributions.kind())
.put("schemaVersion", 1).put("byteLength", contributions.byteLength()).put("digest", contributions.digest());
byte[] bundleBytes = ExtensionRecordJournal.bytes(bundle);
var bundleResource = new CommitResource("bundle", "managed-extension-config-bundle", 1,
bundleBytes.length, DigestUtils.sha256Hex(bundleBytes), Base64.getEncoder().encodeToString(bundleBytes));
ObjectNode bundleRef = json.createObjectNode().put("resourceId", bundleResource.resourceId())
.put("kind", bundleResource.kind()).put("schemaVersion", 1)
.put("byteLength", bundleResource.byteLength()).put("digest", bundleResource.digest());
ObjectNode inheritedRef = json.createObjectNode().put("resourceId", "inherited-baseline")
.put("kind", "managed-extension-config-bundle").put("schemaVersion", 1)
.put("byteLength", 2).put("digest", DigestUtils.sha256Hex("{}"));
List<CommitResource> resources = new ArrayList<>(resources());
resources.removeIf(resource -> resource.resourceId().equals("bundle"));
resources.addAll(List.of(bundleResource, contributions, part));
ObjectNode preparing = phase("nullable_preparing", 1, null).set("previousBundleRef", inheritedRef);
commit(journal, id, workspace, preparing, false, false, resources);
byte[] session = id.getBytes(StandardCharsets.UTF_8);
long sentinel = 12345L;
int firstCount = jdbc.update("UPDATE ma_resource_ref SET gmt_create = ?"
+ " WHERE owner_kind='SESSION_RECORD' AND session_id = ?", sentinel, session);
assertThat(firstCount).isPositive();
List<String> firstIds = jdbc.queryForList("SELECT resource_id FROM ma_resource_ref"
+ " WHERE owner_kind='SESSION_RECORD' AND session_id = ?", String.class, session);
ObjectNode prepared = (ObjectNode) phase("prepared", 2, preparing).set("bundleRef", bundleRef);
prepared.set("previousBundleRef", inheritedRef);
commit(journal, id, workspace, prepared, false, false, resources);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_resource_ref"
+ " WHERE owner_kind='SESSION_RECORD' AND session_id = ? AND gmt_create = ?",
Integer.class, session, sentinel)).isEqualTo(firstCount);
List<String> secondIds = jdbc.queryForList("SELECT resource_id FROM ma_resource_ref"
+ " WHERE owner_kind='SESSION_RECORD' AND session_id = ?", String.class, session);
assertThat(secondIds).containsAll(firstIds)
.contains(bundleResource.resourceId(), contributions.resourceId(), part.resourceId());
assertThat(secondIds.size()).isGreaterThan(firstIds.size());
}
private void commit(ExtensionRecordJournal journal, String id, String workspace, ObjectNode body,
boolean binding, boolean mismatch) {
commit(journal, id, workspace, body, binding, mismatch, resources());
+30
View File
@@ -4895,6 +4895,36 @@ try {
throw new Error(
'A3 recovery changed an Action inputRef or toolStageId',
);
// State alone proves a decision was committed, not which option won.
// Read each command Allow's committed decision back and hold it to the
// revisions the browser answered; the generic else-branch read-back is
// arm-agnostic and must not be skipped by the command-rewrite split.
for (const [actionId, options] of [
[originalApprovalId!, originalApprovalOptions!],
[siblingApprovalId!, siblingApprovalOptions!],
] as const) {
const decisionHex = runMysql(
mysqlPort,
`SELECT HEX(r.inline_bytes) FROM qwen_managed_agent.ma_resource_ref rr JOIN qwen_managed_agent.ma_resource r ON r.resource_key = rr.resource_key WHERE rr.owner_kind='SESSION_RECORD' AND rr.owner_key = (SELECT record_key FROM qwen_managed_agent.ma_session_record WHERE ${sessionFilter} AND record_kind='ACTION' AND action_id=${sqlString(actionId)}) AND r.kind='managed-action-decision'`,
);
if (!decisionHex || decisionHex === 'NULL')
throw new Error(`A3 command decision is unavailable: ${actionId}`);
const decision = JSON.parse(
Buffer.from(decisionHex, 'hex').toString('utf8'),
) as {
optionId: string;
inputRevision: number;
policyRevision: string;
};
if (
decision.optionId !== 'allow' ||
decision.inputRevision !== options.inputRevision ||
decision.policyRevision !== options.policyRevision
)
throw new Error(
`A3 command decision differs from the chosen option: ${JSON.stringify(decision)}`,
);
}
saveEvidence('command-rewrite-result.json', evidence);
approvalResultJson = JSON.stringify(
{
+71 -19
View File
@@ -356,6 +356,12 @@ describe('managed-agent-server e2e runner', () => {
for (const event of Object.values(manifest.hooks)) {
expect(event[0].matcher).toBe('run_shell_command');
expect(event[0].hooks[0].type).toBe('command');
expect(event[0].hooks[0].command).toContain(
'"${extensionPath}/hook.cjs"',
);
expect(event[0].hooks[0].command).toContain(
JSON.stringify(join(temporary, 'hooks.jsonl')),
);
}
const command = readFileSync(
join(temporary, 'source', 'commands', 'two-step.md'),
@@ -613,7 +619,7 @@ describe('managed-agent-server e2e runner', () => {
actions,
checkpoint: {
phase: 'await_action',
requestId: 'command_approval_2',
requestId: actions[1].id,
},
stages,
durableHooks,
@@ -696,6 +702,7 @@ describe('managed-agent-server e2e runner', () => {
(hook) => hook.eventName === 'PostToolUse',
).resultRef = null;
},
/A3 durable PostToolUse Hook evidence differs/,
],
[
'durable Hook occurrence used as the command stage identity',
@@ -703,6 +710,7 @@ describe('managed-agent-server e2e runner', () => {
evidence.durableHooks[0].toolUseId =
evidence.durableHooks[0].occurrenceId;
},
/A3 durable PreToolUse Hook evidence differs/,
],
[
'only a plan marker remains for the command step',
@@ -715,18 +723,43 @@ describe('managed-agent-server e2e runner', () => {
hook.hookId = '__plan__';
}
},
/A3 durable PreToolUse Hook evidence differs/,
],
[
'step two ran before the crash',
(evidence) => {
evidence.effects[1] = 'REWRITTEN_SECOND';
},
/A3 crash physical Hook\/effect boundary differs/,
],
[
'the two steps shared an Action',
(evidence) => {
evidence.actions[1].id = evidence.actions[0].id;
evidence.checkpoint.requestId = evidence.actions[1].id;
},
/A3 crash did not land/,
],
[
'the pending Action was already decided',
(evidence) => {
evidence.actions[1].state = 'decided';
},
/A3 crash did not land/,
],
[
'the checkpoint pointed at the decided Action',
(evidence) => {
evidence.checkpoint.requestId = evidence.actions[0].id;
},
/A3 crash did not land/,
],
[
'the checkpoint had left await_action',
(evidence) => {
evidence.checkpoint.phase = 'dispatch';
},
/A3 crash did not land/,
],
[
'step two PostToolUse physically ran',
@@ -737,16 +770,20 @@ describe('managed-agent-server e2e runner', () => {
tool_input: {},
});
},
/A3 crash physical Hook\/effect boundary differs/,
],
])('rejects a command-rewrite crash barrier with %s', (_name, mutate) => {
const assertions = commandRewriteAssertions();
const evidence = commandRewriteFixture(false);
const identity = withCommandRewriteReceipts(assertions, evidence);
mutate(evidence);
expect(() =>
assertions.assertCommandRewriteCrashBarrier(evidence, identity),
).toThrow();
});
])(
'rejects a command-rewrite crash barrier with %s',
(_name, mutate, expected) => {
const assertions = commandRewriteAssertions();
const evidence = commandRewriteFixture(false);
const identity = withCommandRewriteReceipts(assertions, evidence);
mutate(evidence);
expect(() =>
assertions.assertCommandRewriteCrashBarrier(evidence, identity),
).toThrow(expected);
},
);
it.each([
[
@@ -754,12 +791,21 @@ describe('managed-agent-server e2e runner', () => {
(evidence) => {
evidence.actions[1].state = 'requested';
},
/A3 final Action identities/,
],
[
'the two steps shared an Action',
(evidence) => {
evidence.actions[1].id = evidence.actions[0].id;
},
/A3 final Action identities/,
],
[
'a repeated physical Hook',
(evidence) => {
evidence.physicalHooks.push(evidence.physicalHooks[0]);
},
/A3 physical PreToolUse count differs/,
],
[
'a third extension command receipt',
@@ -770,28 +816,34 @@ describe('managed-agent-server e2e runner', () => {
state: 'settled',
});
},
/A3 extension command receipts differ/,
],
[
'a receipt for different input',
(evidence) => {
evidence.receipts[0].fingerprint = '0'.repeat(64);
},
/A3 extension command receipts differ/,
],
[
'a duplicate toolStageId',
(evidence) => {
evidence.stages[1].toolStageId = evidence.stages[0].toolStageId;
},
/A3 command stage evidence differs/,
],
])('rejects command-rewrite final evidence with %s', (_name, mutate) => {
const assertions = commandRewriteAssertions();
const evidence = commandRewriteFixture(true);
const identity = withCommandRewriteReceipts(assertions, evidence);
mutate(evidence);
expect(() =>
assertions.assertCommandRewriteFinalEvidence(evidence, identity),
).toThrow();
});
])(
'rejects command-rewrite final evidence with %s',
(_name, mutate, expected) => {
const assertions = commandRewriteAssertions();
const evidence = commandRewriteFixture(true);
const identity = withCommandRewriteReceipts(assertions, evidence);
mutate(evidence);
expect(() =>
assertions.assertCommandRewriteFinalEvidence(evidence, identity),
).toThrow(expected);
},
);
it('pins the #14481 approval fault arms to durable barriers', () => {
const source = read('scripts/run-managed-agent-server-e2e.ts');