From ed52a374ae0b14bf8a97aa3d069ddc3b62672590 Mon Sep 17 00:00:00 2001 From: DrDrewCain Date: Sun, 13 Sep 2026 05:16:43 -0500 Subject: [PATCH 1/2] Validate exact tool approvals and paused runs in the console --- src/agents/approval-actions.ts | 29 +++++++++++ src/agents/approval-json.ts | 42 +++++++++++++++ src/agents/approvals.ts | 69 +++++++++++++++++++++++++ src/agents/runs.ts | 13 +++-- tests/agent-approval-actions.test.ts | 28 ++++++++++ tests/agent-approval-boundaries.test.ts | 31 +++++++++++ tests/agent-approvals.test.ts | 45 ++++++++++++++++ tests/fixtures/agent-approval-data.ts | 9 ++++ 8 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 src/agents/approval-actions.ts create mode 100644 src/agents/approval-json.ts create mode 100644 src/agents/approvals.ts create mode 100644 tests/agent-approval-actions.test.ts create mode 100644 tests/agent-approval-boundaries.test.ts create mode 100644 tests/agent-approvals.test.ts create mode 100644 tests/fixtures/agent-approval-data.ts diff --git a/src/agents/approval-actions.ts b/src/agents/approval-actions.ts new file mode 100644 index 0000000..944b89a --- /dev/null +++ b/src/agents/approval-actions.ts @@ -0,0 +1,29 @@ +import type {ApiClient} from '../api.ts'; +import {approvalAddress,matchContinuation,matchDecision,parseApproval,parseApprovalPage,prepareContinuation,sameCall,sameDecision,type ApprovalContinuation,type Decision,type ToolApproval} from './approvals.ts'; +import {runAddress,type RunRequest,type RunStatus} from './runs.ts'; +const secure={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; +type Transport=Pick; +export async function readApprovals(api:Transport,request:RunRequest,signal:AbortSignal):Promise[]> { + signal.throwIfAborted(); + const raw=await api.request(approvalAddress(request.run_id),{...secure,signal}); + signal.throwIfAborted();return parseApprovalPage(raw,request); +} +export async function decideApproval(api:Transport,request:RunRequest,pending:Readonly,decision:Decision,signal:AbortSignal):Promise> { + const checked=parseApproval(pending,request);if(decision!=='approve'&&decision!=='deny')throw Error('Choose approve or deny.'); + const items=await readApprovals(api,request,signal),current=items.find(item=>item.request_id===checked.request_id); + if(!current||!sameCall(current,checked)||(current.decision!==null&¤t.decision!==decision))throw Error('This call has changed. Check status before deciding.'); + signal.throwIfAborted(); + const saved=parseApproval(await api.request(approvalAddress(request.run_id,checked.request_id),{...secure,signal,method:'POST',body:JSON.stringify({decision,expected_revision:1})}),request); + signal.throwIfAborted();matchDecision(saved,current,decision);return saved; +} +export async function continueApprovals(api:Transport,request:RunRequest,expected:ApprovalContinuation,prior:readonly Readonly[],signal:AbortSignal):Promise { + const checked=prior.map(item=>parseApproval(item,request)),ids=Object.keys(expected.decisions); + if(ids.some(id=>expected.decisions[id]!==2))throw Error('Invalid approval selection.'); + const body=prepareContinuation(checked,ids,expected.continuation_id); + const current=await readApprovals(api,request,signal); + prepareContinuation(current,ids,body.continuation_id); + for(const id of ids){const latest=current.find(item=>item.request_id===id),original=checked.find(item=>item.request_id===id);if(!latest||!original||!sameDecision(latest,original))throw Error('A selected decision changed. Check status before continuing.');} + signal.throwIfAborted(); + const result=await api.request(runAddress(request.run_id)+'/approval-continuations',{...secure,signal,method:'POST',body:JSON.stringify(body)}); + signal.throwIfAborted();return matchContinuation(result,request,body,checked); +} diff --git a/src/agents/approval-json.ts b/src/agents/approval-json.ts new file mode 100644 index 0000000..1544bf7 --- /dev/null +++ b/src/agents/approval-json.ts @@ -0,0 +1,42 @@ +/** Validate bounded JSON without converting numeric literals into JS numbers. */ +export function literalArguments(value:unknown):string { + if(typeof value!=='string'||new TextEncoder().encode(value).length>16000)throw Error('Invalid tool arguments.'); + const source=value;let cursor=0,nodes=0; + const fail=():never=>{throw Error('The literal tool arguments could not be verified.');}; + const string=():string=>{ + const start=cursor;if(source[cursor++]!=='"')fail(); + while(cursor{ + if(depth>32||++nodes>20000)fail(); + const first=source[cursor]; + if(first==='"'){string();return;} + if(first==='{'||first==='['){ + cursor++;const closing=first==='{'?'}':']',keys=new Set(); + if(source[cursor]===closing){cursor++;return;} + for(;;){ + if(first==='{'){ + const key=string();if(++nodes>20000||keys.has(key)||source[cursor++]!==':')fail();keys.add(key); + } + visit(depth+1); + if(source[cursor]===closing){cursor++;return;} + if(source[cursor++]!==',')fail(); + } + } + for(const token of ['null','true','false'])if(source.startsWith(token,cursor)){cursor+=token.length;return;} + const number=source.slice(cursor).match(/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/); + if(!number)return fail();const token=number[0];cursor+=token.length; + if(/[.eE]/.test(token)){if(!Number.isFinite(Number(token)))fail();} + else {const integer=BigInt(token);if(integer<=-(1n<<256n)||integer>=(1n<<256n))fail();} + }; + if(source[0]!=='{')fail();visit(0);if(cursor!==source.length)fail();return source; +} diff --git a/src/agents/approvals.ts b/src/agents/approvals.ts new file mode 100644 index 0000000..4a92f49 --- /dev/null +++ b/src/agents/approvals.ts @@ -0,0 +1,69 @@ +import {identifier,isHandoffPlan,isInputTask,record} from './plans.ts'; +import {matchRun,parseRunStatus,runAddress,type RunRequest,type RunStatus} from './runs.ts'; +import {literalArguments} from './approval-json.ts'; +export type Decision='approve'|'deny'; +export interface ApprovalCall {step_id:string;selection_id:string;agent_id:string;model_id:string;binding:string;tool_name:string;tool_revision:string;tool_digest:string;arguments_json:string;operation_digest:string} +export interface ToolApproval {space:string;run_id:string;request_id:string;call:Readonly;revision:number;created_at:string;decision:Decision|null;decided_by:string|null;decided_at:string|null;decision_digest:string|null;activation_id:string|null;activated_at:string|null;consumed_at:string|null} +export interface ApprovalContinuation {continuation_id:string;decisions:Record} +function fail():never{throw Error('The tool approval response could not be verified.');} +function keys(row:Record,expected:string):void{const names=expected.split(' ');if(Object.keys(row).length!==names.length||names.some(name=>!Object.hasOwn(row,name)))fail();} +function text(value:unknown,maximum:number):string{if(typeof value!=='string'||!value.trim()||new TextEncoder().encode(value).length>maximum)fail();return value;} +function digest(value:unknown):string{const result=text(value,64);if(!/^[a-f0-9]{64}$/.test(result))fail();return result;} +function stamp(value:unknown):string{const result=text(value,64);if(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(result)||!Number.isFinite(Date.parse(result)))fail();return result;} +const callKeys='step_id selection_id agent_id model_id binding tool_name tool_revision tool_digest arguments_json operation_digest'; +const recordKeys='space run_id request_id call revision created_at decision decided_by decided_at decision_digest activation_id activated_at consumed_at'; +export function parseApproval(value:unknown,request:RunRequest):Readonly{ + const row=record(value);keys(row,recordKeys);const raw=record(row.call);keys(raw,callKeys); + if(row.space!==request.space||row.run_id!==request.run_id)fail(); + const tool_name=text(raw.tool_name,64),tool_revision=text(raw.tool_revision,128); + if(!/^[A-Za-z0-9_-]+$/.test(tool_name)||['search_memory','trace_memory','read_memory','compute_memory','answer','unknown_tool','custom_tool'].includes(tool_name)||!/^[A-Za-z0-9._:-]+$/.test(tool_revision))fail(); + const call:Readonly=Object.freeze({step_id:identifier(raw.step_id),selection_id:identifier(raw.selection_id),agent_id:identifier(raw.agent_id),model_id:identifier(raw.model_id),binding:digest(raw.binding),tool_name,tool_revision,tool_digest:digest(raw.tool_digest),arguments_json:literalArguments(raw.arguments_json),operation_digest:digest(raw.operation_digest)}); + if(request.bindings[call.selection_id]!==call.binding)fail(); + if(isHandoffPlan(request.plan)){ + const plan=request.plan,hop=Array.from({length:plan.max_handoffs+1},(_,index)=>`hop-${String(index+1).padStart(2,'0')}`).indexOf(call.step_id); + const selected=plan.agents.find(agent=>agent.agent_id===call.selection_id); + if(hop<0||!selected||selected.agent_id!==call.agent_id||selected.model_id!==call.model_id)fail(); + let reachable=new Set([plan.root_agent]); + for(let index=0;indexreachable.has(agent.agent_id)).flatMap(agent=>agent.can_handoff_to)); + if(!reachable.has(call.agent_id))fail(); + }else{ + const task=request.plan.tasks.find(task=>task.task_id===call.selection_id); + if(!task||isInputTask(task)||task.task_id!==call.step_id||task.agent_id!==call.agent_id||task.model_id!==call.model_id)fail(); + } + if(typeof row.revision!=='number'||![1,2,3,4].includes(row.revision))fail(); + const revision=row.revision,decision=row.decision;if(decision!==null&&decision!=='approve'&&decision!=='deny')fail(); + const decided_by=row.decided_by===null?null:identifier(row.decided_by),decided_at=row.decided_at===null?null:stamp(row.decided_at),decision_digest=row.decision_digest===null?null:digest(row.decision_digest); + const activation_id=row.activation_id===null?null:identifier(row.activation_id),activated_at=row.activated_at===null?null:stamp(row.activated_at),consumed_at=row.consumed_at===null?null:stamp(row.consumed_at); + if([decision,decided_by,decided_at,decision_digest].some(value=>(value!==null)!==(revision>=2))||[activation_id,activated_at].some(value=>(value!==null)!==(revision>=3))||(consumed_at!==null)!==(revision===4))fail(); + return Object.freeze({space:request.space,run_id:request.run_id,request_id:digest(row.request_id),call,revision,created_at:stamp(row.created_at),decision,decided_by,decided_at,decision_digest,activation_id,activated_at,consumed_at}); +} +export function parseApprovalPage(value:unknown,request:RunRequest):Readonly[] { + const row=record(value);if(row.space!==request.space||row.run_id!==request.run_id||!Array.isArray(row.items)||row.items.length>512)fail(); + const items=(row.items as unknown[]).map(value=>parseApproval(value,request)),pending=items.filter(item=>item.revision<4); + if(new Set(items.map(item=>item.request_id)).size!==items.length||new Set(pending.map(item=>item.call.step_id)).size!==pending.length)fail();return items; +} +export function sameCall(left:Readonly,right:Readonly):boolean{ + return left.space===right.space&&left.run_id===right.run_id&&left.request_id===right.request_id&&left.created_at===right.created_at&&callKeys.split(' ').every(key=>left.call[key as keyof ApprovalCall]===right.call[key as keyof ApprovalCall]); +} +export function sameDecision(left:Readonly,right:Readonly):boolean{ + return sameCall(left,right)&&left.decision===right.decision&&left.decided_by===right.decided_by&&left.decided_at===right.decided_at&&left.decision_digest===right.decision_digest; +} +export function matchDecision(saved:Readonly,pending:Readonly,decision:Decision):void{ + if(!sameCall(saved,pending)||saved.revision<2||saved.decision!==decision||(pending.revision>=2&&!sameDecision(saved,pending)))fail(); +} +export function prepareContinuation(items:readonly Readonly[],selected:readonly string[],continuationId:string):ApprovalContinuation{ + identifier(continuationId);if(!selected.length||selected.length>32||new Set(selected).size!==selected.length)fail(); + const group=items.filter(item=>item.activation_id===continuationId).map(item=>item.request_id); + if(group.length&&(group.length!==selected.length||group.some(id=>!selected.includes(id))))fail(); + for(const id of selected){const item=items.find(item=>item.request_id===id);if(!item||item.revision<2||item.decision_digest===null||(item.activation_id!==null&&item.activation_id!==continuationId))fail();} + return {continuation_id:continuationId,decisions:Object.fromEntries(selected.map(id=>[id,2 as const]))}; +} +export function matchContinuation(value:unknown,request:RunRequest,expected:ApprovalContinuation,prior:readonly Readonly[]):RunStatus{ + const row=record(value),activation=record(row.activation);keys(activation,'space run_id activation_id created_at decisions decision_digests'); + if(activation.space!==request.space||activation.run_id!==request.run_id||activation.activation_id!==expected.continuation_id)fail();stamp(activation.created_at); + const revisions=record(activation.decisions),hashes=record(activation.decision_digests),ids=Object.keys(expected.decisions); + if(Object.keys(revisions).length!==ids.length||Object.keys(hashes).length!==ids.length)fail(); + for(const id of ids){const item=prior.find(item=>item.request_id===id);if(expected.decisions[id]!==2||revisions[id]!==2||!item||item.decision_digest===null||hashes[id]!==item.decision_digest)fail();} + const status=parseRunStatus(row.status,request.space,request.run_id);matchRun(status,request);return status; +} +export function approvalAddress(runId:string,requestId?:string):string{return runAddress(runId)+'/approvals'+(requestId===undefined?'':'/'+digest(requestId)+'/decision');} diff --git a/src/agents/runs.ts b/src/agents/runs.ts index 23d7584..58dfe93 100644 --- a/src/agents/runs.ts +++ b/src/agents/runs.ts @@ -1,6 +1,6 @@ import {usageFields,type ToolTokenUsage} from './usage.ts'; import {bindingIds,identifier,isHandoffPlan,isInputTask,parseSavedPlan,record,type WorkflowPlan,type HandoffPlan} from './plans.ts'; -export interface RunStatus {space:string;run_id:string;created_at:string;workflow_id:string;plan_revision:number;status:string;active_local:boolean;completed_steps:string[];inflight:string|null;outcome_unknown:boolean;error_class:string|null;max_parallel:number;inflight_steps:string[];waiting_steps:string[]} +export interface RunStatus {space:string;run_id:string;created_at:string;workflow_id:string;plan_revision:number;status:string;active_local:boolean;completed_steps:string[];inflight:string|null;outcome_unknown:boolean;error_class:string|null;max_parallel:number;inflight_steps:string[];waiting_steps:string[];paused_steps:string[]} export interface RunRequest {space:string;run_id:string;question:string;created_at:string;plan:WorkflowPlan;revision:number;bindings:Record;max_parallel:number} export interface TaskOutput {kind?:'model';task_id:string;agent_id:string;model_id:string;text:string;source_status:'retained'|'none';evidence_ids:string[];model_calls:number;tool_calls:number;handoff_to?:string|null;usage?:ToolTokenUsage|null} export interface HumanOutput {kind:'human_input';task_id:string;text:string;activation_id:string} @@ -25,8 +25,11 @@ export function parseRunStatus(value:unknown,space:string,runId?:string):RunStat const status=text(row.status,64),inflight=row.inflight===null?null:identifier(row.inflight),inflight_steps=row.inflight_steps===undefined?(inflight?[inflight]:[]):names(row.inflight_steps); const max_parallel=integer(row.max_parallel===undefined?1:row.max_parallel,1,8); if((inflight_steps[0]??null)!==inflight||inflight_steps.length>max_parallel)fail(); - if(!['created','deadline','outcome_unknown','retry_not_allowed','registered','running','completed','failed','cancelled','sources_invalid','verification_unavailable','unavailable','awaiting_input'].includes(status))fail(); - return {space,run_id,created_at:date(row.created_at),workflow_id:identifier(row.workflow_id),plan_revision:integer(row.plan_revision,1),status, + if(!['created','deadline','outcome_unknown','retry_not_allowed','registered','running','completed','failed','cancelled','sources_invalid','verification_unavailable','unavailable','awaiting_input','paused'].includes(status))fail(); + const paused_steps=row.paused_steps===undefined?[]:names(row.paused_steps),completed=names(row.completed_steps),waiting=row.waiting_steps===undefined?[]:names(row.waiting_steps); + const groups=[completed,waiting,inflight_steps,paused_steps],flat=groups.flat();if(new Set(flat).size!==flat.length)fail(); + if((status==='paused'&&(!paused_steps.length||row.outcome_unknown!==false))||(status==='completed'&&paused_steps.length))fail(); + return {paused_steps,space,run_id,created_at:date(row.created_at),workflow_id:identifier(row.workflow_id),plan_revision:integer(row.plan_revision,1),status, active_local:bool(row.active_local),completed_steps:names(row.completed_steps),inflight,inflight_steps,waiting_steps:row.waiting_steps===undefined?[]:names(row.waiting_steps),max_parallel,outcome_unknown:bool(row.outcome_unknown),error_class:row.error_class===null?null:text(row.error_class,128)}; } export function parseRunPage(value:unknown,space:string):{items:RunStatus[];next_after:string|null}{ @@ -48,11 +51,13 @@ function hopIds(plan:HandoffPlan):string[]{return Array.from({length:plan.max_ha export function matchRun(status:RunStatus,request:RunRequest):void{ if(status.run_id!==request.run_id||status.space!==request.space||status.workflow_id!==request.plan.workflow_id||status.plan_revision!==request.revision||status.max_parallel!==request.max_parallel||Date.parse(status.created_at)!==Date.parse(request.created_at))fail(); const ids=isHandoffPlan(request.plan)?hopIds(request.plan):request.plan.tasks.map(task=>task.task_id),known=new Set(ids); - if([...status.completed_steps,...status.inflight_steps,...status.waiting_steps].some(id=>!known.has(id))||(status.inflight!==null&&!known.has(status.inflight)))fail(); + if([...status.completed_steps,...status.inflight_steps,...status.waiting_steps,...status.paused_steps].some(id=>!known.has(id))||(status.inflight!==null&&!known.has(status.inflight)))fail(); if(!isHandoffPlan(request.plan)){const tasks=request.plan.tasks;if(status.inflight_steps.some(id=>tasks.some(task=>task.task_id===id&&isInputTask(task))))fail();} if(status.waiting_steps.some(id=>isHandoffPlan(request.plan)||!request.plan.tasks.some(task=>task.task_id===id&&isInputTask(task)))||status.waiting_steps.some(id=>status.completed_steps.includes(id)||status.inflight_steps.includes(id)))fail(); if(isHandoffPlan(request.plan)&&JSON.stringify(status.completed_steps)!==JSON.stringify(ids.slice(0,status.completed_steps.length)))fail(); if(isHandoffPlan(request.plan)&&status.inflight!==null&&status.inflight!==ids[status.completed_steps.length])fail(); + if(isHandoffPlan(request.plan)){if(status.paused_steps.length&&(status.paused_steps.length!==1||status.paused_steps[0]!==ids[status.completed_steps.length]))fail();} + else for(const id of status.paused_steps){const task=request.plan.tasks.find(task=>task.task_id===id);if(!task||isInputTask(task)||task.depends_on.some(dep=>!status.completed_steps.includes(dep)))fail();} } function parseOutput(value:unknown,expected:{task_id:string;agent_id:string;model_id:string;binding:string;depends_on:string[]},includeUsage:boolean):{output:TaskOutput;packets:string[]}{ const output=record(value); diff --git a/tests/agent-approval-actions.test.ts b/tests/agent-approval-actions.test.ts new file mode 100644 index 0000000..49f95cc --- /dev/null +++ b/tests/agent-approval-actions.test.ts @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readApprovals,decideApproval,continueApprovals} from '../src/agents/approval-actions.ts'; +import {parseApproval,prepareContinuation} from '../src/agents/approvals.ts'; +import {request,pending,decided,status,page} from './fixtures/agent-approval-data.ts'; +function transport(responses:unknown[]){const calls:{path:string;options?:RequestInit}[]=[];return {calls,api:{async request(path:string,options?:RequestInit):Promise{calls.push({path,options});const response=responses.shift();if(response instanceof Error)throw response;return response as T;}}};} +test('reading and deciding each perform only their explicit action',async()=>{ + const t=transport([page([pending]),page([pending]),decided]),signal=new AbortController().signal; + const [item]=await readApprovals(t.api,request,signal);await decideApproval(t.api,request,item,'approve',signal); + const writes=t.calls.filter(call=>call.options?.method==='POST');assert.equal(writes.length,1);assert.deepEqual(JSON.parse(writes[0].options!.body as string),{decision:'approve',expected_revision:1}); + assert.ok(t.calls.every(call=>call.options?.cache==='no-store')); +}); +test('source snapshot changes and cancellation refuse before POST',async()=>{ + const p=parseApproval(pending,request),t=transport([page([{...pending,call:{...pending.call,arguments_json:'{"message":"changed"}'}}])]); + await assert.rejects(()=>decideApproval(t.api,request,p,'approve',new AbortController().signal));assert.equal(t.calls.length,1); + const controller=new AbortController();let writes=0; + const api={async request(path:string,options?:RequestInit):Promise{if(options?.method==='POST')writes++;controller.abort();return page([pending]) as T;}}; + await assert.rejects(()=>decideApproval(api,request,p,'approve',controller.signal));assert.equal(writes,0); +}); +test('ambiguous continuation is never automatically replayed',async()=>{ + const d=parseApproval(decided,request),body=prepareContinuation([d],[d.request_id],'next'),t=transport([page([decided]),Error('connection lost')]); + await assert.rejects(()=>continueApprovals(t.api,request,body,[d],new AbortController().signal)); + assert.equal(t.calls.filter(call=>call.options?.method==='POST').length,1); +}); +test('changed decision hashes cannot authorize continuation',async()=>{ + const d=parseApproval(decided,request),body=prepareContinuation([d],[d.request_id],'next'),t=transport([page([{...decided,decision_digest:'f'.repeat(64)}])]); + await assert.rejects(()=>continueApprovals(t.api,request,body,[d],new AbortController().signal));assert.equal(t.calls.length,1); +}); diff --git a/tests/agent-approval-boundaries.test.ts b/tests/agent-approval-boundaries.test.ts new file mode 100644 index 0000000..e29b03e --- /dev/null +++ b/tests/agent-approval-boundaries.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {literalArguments} from '../src/agents/approval-json.ts'; +import {parseApproval,parseApprovalPage,prepareContinuation,matchContinuation} from '../src/agents/approvals.ts'; +import {request,pending,decided,status} from './fixtures/agent-approval-data.ts'; + +test('literal boundary preserves exact valid values and never builds prototype-bearing objects',()=>{ + const maximum=(1n<<256n)-1n; + for(const raw of ['{"__proto__":{"polluted":true},"constructor":{"prototype":1}}',`{"x":${maximum}}`,`{"x":-${maximum}}`,'{"big":9007199254740993,"decimal":1.0000000000000002,"zero":-0.0}', '{"line":"a\\nb","pair":"😀","escaped":"\\u0061"}']){ + assert.equal(literalArguments(raw),raw); + } + assert.equal(({} as {polluted?:boolean}).polluted,undefined); +}); +test('literal malformed, UTF8, integer and depth bounds refuse',()=>{ + for(const raw of ['{"x":1,"\\u0078":2}','{"x":01}','{"x":1.}','{"x":1e}','{"x":1e309}','{"x":+1}','{"x":true}tail','{"x":"\\ud800"}','{"x":"\udc00"}',`{"x":${1n<<256n}}`,`{"x":-${1n<<256n}}`,'{"x":'+'['.repeat(33)+'0'+']'.repeat(33)+'}','{"x":"'+'😀'.repeat(4000)+'"}'])assert.throws(()=>literalArguments(raw),raw.slice(0,40)); +}); +test('approval record fields are own, detached and frozen',()=>{ + const source=structuredClone(pending); + const parsed=parseApproval(source,request); + source.call.arguments_json='{"changed":true}'; + assert.equal(parsed.call.arguments_json,pending.call.arguments_json); + assert.ok(Object.isFrozen(parsed));assert.ok(Object.isFrozen(parsed.call)); + assert.throws(()=>parseApproval(Object.create(pending),request)); + assert.throws(()=>parseApproval({...pending,call:Object.create(pending.call)},request)); + assert.throws(()=>parseApprovalPage({space:'alpha',run_id:'one',items:[pending,{...pending,request_id:'f'.repeat(64)}]},request)); +}); +test('selected activation acknowledgement binds IDs, revisions, hashes and space',()=>{ + const record=parseApproval(decided,request),expected=prepareContinuation([record],[record.request_id],'next'); + const activation={space:'alpha',run_id:'one',activation_id:'next',created_at:pending.created_at,decisions:{[record.request_id]:2},decision_digests:{[record.request_id]:record.decision_digest}}; + for(const mutation of [{decisions:{[record.request_id]:true}},{decision_digests:{[record.request_id]:'f'.repeat(64)}},{space:'bravo'},{decisions:{[record.request_id]:2,extra:2}},{decision_digests:{[record.request_id]:record.decision_digest,extra:'f'.repeat(64)}}])assert.throws(()=>matchContinuation({status,activation:{...activation,...mutation}},request,expected,[record])); +}); diff --git a/tests/agent-approvals.test.ts b/tests/agent-approvals.test.ts new file mode 100644 index 0000000..560fd49 --- /dev/null +++ b/tests/agent-approvals.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseApprovalPage,parseApproval,matchDecision,prepareContinuation,matchContinuation} from '../src/agents/approvals.ts'; +import {parseRunRequest,parseRunStatus,matchRun} from '../src/agents/runs.ts'; +import {request,pending,decided,status,page,call,time} from './fixtures/agent-approval-data.ts'; +test('literal arguments preserve large integers, decimal formatting and Unicode',()=>{ + for(const arguments_json of ['{"count":123456789012345678901234567890}', '{"amount":1.0,"small":1e-07}', '{"😀":"hello","空":[]}']){ + const parsed=parseApproval({...pending,call:{...call,arguments_json}},request); + assert.equal(parsed.call.arguments_json,arguments_json); + } + assert.equal(parseApproval(pending,request).call.tool_revision,'.'); +}); +test('refuse substituted models, bindings, fields and ambiguous argument objects',()=>{ + for(const change of [{model_id:'fast'},{step_id:'other'},{binding:'f'.repeat(64)},{tool_name:'search_memory'}, + {arguments_json:'{"x":1,"x":2}'},{arguments_json:'{"x":NaN}'},{arguments_json:'{"x":'+ '1'.repeat(100)+'}'}, + {arguments_json:'{"x":'+'['.repeat(34)+'0'+']'.repeat(34)+'}'},{arguments_json:'[]'}]){ + assert.throws(()=>parseApproval({...pending,call:{...call,...change}},request)); + } + for(const change of [{revision:true},{revision:2},{space:'other'},{decision:'approve'},{created_at:'2026-09-13'}, {extra:true}])assert.throws(()=>parseApproval({...pending,...change},request)); + assert.throws(()=>parseApprovalPage(page([pending,pending]),request)); +}); +test('decision acknowledgement cannot change the exact call or chosen decision',()=>{ + const p=parseApproval(pending,request),d=parseApproval(decided,request); + matchDecision(d,p,'approve'); + assert.throws(()=>matchDecision(d,p,'deny')); + assert.throws(()=>matchDecision(parseApproval({...decided,call:{...call,arguments_json:'{"message":"Changed"}'}},request),p,'approve')); +}); +test('activation requires exact immutable selected decision hashes and one batch',()=>{ + const d=parseApproval(decided,request),body=prepareContinuation([d],[d.request_id],'next'); + const activation={space:'alpha',run_id:'one',activation_id:'next',created_at:time,decisions:{[d.request_id]:2},decision_digests:{[d.request_id]:d.decision_digest}}; + matchContinuation({status,activation},request,body,[d]); + for(const change of [{activation_id:'other'},{decisions:{}},{decision_digests:{[d.request_id]:'f'.repeat(64)}},{run_id:'other'}])assert.throws(()=>matchContinuation({status,activation:{...activation,...change}},request,body,[d])); + assert.throws(()=>prepareContinuation([d],[],'next')); + assert.throws(()=>prepareContinuation([d],[d.request_id,d.request_id],'next')); + assert.throws(()=>prepareContinuation([parseApproval(pending,request)],[d.request_id],'next')); +}); +test('saved activation retries preserve their exact original group',()=>{ + const activated=parseApproval({...decided,revision:3,activation_id:'next',activated_at:time},request); + assert.equal(prepareContinuation([activated],[activated.request_id],'next').continuation_id,'next'); + assert.throws(()=>prepareContinuation([activated],[activated.request_id],'different')); +}); +test('paused steps are disjoint model tasks, not unknown or completed execution',()=>{ + const parsed=parseRunStatus(status,'alpha');matchRun(parsed,request);assert.deepEqual(parsed.paused_steps,['send']); + for(const change of [{paused_steps:[]},{paused_steps:['other']},{paused_steps:['send','send']},{completed_steps:['send']},{status:'completed'},{outcome_unknown:true}])assert.throws(()=>matchRun(parseRunStatus({...status,...change},'alpha'),request)); +}); diff --git a/tests/fixtures/agent-approval-data.ts b/tests/fixtures/agent-approval-data.ts new file mode 100644 index 0000000..a5fd4e0 --- /dev/null +++ b/tests/fixtures/agent-approval-data.ts @@ -0,0 +1,9 @@ +import {parseRunRequest} from '../../src/agents/runs.ts'; +export const time='2026-09-13T10:00:00Z'; +export const task={task_id:'send',agent_id:'worker',model_id:'careful',prompt:'Send a note',depends_on:[]}; +export const request=parseRunRequest({space:'alpha',run_id:'one',created_at:time,question:'Hello',plan:{space:'alpha',revision:1,updated_at:time,plan:{workflow_id:'job',tasks:[task]},bindings:{send:'a'.repeat(64)}}},'alpha','one'); +export const call={step_id:'send',selection_id:'send',agent_id:'worker',model_id:'careful',binding:'a'.repeat(64),tool_name:'send_note',tool_revision:'.',tool_digest:'b'.repeat(64),arguments_json:'{"message":""}',operation_digest:'c'.repeat(64)}; +export const pending={space:'alpha',run_id:'one',request_id:'d'.repeat(64),call,revision:1,created_at:time,decision:null,decided_by:null,decided_at:null,decision_digest:null,activation_id:null,activated_at:null,consumed_at:null}; +export const decided={...pending,revision:2,decision:'approve',decided_by:'key:owner',decided_at:time,decision_digest:'e'.repeat(64)}; +export const status={space:'alpha',run_id:'one',created_at:time,workflow_id:'job',plan_revision:1,status:'paused',active_local:false,completed_steps:[],inflight:null,outcome_unknown:false,error_class:null,paused_steps:['send']}; +export const page=(items:unknown[])=>({space:'alpha',run_id:'one',items}); From 94025e4401d789c8537034bc6f930bd00332a478 Mon Sep 17 00:00:00 2001 From: DrDrewCain Date: Sun, 13 Sep 2026 05:30:41 -0500 Subject: [PATCH 2/2] Add explicit tool decisions and durable continuation controls --- README.md | 34 +++++++++++- package.json | 1 + scripts/check-agent-approvals.mjs | 57 +++++++++++++++++++ src/agents/AgentsPage.tsx | 6 +- src/agents/ApprovalPanel.tsx | 73 +++++++++++++++++++++++++ src/agents/RunPanel.tsx | 25 ++++++--- src/agents/agents.css | 2 + src/agents/approval-actions.ts | 1 + src/agents/approval-json.ts | 5 ++ src/capabilities.ts | 6 +- tests/agent-approval-boundaries.test.ts | 8 +++ 11 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 scripts/check-agent-approvals.mjs create mode 100644 src/agents/ApprovalPanel.tsx diff --git a/README.md b/README.md index c09565e..3ddac00 100644 --- a/README.md +++ b/README.md @@ -472,8 +472,8 @@ replace the selected model or task graph. Saved plans belong to the authenticated space and are loaded without browser storage. The server enforces write permissions; read-only users can inspect plans. The Python host must configure `AgentCatalog` and `AgentPlanStore` in `create_app`. -This editor saves configuration; starting, cancelling and inspecting runs in the -browser remains separate work. Native execution uses `AgentWorkflow`. +Hosts with run capabilities also support starting, cancelling and inspecting +saved workflow runs in the browser. Native execution uses `AgentWorkflow`. Run `node --test scripts/test-agents.cjs` with the same Playwright environment variables used above. It verifies packaged desktop/mobile editing, persistence, @@ -649,3 +649,33 @@ reopening it. A schema checks shape, not factual accuracy. selected models, exact save/reload, invalid drafts and final answers, and an encrypted journal restart without model replay, at desktop and mobile widths. Use the same isolated Python/browser settings as the task contract fixture. + +### Exact tool approvals + +When the host advertises `agents.approvals`, run inspection shows guarded calls +with their agent, selected model, workflow step, tool revision and exact JSON +arguments. Large integers retain their original digits. Directional Unicode +controls appear as explicit Unicode escapes in both pending requests and history; +the bound arguments remain unchanged. + +**Approve** or **Deny** saves a decision without executing a tool or calling a +model. Select individual saved decisions and choose **Continue with selected +decisions** to advance those calls. A denial skips that tool handler and lets the +agent receive the denial when explicitly continued. Decisions require a review +or full key; continuation requires a write or full key. The host enforces these +permissions and binds each decision to the saved call and revision. + +An unconfirmed continuation retains its identifier and selected batch across +status refreshes and reopening that run in the current panel. Retrying is always +explicit. A continuation already saved by the host can also be recovered after +reloading the browser. Nothing is automatically resubmitted. Cancelled runs and +runs with unknown outcomes cannot continue; admitted-call history alone does not +prove an effect completed. Read the run status and verified results. + +After `pnpm build`, `pnpm test:agent-approvals:browser` exercises the packaged +console against an isolated local HTTP fixture. It checks ambiguous-response +recovery across status refresh and visible directional controls without changing +argument values. It requires an installed Playwright module and local browser; +set `SCONE_PLAYWRIGHT_MODULE` to its module path and +`SCONE_BROWSER_EXECUTABLE` to the browser executable when they are not available +through Playwright's defaults. The command does not install or download either. diff --git a/package.json b/package.json index 8d98072..3970a46 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "tsc --noEmit", "check:assets": "node scripts/package.mjs --check", "test": "node --test tests/*.test.mjs tests/*.test.ts", + "test:agent-approvals:browser": "node scripts/check-agent-approvals.mjs", "start": "node scripts/host.mjs" }, "dependencies": { diff --git a/scripts/check-agent-approvals.mjs b/scripts/check-agent-approvals.mjs new file mode 100644 index 0000000..6709e43 --- /dev/null +++ b/scripts/check-agent-approvals.mjs @@ -0,0 +1,57 @@ +/** Real packaged React regressions; configure a locally installed browser and Playwright. */ +import fs from 'node:fs'; +import http from 'node:http'; +import assert from 'node:assert/strict'; +import {fileURLToPath} from 'node:url'; +const {chromium}=await import(process.env.SCONE_PLAYWRIGHT_MODULE || 'playwright'); +const root=fileURLToPath(new URL('..',import.meta.url)); +const html=fs.readFileSync(root+'/dist/console.html','utf8').replaceAll('__SCONE_TOKEN__','review'); +assert(html.includes('Tool approval requests')); +const time='2026-09-13T10:00:00Z'; +const call={step_id:'send',selection_id:'send',agent_id:'worker',model_id:'careful',binding:'a'.repeat(64),tool_name:'send_note',tool_revision:'.',tool_digest:'b'.repeat(64),arguments_json:'{"message":"hello"}',operation_digest:'c'.repeat(64)}; +const decided={space:'alpha',run_id:'one',request_id:'d'.repeat(64),call,revision:2,created_at:time,decision:'approve',decided_by:'key:owner',decided_at:time,decision_digest:'e'.repeat(64),activation_id:null,activated_at:null,consumed_at:null}; +const status={space:'alpha',run_id:'one',created_at:time,workflow_id:'job',plan_revision:1,status:'paused',active_local:false,completed_steps:[],inflight:null,outcome_unknown:false,error_class:null,paused_steps:['send']}; +const request={space:'alpha',run_id:'one',created_at:time,question:'Hello',plan:{space:'alpha',revision:1,updated_at:time,plan:{workflow_id:'job',tasks:[{task_id:'send',agent_id:'worker',model_id:'careful',prompt:'Send a note',depends_on:[]}]},bindings:{send:'a'.repeat(64)}}}; +const caps=JSON.parse(fs.readFileSync(root+'/tests/fixtures/http-capabilities.json')).python; +for(const k of Object.keys(caps.features))if(k.startsWith('agents.'))caps.features[k]=false; +for(const k of ['catalog','plans','runs','approvals'])caps.features['agents.'+k]=true; +const posts=[],unexpected=[];let records=[decided]; +const server=http.createServer(async(req,res)=>{ + const path=new URL(req.url,'http://localhost').pathname;const json=value=>{res.setHeader('Content-Type','application/json');res.end(JSON.stringify(value));}; + if(path==='/agents'||path==='/'){res.setHeader('Content-Type','text/html');return res.end(html);} + if(path==='/v1/status')return json({space:'alpha',episodes:0}); + if(path==='/v1/capabilities')return json(caps); + if(path==='/v1/agents/catalog')return json({agents:[{agent_id:'worker',default_model:'careful',models:[{model_id:'careful',label:'Careful',revision:'1'}]}]}); + if(path==='/v1/agent-plans'||path==='/v1/agent-runs')return json({items:[],next_after:null}); + if(path==='/v1/agent-runs/one/request'){await new Promise(resolve=>setTimeout(resolve,100));return json(request);} + if(path==='/v1/agent-runs/one')return json(status); + if(path==='/v1/agent-runs/one/approvals')return json({space:'alpha',run_id:'one',items:records}); + if(path==='/v1/agent-runs/one/approval-continuations') {let body='';for await(const chunk of req)body+=chunk;posts.push(JSON.parse(body));res.statusCode=503;return json({error:"acknowledgement unavailable"});} + unexpected.push(path);res.statusCode=404;json({error:'missing'}); +}); +(async()=>{let browser;try{ + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));browser=await chromium.launch({headless:true,...(process.env.SCONE_BROWSER_EXECUTABLE?{executablePath:process.env.SCONE_BROWSER_EXECUTABLE}:{})}); + const page=await browser.newPage();page.setDefaultTimeout(7000); + await page.goto('http://127.0.0.1:'+server.address().port+'/agents'); + await page.getByLabel('Find run by identifier').fill('one');await page.getByRole('button',{name:'Open run',exact:true}).click(); + await page.getByLabel('Continue with this approval').check();await page.getByRole('button',{name:'Continue with selected decisions (1)',exact:true}).click(); + await page.getByText(/Continuation not confirmed/).waitFor();assert.equal(posts.length,1);const first=posts[0].continuation_id; + assert.equal(await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).count(),1); + await page.getByRole('button',{name:'Check status',exact:true}).click();await page.getByLabel('Continue with this approval').waitFor({state:'hidden'});await page.getByLabel('Continue with this approval').waitFor(); + const retrySurvived=await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).count(); + assert.equal(retrySurvived,1,'Check status must preserve the exact pending continuation'); + await page.getByRole('button',{name:'Retry same tool continuation',exact:true}).click(); + await page.getByText(/Continuation not confirmed/).waitFor();assert.equal(posts.length,2); + assert.deepEqual(posts[1],posts[0]); + console.log('Status refresh preserves the exact ambiguous continuation ID and batch.'); + const raw=JSON.stringify({destination:'\u202emoc.elpmaxe@nimda\u202c'}); + const bidi={...decided,call:{...call,arguments_json:raw}}; + records=[bidi,{...bidi,request_id:'f'.repeat(64),revision:4,activation_id:'history',activated_at:time,consumed_at:time}]; + await page.getByRole('button',{name:'Check status',exact:true}).click(); + await page.getByText('Directional control characters are shown as Unicode escapes.',{exact:true}).first().waitFor(); + const displayed=await page.locator('.agent-arguments').allTextContents(); + assert.equal(displayed.length,2); + for(const value of displayed){assert.ok(value.includes('\\u202e'));assert.ok(!/[\u202a-\u202e]/.test(value));assert.deepEqual(JSON.parse(value),JSON.parse(raw));} + assert.equal(posts.length,2,'Displaying bidi requests must not submit decisions or continuations'); + console.log('Pending and admitted argument displays reveal directional controls without changing their values.'); + }catch(error){console.error(error);process.exitCode=1;}finally{await browser?.close();await new Promise(resolve=>server.close(resolve));}})(); diff --git a/src/agents/AgentsPage.tsx b/src/agents/AgentsPage.tsx index c9cd661..e704c39 100644 --- a/src/agents/AgentsPage.tsx +++ b/src/agents/AgentsPage.tsx @@ -13,7 +13,7 @@ import {AgentTools} from './AgentTools'; const secureRequest={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; -type Ready={handoffRequirementsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null}; +type Ready={approvalsAvailable:boolean;handoffRequirementsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean;usageAvailable:boolean;inputsAvailable:boolean;handoffsAvailable:boolean;runsAvailable:boolean;maxParallel:number;space:string;catalog:AgentChoice[];items:SavedPlan[];next_after:string|null}; function Editor({api,space,catalog,initial,onSave,onDirty,inputsAvailable,requirementsAvailable,schemaAvailable}:{api:ApiClient;space:string;catalog:AgentChoice[];initial:SavedPlan|null;onSave:(plan:SavedPlan)=>void;onDirty:()=>void;inputsAvailable:boolean;requirementsAvailable:boolean;schemaAvailable:boolean}){ const first=catalog[0]; const newTask=(id:string):AgentTask=>({task_id:id,agent_id:first?.agent_id??'',model_id:first?.default_model??'',prompt:'',depends_on:[]}); @@ -80,7 +80,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){ if(!caps.features['agents.catalog']||!caps.features['agents.plans'])throw Error('Agent workflow configuration is not enabled on this server.'); const [catalog,page]=await Promise.all([api.request('/v1/agents/catalog',options).then(parseCatalog),api.request('/v1/agent-plans?limit=20',options).then(value=>parsePlanPage(value,status.space as string))]); const maxParallel=caps.features['agents.parallel']?parseRunPolicy(await api.request('/v1/agents/run-policy',options),status.space):1; - if(!controller.signal.aborted)setLoaded({api,data:{handoffRequirementsAvailable:caps.features['agents.handoffs.output_requirements'],requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}}); + if(!controller.signal.aborted)setLoaded({api,data:{approvalsAvailable:caps.features['agents.approvals'],handoffRequirementsAvailable:caps.features['agents.handoffs.output_requirements'],requirementsAvailable:caps.features['agents.output_requirements'],schemaAvailable:caps.features['agents.output_schema'],usageAvailable:caps.features['agents.usage'],inputsAvailable:caps.features['agents.inputs'],handoffsAvailable:caps.features['agents.handoffs'],maxParallel,runsAvailable:caps.features['agents.runs'],space:status.space,catalog,...page}}); })().catch(error=>{if(!controller.signal.aborted)setError(error instanceof Error?error.message:'Agent configuration could not be loaded.');}).finally(()=>{if(!controller.signal.aborted)setLoading(false);}); return()=>controller.abort(); },[api,enabled,refresh]); @@ -101,7 +101,7 @@ export function AgentsPage({api,enabled}:{api:ApiClient;enabled:boolean}){ {(()=>{ const props={api,handoffRequirementsAvailable:data.handoffRequirementsAvailable,requirementsAvailable:data.requirementsAvailable,schemaAvailable:data.schemaAvailable,inputsAvailable:data.inputsAvailable,space:data.space,catalog:data.catalog,initial:selection.plan,onDirty:()=>setDirty(true),onSave:(saved:SavedPlan)=>{setDirty(false);setSelection(current=>({...current,plan:saved}));setLoaded(current=>current?.api===api?{api,data:{...current.data,items:[saved,...current.data.items.filter(item=>item.plan.workflow_id!==saved.plan.workflow_id)]}}:current);}}; return (selection.plan?isHandoffPlan(selection.plan.plan):selection.handoff)?data.handoffsAvailable?:

This server does not support handoff configuration.

:selection.plan&&isInteractivePlan(selection.plan.plan)&&!data.inputsAvailable?

This server does not support human input workflows.

:; - })()}{data.runsAvailable&&} {error&&

{error}

} + })()}{data.runsAvailable&&} {error&&

{error}

} } ; } diff --git a/src/agents/ApprovalPanel.tsx b/src/agents/ApprovalPanel.tsx new file mode 100644 index 0000000..4a95c7a --- /dev/null +++ b/src/agents/ApprovalPanel.tsx @@ -0,0 +1,73 @@ +import {displayArguments} from './approval-json'; +import {useEffect,useRef,useState} from 'react'; +import {ApiError,type ApiClient} from '../api'; +import {continueApprovals,decideApproval,type ApprovalAttemptRef} from './approval-actions'; +import {prepareContinuation,type Decision,type ToolApproval} from './approvals'; +import type {RunRequest,RunStatus} from './runs'; +function issue(error:unknown):string { + if(error instanceof ApiError){ + if(error.status===403)return 'This key cannot perform that action. Decisions require a review or full key; continuing requires a write or full key.'; + if(error.status===409)return 'The request changed or cannot continue in its current state. Check status to review the current call, decision and run.'; + if(error.status===429)return 'The server is busy. Check status before retrying the same continuation.'; + if(error.status===404)return 'This approval is no longer available in the connected space.'; + } + return error instanceof Error?error.message:'The approval could not be confirmed.'; +} +function stage(item:Readonly):string { + if(item.revision===1)return 'Needs your decision'; + if(item.revision===2)return item.decision==='approve'?'Approved · waiting to continue':'Denied · waiting to continue'; + if(item.revision===3)return 'Continuation saved'; + return 'Execution admitted'; +} +function Arguments({value}:{value:string}){ + const visible=displayArguments(value); + return <>{visible!==value&&

Directional control characters are shown as Unicode escapes.

}
{visible}
; +} +export function ApprovalPanel({api,request,status,items,onChanged,attempt}:{api:ApiClient;request:RunRequest;status:RunStatus;items:readonly Readonly[];onChanged:()=>void;attempt:ApprovalAttemptRef}){ + const [selected,setSelected]=useState>(()=>new Set()),[busy,setBusy]=useState(false),[message,setMessage]=useState(''); + const active=useRef(null); + useEffect(()=>()=>active.current?.abort(),[api,request.run_id,request.space]); + const safe=!status.active_local&&!status.outcome_unknown&&!['cancelled','sources_invalid','completed','running','unavailable'].includes(status.status); + const pending=items.filter(item=>item.revision<4),history=items.filter(item=>item.revision===4); + const decide=async(item:Readonly,decision:Decision)=>{ + if(busy||!safe||attempt.current)return; + const controller=new AbortController();active.current=controller;setBusy(true);setMessage(''); + try{ + await decideApproval(api,request,item,decision,AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])); + if(!controller.signal.aborted)onChanged(); + }catch(error){if(!controller.signal.aborted)setMessage(issue(error)+' Decision not confirmed. Check status before deciding again.');} + finally{if(!controller.signal.aborted)setBusy(false);} + }; + const resume=async(recoveryId?:string)=>{ + if(busy||!safe)return; + const controller=new AbortController();active.current=controller;setBusy(true);setMessage(''); + try{ + if(!attempt.current){ + const ids=recoveryId?items.filter(item=>item.activation_id===recoveryId).map(item=>item.request_id):[...selected]; + attempt.current={body:prepareContinuation(items,ids,recoveryId??crypto.randomUUID()),prior:items}; + } + const saved=attempt.current; + await continueApprovals(api,request,saved.body,saved.prior,AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])); + if(!controller.signal.aborted){attempt.current=null;onChanged();} + }catch(error){if(!controller.signal.aborted)setMessage(issue(error)+' Continuation not confirmed. Check status or retry this same continuation; it will not be submitted automatically.');} + finally{if(!controller.signal.aborted)setBusy(false);} + }; + const recoveries=[...new Set(pending.filter(item=>item.revision===3).map(item=>item.activation_id))].filter((id):id is string=>id!==null); + return
+

Tool approval requests

Review the exact call, then approve or deny it. Saving a decision does not run a tool or call a model. Continue separately when you are ready.

+ {pending.map(item=>
+
{item.call.tool_name}
{stage(item)}
+
Agent
{item.call.agent_id}
Selected model
{item.call.model_id}
Workflow step
{item.call.step_id}
Tool revision
{item.call.tool_revision}
+

Exact arguments (JSON)

+
Request details
Request identifier
{item.request_id}
Record revision
{item.revision}
Requested
{item.created_at}
{item.decided_by&&<>
Decided by
{item.decided_by}
}{item.activation_id&&<>
Continuation identifier
{item.activation_id}
}
+ {item.revision===1?
:

{item.decision==='approve'?'This exact call is approved.':'The tool handler will be skipped.'} {item.revision===2?'Continue to let the agent proceed.':'The saved continuation can be retried explicitly.'}

} + {item.revision===2&&} +
)} + {(pending.some(item=>item.revision===2)||attempt.current)&&
} + {!attempt.current&&recoveries.map(id=>)} + {attempt.current&&

Continuation: {attempt.current.body.continuation_id}

} + {status.active_local&&pending.length>0&&

Independent work is still running. Check status when it finishes.

} + {history.length>0&&
Previously admitted calls ({history.length})

Admission alone does not confirm an effect completed. Check the run results and any unknown-outcome notice.

    {history.map(item=>
  • {item.call.step_id} · {item.call.tool_name} · {item.decision==='approve'?'Approved':'Denied'}
    Exact request

    {item.call.agent_id} · {item.call.model_id} · Tool revision {item.call.tool_revision}

    {item.request_id}

  • )}
} + {message&&

{message}

} +
; +} diff --git a/src/agents/RunPanel.tsx b/src/agents/RunPanel.tsx index e66d340..97f9a3a 100644 --- a/src/agents/RunPanel.tsx +++ b/src/agents/RunPanel.tsx @@ -1,3 +1,6 @@ +import {ApprovalPanel} from './ApprovalPanel'; +import {readApprovals,type ApprovalAttemptRef} from './approval-actions'; +import type {ToolApproval} from './approvals'; import {UsageDetails} from './UsageDetails'; import {useEffect,useRef,useState} from 'react'; import {ApiError,type ApiClient} from '../api'; @@ -15,13 +18,13 @@ function message(error:unknown):string{ } return error instanceof Error?error.message:'The run response is unavailable.'; } -function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;onChanged:()=>void;inputsAvailable:boolean;usageAvailable:boolean}){ +function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable,approvalsAvailable,approvalAttempt}:{api:ApiClient;space:string;id:string;expected?:RunSubmission;approvalAttempt:ApprovalAttemptRef;onChanged:()=>void;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean}){ const [version,setVersion]=useState(0),[request,setRequest]=useState(null),[status,setStatus]=useState(null),[result,setResult]=useState(null),[issue,setIssue]=useState(''),[busy,setBusy]=useState(false),[cancelling,setCancelling]=useState(false); - const [inputs,setInputs]=useState([]); + const [inputs,setInputs]=useState([]),[approvals,setApprovals]=useState[]>([]); const active=useRef(null); useEffect(()=>{ const controller=new AbortController();active.current=controller;let timer:ReturnType|undefined; - const started=Date.now();setRequest(null);setStatus(null);setResult(null);setInputs([]);setIssue('');setBusy(true); + const started=Date.now();setRequest(null);setStatus(null);setResult(null);setInputs([]);setApprovals([]);setIssue('');setBusy(true); const options=()=>({...secureRequest,signal:AbortSignal.any([controller.signal,AbortSignal.timeout(15000)])}); void (async()=>{ const original=parseRunRequest(await api.request(runAddress(id)+'/request',options()),space,id); @@ -33,6 +36,7 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable const progress=parseRunStatus(await api.request(runAddress(id),options()),space,id);matchRun(progress,original); if(controller.signal.aborted)return;setStatus(progress);setResult(null);setIssue(''); if(isInteractivePlan(original.plan)&&inputsAvailable){const prompts=parseInputPage(await api.request(runAddress(id)+'/inputs',options()),original);if(controller.signal.aborted)return;verifiedInputs=prompts;setInputs(prompts);} + if(approvalsAvailable){const records=await readApprovals(api,original,options().signal);if(controller.signal.aborted)return;setApprovals(records);} terminal=!progress.active_local&&(progress.status!=='running'||progress.outcome_unknown); if(!progress.active_local&&['completed','verification_unavailable'].includes(progress.status)){ const verified=parseRunResult(await api.request(runAddress(id)+'/result'+(usageAvailable?'?include_usage=true':''),options()),original,usageAvailable); @@ -42,14 +46,14 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable if(Date.now()-started<330000)timer=setTimeout(()=>void poll(),1500); else setIssue('Automatic status checks paused. Use Check status to continue.'); } - }catch(error){if(!controller.signal.aborted){setResult(null);setInputs([]);setIssue(message(error));}} + }catch(error){if(!controller.signal.aborted){setResult(null);setInputs([]);setApprovals([]);setIssue(message(error));}} finally{if(!controller.signal.aborted){setBusy(false);if(terminal)onChanged();}} }; await poll(); })().catch(error=>{if(!controller.signal.aborted){setIssue(message(error));setBusy(false);}}); return()=>{controller.abort();if(timer)clearTimeout(timer);}; - },[api,space,id,version,expected,inputsAvailable,usageAvailable]); - const canCancel=!!status&&(status.active_local||(!status.outcome_unknown&&['awaiting_input','registered','created'].includes(status.status))); + },[api,space,id,version,expected,inputsAvailable,usageAvailable,approvalsAvailable]); + const canCancel=!!status&&(status.active_local||(!status.outcome_unknown&&['awaiting_input','paused','registered','created'].includes(status.status))); const cancel=async()=>{ if(cancelling||!canCancel)return; const controller=active.current;if(!controller)return;setCancelling(true);setResult(null);setIssue(''); @@ -67,6 +71,8 @@ function RunView({api,space,id,expected,onChanged,inputsAvailable,usageAvailable {status?.error_class&&

Run detail: {status.error_class}

} {request&&
Original request · {request.plan.workflow_id} · Revision {request.revision} · {isHandoffPlan(request.plan)?`Up to ${request.plan.max_handoffs} handoffs`:`Up to ${request.max_parallel} simultaneous tasks`}

{request.question}

{isHandoffPlan(request.plan)?<>

Starting agent: {request.plan.root_agent}

    {request.plan.agents.map(agent=>
  • {agent.agent_id} · {agent.model_id} · {agent.can_handoff_to.length?`May hand off to ${agent.can_handoff_to.join(', ')}`:'Must finish without handing off'}
  • )}
:
    {request.plan.tasks.map(task=>
  • {task.task_id}: {isInputTask(task)?'Human input':`${task.agent_id} · ${task.model_id}`}{task.depends_on.length?` · Receives ${task.depends_on.join(', ')}`:''}
  • )}
}
} {request&&status&&inputsAvailable&&inputs.length>0&&{setVersion(n=>n+1);onChanged();}}/>} + {request&&status&&approvalsAvailable&&approvals.length>0&&{setVersion(n=>n+1);onChanged();}}/>} + {status?.paused_steps.length&&!approvalsAvailable?

This run is paused for a tool decision. This server does not advertise the approval interface.

:null} {result?.outcome==='handoff_limit'&&

The handoff limit was reached. These are partial results; no final answer was produced.

} {result&&
{result.tasks.map(output=>output.kind==='human_input'?

{output.task_id} · Human input

Reply used by this workflow

{output.text}
:

{output.task_id} · {output.agent_id} · {output.model_id}{result.finalTask===output.task_id?' · Final answer':''}

{output.handoff_to!==undefined&&

{output.handoff_to===null?'Agent finished':`Handed off to ${output.handoff_to}`}

}

{output.source_status==='retained'?`${output.evidence_ids.length} retained evidence references`:'No retained evidence · Treat this as ungrounded model output'} · {output.model_calls} model calls · {output.tool_calls} tool calls

{result.reusedTasks?.includes(output.task_id)&&

Saved task result reused

}{output.usage!==undefined&&}
{output.text}
)}
} {issue&&

{issue}

} @@ -86,9 +92,12 @@ function RunHistory({api,space,version,onSelect}:{api:ApiClient;space:string;ver useEffect(()=>{const controller=new AbortController();active.current=controller;setItems([]);setAfter(null);void load(controller,null);return()=>controller.abort();},[api,space,version]); return

Run history in {space}

    {items.map(run=>
  • )}
{!busy&&!items.length&&!issue&&

No runs saved yet.

}{busy&&

Loading runs…

}{after&&}{issue&&

{issue}

}
; } -export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable,usageAvailable}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean;usageAvailable:boolean}){ +export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inputsAvailable,usageAvailable,approvalsAvailable}:{api:ApiClient;space:string;plan:SavedPlan|null;dirty:boolean;maxParallel:number;handoffsAvailable:boolean;inputsAvailable:boolean;usageAvailable:boolean;approvalsAvailable:boolean}){ const [runId,setRunId]=useState(()=>crypto.randomUUID()),[parallel,setParallel]=useState(1),[question,setQuestion]=useState(''),[busy,setBusy]=useState(false),[attempted,setAttempted]=useState(false),[issue,setIssue]=useState(''),[submitted,setSubmitted]=useState(null),[selected,setSelected]=useState<{id:string;version:number;expected?:RunSubmission}|null>(null),[history,setHistory]=useState(0),[lookup,setLookup]=useState(''); const active=useRef(null); + const approvalAttempts=useRef({api,space,items:new Map()}); + if(approvalAttempts.current.api!==api||approvalAttempts.current.space!==space)approvalAttempts.current={api,space,items:new Map()}; + const pendingApproval=(id:string):ApprovalAttemptRef=>{const saved=approvalAttempts.current.items.get(id);if(saved)return saved;const created:ApprovalAttemptRef={current:null};approvalAttempts.current.items.set(id,created);return created;}; const supported=!plan||(isInteractivePlan(plan.plan)?inputsAvailable:!isHandoffPlan(plan.plan)||handoffsAvailable); useEffect(()=>()=>active.current?.abort(),[]); const inspect=(id:string,expected?:RunSubmission)=>{runAddress(id);setSelected(current=>({id,expected:expected??(submitted?.run_id===id?submitted:undefined),version:(current?.version??0)+1}));}; @@ -109,7 +118,7 @@ export function RunPanel({api,space,plan,dirty,maxParallel,handoffsAvailable,inp {attempted&&
A new run calls the selected models again.
} {issue&&

{issue}

}
{event.preventDefault();try{inspect(lookup);setIssue('');}catch(error){setIssue(message(error));}}}>
- {selected&&setHistory(n=>n+1)}/>} + {selected&&setHistory(n=>n+1)}/>} ; } diff --git a/src/agents/agents.css b/src/agents/agents.css index 7893103..211acd9 100644 --- a/src/agents/agents.css +++ b/src/agents/agents.css @@ -19,3 +19,5 @@ .agent-tools li {margin:12px 0;} .agent-tools li p {white-space:pre-wrap;margin:4px 0;} .agent-tools small,.agent-tools-empty {color:var(--muted);} + +.agent-approvals{margin-top:24px;max-width:100%;min-width:0}.agent-approvals>p{max-width:72ch;line-height:1.6}.agent-run-view .agent-approval{padding:20px;border:1px solid var(--border,#ddd);border-radius:12px;margin:16px 0}.agent-approval h5{font-size:1rem;margin:0}.agent-approval-stage{font-size:.8rem;padding:5px 9px;border-radius:6px;background:var(--surface-muted,#f4f4f4)}.agent-approval-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:18px 0}.agent-approval dt{font-size:.8rem;color:var(--muted,#777)}.agent-approval dd{margin:4px 0 0;overflow-wrap:anywhere}.agent-arguments-label{font-size:.85rem;font-weight:600;margin-bottom:8px}.agent-arguments{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-height:24rem;overflow:auto;font: .85rem/1.6 ui-monospace,SFMono-Regular,monospace;padding:14px;border-radius:8px;background:var(--surface-muted,#f4f4f4);unicode-bidi:isolate}.agent-approval-identity{font-size:.8rem;line-height:1.5;margin:12px 0}.agent-runs .agent-approval-select{display:flex;align-items:center;gap:10px;margin-top:12px}.agent-runs .agent-approval-select input{width:18px;height:18px;padding:0;margin:0;flex-shrink:0}.agent-recovery{display:block;max-width:100%;overflow-wrap:anywhere;margin:12px 0}.agent-approval-attempt{font-size:.8rem;overflow-wrap:anywhere}.agent-approval-history li{margin:16px 0;overflow-wrap:anywhere}.agent-approval-history summary{cursor:pointer}.agent-approval-history p{line-height:1.5}@media(max-width:600px){.agent-approval-facts{grid-template-columns:1fr}.agent-run-view .agent-approval{padding:14px}.agent-arguments{padding:10px;font-size:.8rem}.agent-approvals .agent-actions button{width:100%}} diff --git a/src/agents/approval-actions.ts b/src/agents/approval-actions.ts index 944b89a..fcafbe0 100644 --- a/src/agents/approval-actions.ts +++ b/src/agents/approval-actions.ts @@ -1,6 +1,7 @@ import type {ApiClient} from '../api.ts'; import {approvalAddress,matchContinuation,matchDecision,parseApproval,parseApprovalPage,prepareContinuation,sameCall,sameDecision,type ApprovalContinuation,type Decision,type ToolApproval} from './approvals.ts'; import {runAddress,type RunRequest,type RunStatus} from './runs.ts'; +export interface ApprovalAttemptRef {current:{body:ApprovalContinuation;prior:readonly Readonly[]} | null} const secure={cache:'no-store',redirect:'error',credentials:'omit',referrerPolicy:'no-referrer'} as const; type Transport=Pick; export async function readApprovals(api:Transport,request:RunRequest,signal:AbortSignal):Promise[]> { diff --git a/src/agents/approval-json.ts b/src/agents/approval-json.ts index 1544bf7..eb7ace9 100644 --- a/src/agents/approval-json.ts +++ b/src/agents/approval-json.ts @@ -40,3 +40,8 @@ export function literalArguments(value:unknown):string { }; if(source[0]!=='{')fail();visit(0);if(cursor!==source.length)fail();return source; } + +/** Reveal directional controls while keeping the original request bytes intact. */ +export function displayArguments(value:string):string { + return value.replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g,character=>'\\u'+character.charCodeAt(0).toString(16).padStart(4,'0')); +} diff --git a/src/capabilities.ts b/src/capabilities.ts index 2d13cbe..e098ed5 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -1,7 +1,7 @@ export const FEATURE_KEYS = ['recall', 'facts.read', 'facts.review', 'facts.close', 'facts.exclude', 'facts.include', 'events.read', 'metrics.read', 'scopes.read', 'status.read'] as const; export type Feature = typeof FEATURE_KEYS[number]; -type OptionalFeature = 'agents.handoffs.output_requirements' | 'agents.output_requirements' | 'agents.output_schema' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; +type OptionalFeature = 'agents.handoffs.output_requirements' | 'agents.output_requirements' | 'agents.output_schema' | 'agents.approvals' | 'agents.usage' | 'documents.video.understand' | 'documents.sync' | 'agents.inputs' | 'recall.parts' | 'agents.handoffs' | 'agents.parallel' | 'agents.runs' | 'agents.catalog' | 'agents.plans' | 'episodes.forget' | 'documents.ocr.tables' | 'documents.jobs' | 'documents.files' | 'graph.knowledge_walk' | 'documents.provenance' | 'recall.graph_boost' | 'graph.knowledge_seeds' | 'graph.knowledge_paging' | 'graph.timeline' | 'graph.sources' | 'graph.export' | 'graph.path' | 'graph.knowledge' | 'entities.read' | 'graph.report' | 'images.understand' | 'models.manage' | 'episodes.attachments' | 'episodes.list' | 'episodes.read' | 'facts.links' | 'integrity.read' | 'profile.read' | 'processing.distill' | 'processing.derive' | 'jobs.read' | 'recall.conditions'; export interface Capabilities { schema_version: 1; implementation: string; @@ -14,7 +14,7 @@ export function parseCapabilities(value: unknown): Capabilities { if (data.schema_version !== 1 || typeof data.implementation !== 'string' || !data.implementation.trim() || !data.features || typeof data.features !== 'object' || Array.isArray(data.features)) throw Error('Unsupported capability response'); const features = data.features as Record; - for(const key of ['agents.handoffs.output_requirements','agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ + for(const key of ['agents.approvals','agents.handoffs.output_requirements','agents.output_requirements','agents.output_schema','agents.usage','documents.video.understand','documents.sync','documents.ocr.tables','agents.inputs','recall.parts','agents.handoffs','agents.parallel','agents.runs','agents.catalog','agents.plans','episodes.forget','documents.jobs','documents.files','documents.provenance','processing.distill','processing.derive','graph.knowledge','entities.read','graph.report','graph.path','graph.export','graph.sources','graph.timeline','graph.knowledge_paging','graph.knowledge_seeds','graph.knowledge_walk','recall.graph_boost']){ if(features[key]!==undefined&&typeof features[key]!=='boolean')throw Error('Invalid processing capability flag'); } if (FEATURE_KEYS.some(key => typeof features[key] !== 'boolean')) throw Error('Incomplete or invalid capability flags'); @@ -28,5 +28,5 @@ export function parseCapabilities(value: unknown): Capabilities { if(features['recall.conditions']!==undefined&&typeof features['recall.conditions']!=='boolean')throw Error('Invalid recall conditions capability'); if(features['images.understand']!==undefined&&typeof features['images.understand']!=='boolean')throw Error('Invalid image understanding capability'); if(features['models.manage']!==undefined&&typeof features['models.manage']!=='boolean')throw Error('Invalid model management capability'); - return {schema_version:1, implementation:data.implementation, features:{'agents.handoffs.output_requirements':features['agents.handoffs.output_requirements']===true,'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; + return {schema_version:1, implementation:data.implementation, features:{'agents.approvals':features['agents.approvals']===true,'agents.handoffs.output_requirements':features['agents.handoffs.output_requirements']===true,'agents.output_requirements':features['agents.output_requirements']===true,'agents.output_schema':features['agents.output_schema']===true,'agents.usage':features['agents.usage']===true,'documents.video.understand':features['documents.video.understand']===true,'documents.sync':features['documents.sync']===true,'documents.ocr.tables':features['documents.ocr.tables']===true,'agents.inputs':features['agents.inputs']===true,'recall.parts':features['recall.parts']===true,'agents.handoffs':features['agents.handoffs']===true,'agents.parallel':features['agents.parallel']===true,'agents.runs':features['agents.runs']===true,'agents.catalog':features['agents.catalog']===true,'agents.plans':features['agents.plans']===true,'episodes.forget':features['episodes.forget']===true,'documents.jobs':features['documents.jobs']===true,'documents.files':features['documents.files']===true,'graph.knowledge_walk':features['graph.knowledge_walk']===true,'documents.provenance':features['documents.provenance']===true,'recall.graph_boost':features['recall.graph_boost']===true,'graph.knowledge_seeds':features['graph.knowledge_seeds']===true,'graph.knowledge_paging':features['graph.knowledge_paging']===true,'graph.timeline':features['graph.timeline']===true,'graph.sources':features['graph.sources']===true,'graph.export':features['graph.export']===true,'graph.path':features['graph.path']===true,'graph.report':features['graph.report']===true,'graph.knowledge':features['graph.knowledge']===true,'entities.read':features['entities.read']===true,...Object.fromEntries(FEATURE_KEYS.map(key => [key, features[key]])), 'episodes.attachments':features['episodes.attachments'] === true, 'episodes.list':features['episodes.list'] === true, 'episodes.read':features['episodes.read'] === true, 'facts.links':features['facts.links'] === true, 'integrity.read':features['integrity.read'] === true, 'profile.read':features['profile.read'] === true, 'processing.distill':features['processing.distill']===true, 'processing.derive':features['processing.derive']===true, 'jobs.read':features['jobs.read']===true, 'recall.conditions':features['recall.conditions']===true, 'models.manage':features['models.manage']===true, 'images.understand':features['images.understand']===true} as Capabilities['features']}; } diff --git a/tests/agent-approval-boundaries.test.ts b/tests/agent-approval-boundaries.test.ts index e29b03e..609d1bc 100644 --- a/tests/agent-approval-boundaries.test.ts +++ b/tests/agent-approval-boundaries.test.ts @@ -29,3 +29,11 @@ test('selected activation acknowledgement binds IDs, revisions, hashes and space const activation={space:'alpha',run_id:'one',activation_id:'next',created_at:pending.created_at,decisions:{[record.request_id]:2},decision_digests:{[record.request_id]:record.decision_digest}}; for(const mutation of [{decisions:{[record.request_id]:true}},{decision_digests:{[record.request_id]:'f'.repeat(64)}},{space:'bravo'},{decisions:{[record.request_id]:2,extra:2}},{decision_digests:{[record.request_id]:record.decision_digest,extra:'f'.repeat(64)}}])assert.throws(()=>matchContinuation({status,activation:{...activation,...mutation}},request,expected,[record])); }); + +test('argument display reveals directional controls without changing approved bytes',async()=>{ + const {displayArguments}=await import('../src/agents/approval-json.ts'); + const raw='{"destination":"\u202emoc.elpmaxe@nimda\u202c"}'; + assert.equal(displayArguments(raw),'{"destination":"\\u202emoc.elpmaxe@nimda\\u202c"}'); + assert.equal(literalArguments(raw),raw); + assert.equal(displayArguments('{"count":1208925819614629174706176,"word":"שלום"}'),'{"count":1208925819614629174706176,"word":"שלום"}'); +});