mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-08 17:41:36 -10:00
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:
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+4
-3
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+82
-49
@@ -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) {
|
||||
|
||||
+56
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user