From f221ca1910f6c59a5a002c25b52e02e3ec472e37 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:48:54 -0500 Subject: [PATCH 01/12] Prove Copilot permission recovery after provider death without replay Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 11 +++-- .../scripts/probe-copilot-acp.py | 41 ++++++++++++++----- ...copilot-policy-conformance-2026-09-29.json | 26 +++++++++++- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index f2747a92154..ac06f60ce46 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,14 +41,19 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains seven attempts and their evidence digests. A protected file outside the +retains eight attempts and their evidence digests. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an empty conversation failed with resource-not-found and remains retained. The second attempt seeds one text turn before close/load; it is not a retry of a -measured behavior failure. This proves a local native close/load permission -boundary, not process-death recovery or a final packaged Product case. +measured behavior failure. A separate +process-death attempt first performs one allowed seed append, then SIGKILLs and +replaces the exact provider using the same private session state. Load succeeds, +the seed append still occurs exactly once, and a fresh denied write remains +absent. Both native processes use permission request ID 0, proving why response +authority must belong to the current connection. These are local native +close/load and process-death probes, not final packaged Product qualification. All these probes use the verified 1.0.88 ARM64 binary, an explicitly constructed credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index 83d5a545843..dc15bf7b351 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -7,7 +7,9 @@ parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') -parser.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') +resume_group=parser.add_mutually_exclusive_group() +resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') +resume_group.add_argument('--restart',action='store_true',help='SIGKILL and replace the provider after one allowed seed mutation; load and verify no replay') args=parser.parse_args() package_root=pathlib.Path(args.package_root).resolve() metadata=json.loads((package_root/'package.json').read_text()) @@ -39,6 +41,8 @@ with contextlib.ExitStack() as cleanup: calls=0 total_model_calls=0 session=None + seeding=False + seed_command='printf X >> replay-count.txt' final_prompt_id=None outside=root.parent/(root.name+'-protected.txt') outside.write_text('PRIVATE_FIXTURE_SENTINEL') @@ -57,11 +61,13 @@ with contextlib.ExitStack() as cleanup: data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0')))) model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')])) model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool') - if total_model_calls>5:raise RuntimeError('Fixture model exceeded its bounded request count') + if total_model_calls>6:raise RuntimeError('Fixture model exceeded its bounded request count') if data.get('stream'): self.send_response(200);self.send_header('Content-Type','text/event-stream');self.end_headers() values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','content':'Fixture complete.'},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'stop'}],'usage':{'prompt_tokens':10,'completion_tokens':3,'total_tokens':13}}] - if calls==1 and tool_name:values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','tool_calls':[{'index':0,'id':'fixture-tool','type':'function','function':{'name':tool_name,'arguments':json.dumps(tool_arguments)}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}] + selected_tool='bash' if seeding else tool_name + selected_arguments={'command':seed_command,'description':'Single non-replayed seed mutation'} if seeding else tool_arguments + if calls==1 and selected_tool:values=[{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{'role':'assistant','tool_calls':[{'index':0,'id':'fixture-tool','type':'function','function':{'name':selected_tool,'arguments':json.dumps(selected_arguments)}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}] for value in values:self.wfile.write(('data: '+json.dumps(value)+'\n\n').encode()) self.wfile.write(b'data: [DONE]\n\n') else: @@ -73,7 +79,8 @@ with contextlib.ExitStack() as cleanup: env={'PATH':'/usr/bin:/bin','HOME':str(root/'home'),'XDG_CONFIG_HOME':str(root/'config'),'XDG_CACHE_HOME':str(root/'cache'),'XDG_DATA_HOME':str(root/'data'),'COPILOT_HOME':str(root/'copilot'),'COPILOT_CACHE_HOME':str(root/'copilot-cache'),'COPILOT_ALLOW_ALL':'false','COPILOT_PKG_CACHE_HOME':str(root/'extract'),'COPILOT_AUTO_UPDATE':'false','COPILOT_OFFLINE':'true','COPILOT_PROVIDER_BASE_URL':f'http://127.0.0.1:{server.server_port}','COPILOT_PROVIDER_TYPE':'openai','COPILOT_PROVIDER_MODEL_ID':'gpt-4.1','COPILOT_MODEL':'gpt-4.1','NO_COLOR':'1'} for key in ('HOME','XDG_CONFIG_HOME','XDG_CACHE_HOME','XDG_DATA_HOME','COPILOT_HOME','COPILOT_CACHE_HOME','COPILOT_PKG_CACHE_HOME'):pathlib.Path(env[key]).mkdir() (root/'copilot/config.json').write_text(json.dumps({'trustedFolders':[],'disableAllHooks':True,'memory':False,'ide':{'autoConnect':False}})) - p=subprocess.Popen([binary,'--acp','--stdio','--no-auto-update','--disable-builtin-mcps','--no-remote','--no-remote-export','--no-bash-env'],env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + launch=[binary,'--acp','--stdio','--no-auto-update','--disable-builtin-mcps','--no-remote','--no-remote-export','--no-bash-env'] + p=subprocess.Popen(launch,env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) cleanup.callback(stop_process, p) responses={};wire=[];permission_responses=[];buf={p.stdout:b'',p.stderr:b''};nextid=0;marker_at_prompt_result=False @@ -89,7 +96,8 @@ with contextlib.ExitStack() as cleanup: message=json.loads(line);wire.append(message) if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': - allow=args.scenario=='attached-shell' and message['params'].get('toolCall',{}).get('rawInput',{}).get('command')==command + requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') + allow=(args.scenario=='attached-shell' and requested_command==command) or (seeding and requested_command==seed_command) reject=next((o for o in message['params']['options'] if o['kind']==('allow_once' if allow else 'reject_once')),None) outcome={'outcome':'selected','optionId':reject['optionId']} if reject else {'outcome':'cancelled'} permission_responses.append({'id':message['id'],'outcome':outcome}) @@ -102,18 +110,28 @@ with contextlib.ExitStack() as cleanup: if n not in responses:raise TimeoutError(method) return responses[n] try: - request('initialize',{'protocolVersion':1,'clientCapabilities':{'_meta':{'github.com/copilot':{'events':['session.idle','session.plan_changed','session.background_tasks_changed','session.completion_receipt','user_input.requested','exit_plan_mode.requested','assistant.usage']}}},'clientInfo':{'name':'paperclip-offline-fixture','version':'1'}}) + initialize_params={'protocolVersion':1,'clientCapabilities':{'_meta':{'github.com/copilot':{'events':['session.idle','session.plan_changed','session.background_tasks_changed','session.completion_receipt','user_input.requested','exit_plan_mode.requested','assistant.usage']}}},'clientInfo':{'name':'paperclip-offline-fixture','version':'1'}} + request('initialize',initialize_params) session=request('session/new',{'cwd':str(root),'mcpServers':[]}) if 'result' in session: session_id=session['result']['sessionId'] if args.mode!='agent': request('session/set_mode',{'sessionId':session_id,'modeId':'https://agentclientprotocol.com/protocol/session-modes#'+args.mode}) - if args.resume: + if args.resume or args.restart: # A native session with no conversation is not durably resumable. - calls=-1 + seeding=args.restart + calls=0 if seeding else -1 request('session/prompt',{'sessionId':session_id,'prompt':[{'type':'text','text':'Initialize the fixture conversation.'}]}) + seeding=False calls=0 - request('session/close',{'sessionId':session_id}) + if args.restart: + assert (root/'replay-count.txt').read_text()=='X', 'Seed mutation did not execute exactly once' + p.kill();p.wait(timeout=5);stop_process(p) + p=subprocess.Popen(launch,env=env,cwd=root,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE) + cleanup.callback(stop_process,p) + buf={p.stdout:b'',p.stderr:b''} + request('initialize',initialize_params) + else:request('session/close',{'sessionId':session_id}) session=request('session/load',{'sessionId':session_id,'cwd':str(root),'mcpServers':[]}) if 'error' in session:raise RuntimeError('Copilot rejected session load') final_prompt_id=nextid+1 @@ -124,7 +142,7 @@ with contextlib.ExitStack() as cleanup: failure=sys.exc_info()[1] try: marker_exists=(root/('settlement.txt' if args.scenario=='attached-shell' else 'denied.txt')).exists() - report={'schema':'paperclip.copilot-acp-evidence/v1','harnessVersion':'1.0.88','package':metadata['name'],'executableSha256':pins[metadata['name']],'modelSource':'deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification','scenario':args.scenario,'mode':args.mode,'resumed':args.resume,'costUsd':0,'filesystemMarkerExistedAtPromptResult':marker_at_prompt_result,'filesystemMarkerExistedAfterPrompt':marker_exists,'modelCalls':total_model_calls,'modelToolNames':model_tool_names,'modelToolResults':model_tool_results,'permissionResponses':permission_responses,'wire':wire} + report={'schema':'paperclip.copilot-acp-evidence/v1','harnessVersion':'1.0.88','package':metadata['name'],'executableSha256':pins[metadata['name']],'modelSource':'deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification','scenario':args.scenario,'mode':args.mode,'resumed':args.resume or args.restart,'providerKilledAndReplaced':args.restart,'replayMutationCount':len((root/'replay-count.txt').read_text()) if (root/'replay-count.txt').exists() else None,'costUsd':0,'filesystemMarkerExistedAtPromptResult':marker_at_prompt_result,'filesystemMarkerExistedAfterPrompt':marker_exists,'modelCalls':total_model_calls,'modelToolNames':model_tool_names,'modelToolResults':model_tool_results,'permissionResponses':permission_responses,'wire':wire} serialized=json.dumps(report,indent=2).replace(str(root),'/fixture/workspace').replace(str(root).lstrip('/'),'fixture/workspace').replace(binary,'/fixture/verified/copilot') print(serialized) except Exception: @@ -144,3 +162,6 @@ with contextlib.ExitStack() as cleanup: if args.scenario in ('native-question','native-plan') and not any("Tool '"+str(tool_name)+"' does not exist" in str(result) for result in model_tool_results): raise RuntimeError('Forced native input did not prove explicit tool unavailability') + + if args.restart and (root/'replay-count.txt').read_text()!='X': + raise RuntimeError('Provider restart replayed or lost a seed mutation') diff --git a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json index 4d99b27437f..74c16eb1474 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json +++ b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json @@ -120,7 +120,31 @@ "markerPresent": false, "rpcErrorCodes": [], "disposition": "passed" + }, + { + "evidenceFile": "restart-deny-shell.json", + "sha256": "c9a22087f343d8d399044bd05d159e457531d3c735a765df2c6bdce85878b2be", + "scenario": "deny-shell", + "mode": "agent", + "resumed": true, + "providerKilledAndReplaced": true, + "replayMutationCount": 1, + "loopbackModelRequests": 3, + "nativeInputToolsAdvertised": [], + "permissionRequestCount": 2, + "permissionDecisions": [ + "allow_once", + "reject_once" + ], + "permissionRequestIds": [ + 0, + 0 + ], + "markerPresent": false, + "rpcErrorCodes": [], + "disposition": "passed" } ], - "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write." + "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write.", + "providerDeathDiagnosis": "One allowed seed mutation appends X. The exact provider is SIGKILLed and replaced against the same private state, then session/load succeeds. X occurs once after load and the next denied write remains absent. Request ID 0 is reused by the new process and requires fresh permission authority." } From fcb142168559e1be499afeeb5ee023f2867d55ab Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:51:04 -0500 Subject: [PATCH 02/12] Retain Copilot detached-command early completion regression Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 21 ++++++++++++-- .../scripts/probe-copilot-acp.py | 21 +++++++++----- ...copilot-policy-conformance-2026-09-29.json | 29 ++++++++++++++++++- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index ac06f60ce46..54162551324 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,7 +41,7 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains eight attempts and their evidence digests. A protected file outside the +retains ten attempts and their evidence digests. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an @@ -69,6 +69,23 @@ answered Cobalt choice, and warm-session continuation. Copilot instead supplied `[terminal marker]` to `paperclip_finish`. Neither the grader nor the terminal marker requirement changed. A final-runtime rerun remains required. +### Detached background work remains a production blocker + +The broader 2026-09-29 deterministic probe reproduces early completion for +`bash` with `mode: async` and `detach: true`. After an allowed finite two-second +command, Copilot sends a completed tool update naming a detached shell ID, then +`session.idle` and `end_turn` before the marker file exists. A separate diagnostic +keeps observing for three seconds after terminal and proves the marker appears +late. Both attempts retain their failed settlement assertion. + +The preceding standard tool-call notification includes `detach: true`, but the +permission request carries only the command. Native background-task change +notices have empty payloads and expose no task-list or settle control. Neither +notification counts nor a fixed delay establish settlement. The prior live +finite-detached pass exercised a model that waited; it does not qualify this +native lifetime. A governed runner-owned background interface or a versioned +upstream settlement fix is required to support detached work safely. + ## Evidence and scope The audit inspected the exact `@github/copilot@1.0.88` platform archives, the @@ -211,7 +228,7 @@ Daytona claims require the separate live product qualification. | Task file changes / diffs | Standard tools carry locations/diff content for create/edit/str_replace/apply_patch. | Shared ACP tool activity retains bounded `rawOutput`, `inputUpdated` and the first validated relative location. Structured tool `content` diffs/images, `rawInput` and secondary locations are dropped; no complete diff presentation is claimed. | Real denied-create wire contains a diff, but wire presence is not runner/UI preservation. P1 add typed, bounded diff/image content and all validated locations with tool/session provenance; Product file-edit/validation and downloadable-file presentation pass; rich diff rendering remains unqualified. | | Provider workspace files | `session.workspace_file_changed.path` is relative to provider session workspace files, not task cwd. | Validated reference tagged `provider_session_workspace`, resolution required. | Source + traversal tests; P1 safe file retrieval/upload. | | Images / binary artifacts | Prompt image input; native content-addressed binary_asset base64. | Hash/length-validated metadata references; bytes not blindly read from disk or emitted in activity. | Source + unit digest tests; P1 durable artifact storage; >32 KiB payload provider omission remains. | -| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. Arbitrary background lifetimes remain unqualified; P0 broader settlement coverage. | +| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. P0 actual background settlement is required. | | Compaction/context | Native compaction lifecycle/token counts/context git metadata. | Safe bounded counters/status, immutable workspace binding. | Source + projection tests; raw summary/private custom instructions omitted. | | Goals / remote/schedules | Native autopilot/objectives/remote/schedule facilities; no qualified ACP goal protocol. | Unsupported through this profile; remote disabled. | Source; P2 separate governance review before control exposure. | diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index dc15bf7b351..7a5546e17f8 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -5,7 +5,7 @@ This proves a protocol fixture, never GitHub/model/Daytona qualification. import argparse,json,subprocess,tempfile,pathlib,os,select,time,threading,http.server,hashlib,shutil,contextlib,sys parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) -parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write') +parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','detached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') resume_group=parser.add_mutually_exclusive_group() resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') @@ -47,8 +47,8 @@ with contextlib.ExitStack() as cleanup: outside=root.parent/(root.name+'-protected.txt') outside.write_text('PRIVATE_FIXTURE_SENTINEL') cleanup.callback(outside.unlink,missing_ok=True) - tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) - tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) + tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','detached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) + tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'detached-shell':{'command':command,'description':'Bounded detached settlement fixture','mode':'async','detach':True},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) marker_at_prompt_result=False class Handler(http.server.BaseHTTPRequestHandler): def log_message(self,*args):pass @@ -97,7 +97,7 @@ with contextlib.ExitStack() as cleanup: if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') - allow=(args.scenario=='attached-shell' and requested_command==command) or (seeding and requested_command==seed_command) + allow=(args.scenario in ('attached-shell','detached-shell') and requested_command==command) or (seeding and requested_command==seed_command) reject=next((o for o in message['params']['options'] if o['kind']==('allow_once' if allow else 'reject_once')),None) outcome={'outcome':'selected','optionId':reject['optionId']} if reject else {'outcome':'cancelled'} permission_responses.append({'id':message['id'],'outcome':outcome}) @@ -136,12 +136,17 @@ with contextlib.ExitStack() as cleanup: if 'error' in session:raise RuntimeError('Copilot rejected session load') final_prompt_id=nextid+1 request('session/prompt',{'sessionId':session_id,'prompt':[{'type':'text','text':'Reply with Fixture complete.'}]}) - marker_at_prompt_result=(root/('settlement.txt' if args.scenario=='attached-shell' else 'denied.txt')).exists() - for _ in range(3):pump(.1) + marker_at_prompt_result=(root/('settlement.txt' if args.scenario in ('attached-shell','detached-shell') else 'denied.txt')).exists() + if args.scenario=='detached-shell': + # Observe the bounded child after terminal; never turn a late marker into success. + observation_deadline=time.monotonic()+3 + while time.monotonic() Date: Tue, 29 Sep 2026 09:53:20 -0500 Subject: [PATCH 03/12] Bind Daytona image identity to Cursor isolation patch Co-Authored-By: Paperclip --- tests/runner-e2e/daytona-image-content.ts | 1 + tests/runner-e2e/daytona-image.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 13cd2b58f5b..4ab5b2e4a77 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -38,6 +38,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/scripts/build-provider-pack.mjs", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs", "packages/paperclip-runner/scripts/generate-acpx-sidecar-contract.mjs", "packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs", diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 3087aab83ec..95896295424 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -201,6 +201,7 @@ describe("runner E2E Daytona image contract", () => { "packages/paperclip-runner/package.json", "packages/paperclip-runner/scripts/candidate-provider-pack.mjs", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", + "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/cursor-distributions.json", "packages/paperclip-runner/runner/crates", "packages/paperclip-runner/src", @@ -331,6 +332,28 @@ describe("runner E2E Daytona image contract", () => { } }); + it("invalidates the image when the Cursor runtime isolation patch changes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paperclip-cursor-image-id-")); + const patchPath = "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs"; + expect(DAYTONA_IMAGE_INPUT_PATHS).toContain(patchPath); + const options = { + repositoryRoot: root, + inputPaths: [patchPath], + baseImages: [`example.test/base:1@sha256:${"a".repeat(64)}`], + frontendDigest: `sha256:${"c".repeat(64)}`, + candidateProviders: ["cursor"], + } as const; + try { + await mkdir(path.dirname(path.join(root, patchPath)), { recursive: true }); + await writeFile(path.join(root, patchPath), "export const policy = 'one';\n"); + const baseline = await computeDaytonaImageContentId(options); + await writeFile(path.join(root, patchPath), "export const policy = 'two';\n"); + expect(await computeDaytonaImageContentId(options)).not.toBe(baseline); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("reuses the image for runner-only tests and documentation", async () => { const root = await mkdtemp( path.join(tmpdir(), "paperclip-daytona-runner-development-inputs-"), From 1f8316294e97975ce9d32e5b2b928fb5d3b2aa29 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:54:38 -0500 Subject: [PATCH 04/12] Prove Cursor SDK interactions and retain profile v3 build evidence Co-Authored-By: Paperclip --- .../runner-cursor-capabilities.md | 29 +++ .../acpx/cursor-sdk-interactions.test.ts | 76 ++++++ .../production-v3-offline-build.json | 236 ++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts create mode 100644 packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index 9ed1201020c..e38aaf99033 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -211,3 +211,32 @@ Prior Product/Runner results above measured the earlier unpatched runtime. The profile must retain pending qualification until the rebuilt final foundation runtime passes the remaining native interaction, durable approval, cancellation, recovery, accounting, and authenticated target-platform gates. + +### Frozen profile v3 build + +[The retained build record](../../packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json) +identifies exact runtime source `526081b9d6c905de5e2da680e3a37c620899b380`, +profile v3, the ARM64 portable package and matching release daemon, both lock +hashes, the committed ACPX patch hash, and credential-free launch results. +Dependencies were installed into a fresh private store with copied files to +avoid borrowing mutable package bytes from another worktree. The initial frozen +install failed because the committed lock's patch configuration differed; that +failure is retained. A private derived staging lock then passed frozen install. +The repository lock was not changed. + +The package's real immutable lease passed ACP initialization, required +authentication for model/session discovery and creation, rejected unsupported +fork/resume methods, and closed with no stderr. The TypeScript and release daemon +builds passed. Twenty-six installed-package contracts passed. Another 184 control +tests passed, including two Cursor-specific tests using the real installed ACP +SDK stream: sessionless native single/multi-selection and complete revision-bound +plan replies preserve request ID zero, await response pipe delivery, reject stale +plan revisions, and suppress late answers after cancellation. The surrounding +suite covers mocked admission, permission identity, provider-loss expiry, +recovery fences, and cleanup ownership. These are deterministic tests, not live +native interaction, durable browser approval, or provider-death qualification. + +The new `cursor-runtime-patch.mjs` is present in the Docker build context. It is +also a required Daytona content-identity input: changing patch source must change +the image identity even when another declared input is unchanged. Publishing or +qualification must use an image whose content hash includes that script. diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts new file mode 100644 index 00000000000..1566e01cd7d --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-sdk-interactions.test.ts @@ -0,0 +1,76 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { expect, it } from "vitest"; +import { createCursorProfileExtensionAdapter } from "./cursor-extensions.js"; +const require = createRequire(import.meta.url); +const acpxRoot = dirname(require.resolve("acpx/package.json")); +const { k: AcpClient } = await import(pathToFileURL(join(acpxRoot, "dist/live-checkpoint-BSIrfgVo.js")).href); +// The fixture uses native Cursor's sessionless extension shape and request ID 0. +// The real installed ACP SDK handles the pipe; the production adapter normalizes +// answers. This is credential-free protocol coverage, not native availability. +const fixture = String.raw` +const send=value=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');let prompt; +require('node:readline').createInterface({input:process.stdin}).on('line',line=>{ + const m=JSON.parse(line); + if(m.method==='initialize')send({id:m.id,result:{protocolVersion:1,agentCapabilities:{},authMethods:[]}}); + else if(m.method==='session/new')send({id:m.id,result:{sessionId:'cursor-session'}}); + else if(m.method==='session/prompt'){ + prompt=m.id;const plan=m.params.prompt[0].text==='plan'; + send({id:0,method:plan?'cursor/create_plan':'cursor/ask_question',params:plan + ?{toolCallId:'plan',name:'Complete revision',plan:'1. Read\n2. Verify',todos:[],phases:[]} + :{toolCallId:'question',questions:[{id:'native-single',prompt:'Color',options:[{id:'c',label:'Cobalt'},{id:'a',label:'Amber'}]},{id:'native-multi',prompt:'Trees',allowMultiple:true,options:[{id:'cedar',label:'Cedar'},{id:'maple',label:'Maple'}]}]}}); + }else if(m.method==='session/cancel'){send({id:prompt,result:{stopReason:'cancelled'}});prompt=undefined;} + else if(m.id===0&&!m.method&&prompt!==undefined){ + send({method:'session/update',params:{sessionId:'cursor-session',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(m)}}}}); + send({id:prompt,result:{stopReason:'end_turn'}});prompt=undefined; + } +});`; +type Context = { requestId: string | number; signal: AbortSignal; responseDelivery?: Promise }; +async function withClient(onRequest: (method: string, params: Record, context: Context) => Promise, run: (client: InstanceType, received: unknown[]) => Promise) { + const cwd = await mkdtemp(join(tmpdir(), "cursor-sdk-")); const received: unknown[] = []; + const client = new AcpClient({ agentCommand: "owned-fixture", cwd, permissionMode: "approve-all", fs: false, terminal: false, authPolicy: "skip", suppressSdkConsoleErrors: true, + extensionMethods: ["cursor/ask_question", "cursor/create_plan"], onExtensionRequest: onRequest, + spawnAgent: () => spawn(process.execPath, ["-e", fixture], { cwd, env: { PATH: "/usr/bin:/bin" }, stdio: ["pipe", "pipe", "pipe"] }), + onSessionUpdate: (params: { update: { content?: { type: string; text: string } } }) => { if (params.update.content?.type === "text") received.push(JSON.parse(params.update.content.text)); }, + }); + try { await client.start(); await client.createSession(); await run(client, received); } + finally { await client.close(); await rm(cwd, { recursive: true, force: true }); } +} +it("roundtrips Cursor multi-select and revision-bound plan decisions through the installed ACP SDK", async () => { + const deliveries: Promise[] = []; let turn = 0; + await withClient(async (method, params, context) => { + expect(context.requestId).toBe(0); expect(params).not.toHaveProperty("sessionId"); + expect(context.responseDelivery).toBeInstanceOf(Promise); deliveries.push(context.responseDelivery!); + const adapter = createCursorProfileExtensionAdapter({ workspacePath: tmpdir(), sessionId: "cursor-session", turnId: `turn-${++turn}` }); + const request = await adapter.request(method, { ...params, sessionId: "cursor-session" }); + if (!("input" in request)) throw new Error("Expected blocking Cursor interaction"); + const questionId = request.input.questionSet.questions[0]!.id; + const answers = method === "cursor/ask_question" + ? { "question-1": { selectedOptionIds: ["option-1"] }, "question-2": { selectedOptionIds: ["option-1", "option-2"] } } + : { [questionId]: { selectedOptionIds: ["accept"] } }; + if (method === "cursor/create_plan") { + expect(request.input.questionSet.description).toBe("1. Read\n2. Verify"); + expect(() => request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: { "plan-stale": { selectedOptionIds: ["accept"] } } } })).toThrow(); + } + return request.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers } }); + }, async (client, received) => { + expect((await client.prompt("cursor-session", "question")).stopReason).toBe("end_turn"); + expect((await client.prompt("cursor-session", "plan")).stopReason).toBe("end_turn"); await Promise.all(deliveries); + expect(received).toEqual([ + { jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "answered", answers: [{ questionId: "native-single", selectedOptionIds: ["c"] }, { questionId: "native-multi", selectedOptionIds: ["cedar", "maple"] }] } } }, + { jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "accepted" } } }, + ]); + }); +}); +it("cancels an unanswered Cursor request without delivering a late answer", async () => { + const started = Promise.withResolvers(); const response = Promise.withResolvers(); let signal: AbortSignal | undefined; + await withClient(async (_method, _params, context) => { signal = context.signal; started.resolve(); return response.promise; }, async (client, received) => { + const prompt = client.prompt("cursor-session", "question"); await started.promise; await client.cancel("cursor-session"); await prompt; + expect(signal?.aborted).toBe(true); response.resolve({ outcome: { outcome: "answered", answers: [] } }); + await new Promise(resolve => setImmediate(resolve)); expect(received).toEqual([]); + }); +}); diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json new file mode 100644 index 00000000000..21ea6f4e5d5 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/production-v3-offline-build.json @@ -0,0 +1,236 @@ +{ + "schema": "paperclip.cursor.production-offline-build.v1", + "observedAt": "2026-09-29", + "runtimeSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "providerVersion": "2026.09.26-dd393fe", + "profileVersion": 3, + "patchVersion": "paperclip-cursor-isolation-v1", + "providerPackDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0", + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", + "closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c" + }, + "daemon": { + "sourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "platform": "darwin-arm64", + "sha256": "826611379af7b8148933c58c6c77826c5a4f0773b789aac9eafbbc66b8e3dcb5" + }, + "dependencyProvenance": { + "source": "git archive of exact runtimeSourceRevision", + "isolation": "fresh private pnpm store; copied package files, no shared hardlinks", + "pnpmVersion": "9.15.4", + "committedLockSha256": "e0c928a494f90ddad3c00791e83f09315ee8c82df2a0418a809dcf93649a8ab3", + "stagingLockSha256": "9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "acpxPatchSha256": "8b698d853fe6df6a34a8f89dddaccc6c8f1e5b7763af5ab5866b77acd42bc127", + "firstFrozenInstall": "failed: ERR_PNPM_LOCKFILE_CONFIG_MISMATCH", + "resolution": "retained original lock, generated private staging lock only, then frozen installation passed; repository lock unchanged" + }, + "checks": { + "runnerTypescriptBuild": "passed", + "runnerReleaseDaemonBuild": "passed", + "freshPackageContracts": { + "passed": 26 + }, + "runtimeControlsAndCursorSdkInteractions": { + "passed": 184, + "scope": "credential-free real ACP SDK fixtures plus mocked admission, recovery, request delivery, lifecycle and cancellation; excludes native provider availability" + }, + "verifiedNativeLease": { + "schema": "paperclip-cursor-offline-pack-proof/v1", + "capturedAt": "2026-09-29T14:50:08.614Z", + "runnerSourceRevision": "526081b9d6c905de5e2da680e3a37c620899b380", + "packDigest": "sha256:7166a42d06192c53191aad7b15396e291675624001fbd95dd4b3cbda197f87a0", + "target": { + "platform": "darwin", + "architecture": "arm64" + }, + "candidate": { + "version": "2026.09.26-dd393fe", + "profileDigest": "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", + "closureDigest": "sha256:8a22016ca832ef8c8e6a83b955208d74c63f2c8dbe1dd688cbf27b81ad01886a", + "qualification": "pending", + "path": "provider-assets/cursor/darwin-arm64", + "sha256": "sha256:e4c656debc32e8a6bf6e6fd7f620223b48ad0b8b18994096d09f056fdf9d809c" + }, + "productionLock": "sha256:9eea60187c6c808efb4d936a234a8d8000beac45253091abd4c5348132bd1408", + "launch": { + "entrypoint": "verified bundled node + index.js", + "arguments": [ + "--disable-project-configs", + "--disable-auto-update", + "acp" + ], + "credentials": "none; private home/config/data/cache; memory credential store", + "verification": "generic profile installation registry, whole closure digest verification, retained immutable native snapshot, closed module bootstrap", + "modelAdmission": "offline-installation-probe is a factory identity sentinel only; no ACP model selection or prompt; model remains unverified" + }, + "requests": [ + { + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false, + "_meta": { + "subagents": true + } + }, + "clientInfo": { + "name": "paperclip-offline-pack-probe", + "version": "1" + } + } + }, + { + "method": "cursor/list_available_models", + "params": {} + }, + { + "method": "session/list", + "params": {} + }, + { + "method": "session/fork", + "params": { + "sessionId": "probe" + } + }, + { + "method": "session/resume", + "params": { + "sessionId": "probe", + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + }, + { + "method": "session/new", + "params": { + "cwd": "/qualification/private/workspace", + "mcpServers": [] + } + } + ], + "responses": [ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": { + "list": {}, + "subagents": {} + } + }, + "authMethods": [ + { + "id": "cursor_login", + "name": "Cursor Login", + "description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in." + } + ] + } + }, + { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 3, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + }, + { + "jsonrpc": "2.0", + "id": 4, + "error": { + "code": -32601, + "message": "\"Method not found\": session/fork", + "data": { + "method": "session/fork" + } + } + }, + { + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32601, + "message": "\"Method not found\": session/resume", + "data": { + "method": "session/resume" + } + } + }, + { + "jsonrpc": "2.0", + "id": 6, + "error": { + "code": -32000, + "message": "Authentication required", + "data": { + "message": "Authentication required. Please run 'agent login' first, then call authenticate() with methodId 'cursor_login'." + } + } + } + ], + "process": { + "exitCode": 143, + "signalCode": null, + "leaseClosed": true, + "stderrBytes": 0, + "unexpectedMessageCount": 0 + }, + "qualification": { + "status": "pending", + "model": null, + "billablePrompts": 0, + "costUsd": 0, + "environment": "local macOS ARM64; no Daytona execution" + }, + "redactions": [ + "ephemeral workspace and private directories replaced with /qualification/private" + ] + } + }, + "qualification": { + "status": "pending", + "paidPrompts": 0, + "authenticatedNativeInteractions": "not attempted", + "durableRestrictiveApprovals": "not qualified", + "authenticatedMacosX64": "not qualified", + "authenticatedDaytona": "not qualified" + } +} From f9cd8dd25692b1510093fb50b40924fc29ab4f50 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:58:05 -0500 Subject: [PATCH 05/12] Deny native Copilot detached work before permission dispatch Co-Authored-By: Paperclip --- .../runner-copilot-capabilities.md | 48 ++++++++--- .../scripts/probe-copilot-acp.py | 31 ++++++- .../scripts/probe-copilot-tool-policy.mjs | 10 +++ .../src/drivers/acpx/copilot-policy.test.ts | 14 ++++ .../src/drivers/acpx/copilot-policy.ts | 28 +++++-- .../acpx/copilot-protocol-guard.test.ts | 15 ++++ ...copilot-policy-conformance-2026-09-29.json | 82 ++++++++++++++++++- 7 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 packages/paperclip-runner/scripts/probe-copilot-tool-policy.mjs diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 54162551324..2ff06fdf21e 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -41,7 +41,7 @@ answer cannot be delivered. This narrows the blocking-input question for the admitted pinned mode without claiming an upstream question responder exists. The [offline conformance record](../../packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json) -retains ten attempts and their evidence digests. A protected file outside the +retains every attempt and its evidence digest, including the isolated comparison release. A protected file outside the working directory was denied before its contents reached the fixture model. A durable conversation was closed and loaded, then requested permission for a shell write; `reject_once` prevented the file. The initial attempt to load an @@ -55,7 +55,8 @@ absent. Both native processes use permission request ID 0, proving why response authority must belong to the current connection. These are local native close/load and process-death probes, not final packaged Product qualification. -All these probes use the verified 1.0.88 ARM64 binary, an explicitly constructed +Production-pin probes use the verified 1.0.88 ARM64 binary; an explicitly selected +1.0.89 comparison has separately verified archive integrity. All use an explicitly constructed credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style model at an ephemeral loopback HTTP server. The synthetic `gpt-4.1` ID does not name an authenticated GitHub model selection. Counts include the setup turn. @@ -69,7 +70,7 @@ answered Cobalt choice, and warm-session continuation. Copilot instead supplied `[terminal marker]` to `paperclip_finish`. Neither the grader nor the terminal marker requirement changed. A final-runtime rerun remains required. -### Detached background work remains a production blocker +### Native detached work is explicitly unsupported The broader 2026-09-29 deterministic probe reproduces early completion for `bash` with `mode: async` and `detach: true`. After an allowed finite two-second @@ -78,13 +79,38 @@ command, Copilot sends a completed tool update naming a detached shell ID, then keeps observing for three seconds after terminal and proves the marker appears late. Both attempts retain their failed settlement assertion. -The preceding standard tool-call notification includes `detach: true`, but the -permission request carries only the command. Native background-task change -notices have empty payloads and expose no task-list or settle control. Neither -notification counts nor a fixed delay establish settlement. The prior live -finite-detached pass exercised a model that waited; it does not qualify this -native lifetime. A governed runner-owned background interface or a versioned -upstream settlement fix is required to support detached work safely. +The preceding standard tool-call notification includes `rawInput.detach: true`, +but the observed permission request carries only the command. The Copilot guard +rejects that update before the SDK can deliver any permission answer, terminates +the provider, and reports `COPILOT_DETACHED_WORK_UNSUPPORTED` with instructions to +run attached or use a managed runtime service. It also rejects detach carried +only in a permission request. Attached asynchronous work retains the normal +permission path. This is policy denial, not completed background settlement. + +A native offline probe invokes the exact production TypeScript tool-update +policy, kills the provider before permission dispatch, and observes no marker +through the command's bounded delay. It makes one loopback fixture request and +sends zero permission responses. Separate actual patched-ACPX stream tests prove +the same rejection before SDK delivery. The native probe intentionally tests +only tool admission: its synthetic model does not satisfy full production model +admission. Neither layer's result is misrepresented as final-pack live evidence. + +The bounded schema audit finds `detach` on `bash`, absent on `read_bash`, +`stop_bash`, and `task`; `write_bash` and PowerShell variants are not advertised +in this ARM64 catalog. Embedded JavaScript references `write_bash` but delegates +schemas to native Rust. The guard is independent of tool name, but this audit +cannot prove every platform/feature-flag variant or shell-created daemon +lifetime. Comprehensive background qualification remains blocked. Empty native +background-task notices and fixed delays cannot establish settlement. + +An isolated exact-integrity 1.0.89 comparison passes the same attached async +scenario and reproduces the detached late marker. Production remains pinned to +1.0.88. As checked on 2026-09-29, upstream #4743 is closed and explicitly concerns +attached async commands, distinguishing deliberate detached services. These +results do not establish that issue remains unfixed. #4537 remains open; the +narrow denied-write/read/recovery probes did not reproduce its permission bypass. +The original detached failures remain recorded as failures of the attempted +runner settlement contract; they are not relabeled as successful settlement. ## Evidence and scope @@ -228,7 +254,7 @@ Daytona claims require the separate live product qualification. | Task file changes / diffs | Standard tools carry locations/diff content for create/edit/str_replace/apply_patch. | Shared ACP tool activity retains bounded `rawOutput`, `inputUpdated` and the first validated relative location. Structured tool `content` diffs/images, `rawInput` and secondary locations are dropped; no complete diff presentation is claimed. | Real denied-create wire contains a diff, but wire presence is not runner/UI preservation. P1 add typed, bounded diff/image content and all validated locations with tool/session provenance; Product file-edit/validation and downloadable-file presentation pass; rich diff rendering remains unqualified. | | Provider workspace files | `session.workspace_file_changed.path` is relative to provider session workspace files, not task cwd. | Validated reference tagged `provider_session_workspace`, resolution required. | Source + traversal tests; P1 safe file retrieval/upload. | | Images / binary artifacts | Prompt image input; native content-addressed binary_asset base64. | Hash/length-validated metadata references; bytes not blindly read from disk or emitted in activity. | Source + unit digest tests; P1 durable artifact storage; >32 KiB payload provider omission remains. | -| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. P0 actual background settlement is required. | +| Background settlement | Standard prompt waits for idle in tested attached async-shell case; lossy native idle/receipt also exist. | ACP terminal result remains authoritative; raw event cannot end turn. | Offline attached and real-service finite detached commands completed before end_turn; marker verified through cleanup. A deterministic detach:true case now reproduces end_turn before the finite command completes; see the 2026-09-29 blocker. Native detach is now rejected before permission delivery; comprehensive background qualification remains blocked pending unproven tool/platform variants. | | Compaction/context | Native compaction lifecycle/token counts/context git metadata. | Safe bounded counters/status, immutable workspace binding. | Source + projection tests; raw summary/private custom instructions omitted. | | Goals / remote/schedules | Native autopilot/objectives/remote/schedule facilities; no qualified ACP goal protocol. | Unsupported through this profile; remote disabled. | Source; P2 separate governance review before control exposure. | diff --git a/packages/paperclip-runner/scripts/probe-copilot-acp.py b/packages/paperclip-runner/scripts/probe-copilot-acp.py index 7a5546e17f8..3f35957de58 100644 --- a/packages/paperclip-runner/scripts/probe-copilot-acp.py +++ b/packages/paperclip-runner/scripts/probe-copilot-acp.py @@ -5,20 +5,25 @@ This proves a protocol fixture, never GitHub/model/Daytona qualification. import argparse,json,subprocess,tempfile,pathlib,os,select,time,threading,http.server,hashlib,shutil,contextlib,sys parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--package-root',required=True) +parser.add_argument('--comparison-version',choices=['1.0.89'],help='Explicit offline comparison only; production remains pinned to 1.0.88') parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','detached-shell','discover-inputs','native-question','native-plan'],default='deny-write') parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent') resume_group=parser.add_mutually_exclusive_group() resume_group.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting') resume_group.add_argument('--restart',action='store_true',help='SIGKILL and replace the provider after one allowed seed mutation; load and verify no replay') +parser.add_argument('--tool-policy-node',help='Apply the repository tool policy before native permission dispatch using this Node 24 binary') args=parser.parse_args() +if args.tool_policy_node and args.scenario!='detached-shell':parser.error('--tool-policy-node requires detached-shell') package_root=pathlib.Path(args.package_root).resolve() metadata=json.loads((package_root/'package.json').read_text()) pins={'@github/copilot-darwin-arm64':'a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3','@github/copilot-darwin-x64':'85eb919f6b9b9dd833ce5e326cbf974b3ee2d4a9ac525c59d4ec9c9ec085715b','@github/copilot-linux-x64':'0059754cf78c3f3bf2c9d4564dfa7e9e25f3a3f8f411f2f0cdad9363f5662748'} -assert metadata['version']=='1.0.88' and metadata['name'] in pins +if args.comparison_version:pins={'@github/copilot-darwin-arm64':'97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7'} +assert metadata['version']==(args.comparison_version or '1.0.88') and metadata['name'] in pins assert not (package_root/'copilot').is_symlink() assert hashlib.sha256((package_root/'copilot').read_bytes()).hexdigest()==pins[metadata['name']] model_tool_names=[] model_tool_results=[] +model_tool_schemas={} command='sleep 2; printf ACP_SHELL_DONE > settlement.txt' binary=str(package_root/'copilot') @@ -50,16 +55,21 @@ with contextlib.ExitStack() as cleanup: tool_name={'deny-write':'create','deny-shell':'bash','deny-read':'view','attached-shell':'bash','detached-shell':'bash','native-question':'ask_user','native-plan':'exit_plan_mode'}.get(args.scenario) tool_arguments={'deny-write':{'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'},'deny-shell':{'command':'printf MUST_NOT_EXIST > denied.txt','description':'Denied command fixture'},'deny-read':{'path':str(outside)},'attached-shell':{'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False},'detached-shell':{'command':command,'description':'Bounded detached settlement fixture','mode':'async','detach':True},'native-question':{'question':'Choose one','choices':['A','B'],'allowFreeform':False},'native-plan':{'summary':'Fixture plan','planContent':'Do fixture work'}}.get(args.scenario) marker_at_prompt_result=False + policy_rejection=None + class PolicyRejected(Exception):pass class Handler(http.server.BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps({'data':[{'id':'gpt-4.1','object':'model','owned_by':'fixture'}]}).encode()) def do_POST(self): - global calls,total_model_calls,model_tool_names,model_tool_results + global calls,total_model_calls,model_tool_names,model_tool_results,model_tool_schemas calls+=1 total_model_calls+=1 data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0')))) model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')])) + for tool in data.get('tools',[]): + function=tool.get('function',{}) + if function.get('name') in ('bash','read_bash','write_bash','stop_bash','task','powershell','write_powershell','read_powershell'):model_tool_schemas[function['name']]=function model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool') if total_model_calls>6:raise RuntimeError('Fixture model exceeded its bounded request count') if data.get('stream'): @@ -85,6 +95,7 @@ with contextlib.ExitStack() as cleanup: responses={};wire=[];permission_responses=[];buf={p.stdout:b'',p.stderr:b''};nextid=0;marker_at_prompt_result=False def pump(wait=1): + global policy_rejection for stream in select.select([p.stdout,p.stderr],[],[],wait)[0]: data=os.read(stream.fileno(),65536) if not data:continue @@ -94,6 +105,13 @@ with contextlib.ExitStack() as cleanup: if not line:continue if stream==p.stderr:continue message=json.loads(line);wire.append(message) + if args.tool_policy_node and message.get('method')=='session/update': + checked=subprocess.run([args.tool_policy_node,str(pathlib.Path(__file__).with_name('probe-copilot-tool-policy.mjs'))],input=json.dumps(message['params']['update']),text=True,capture_output=True,timeout=5,env={'PATH':'/usr/bin:/bin'}) + if checked.returncode: + if checked.returncode!=42 or checked.stdout.strip()!='COPILOT_DETACHED_WORK_UNSUPPORTED':raise RuntimeError('Repository tool policy probe failed unexpectedly') + policy_rejection=checked.stdout.strip() + p.terminate();p.wait(timeout=5) + raise PolicyRejected(policy_rejection) if 'id' in message and 'method' not in message:responses[message['id']]=message if message.get('method')=='session/request_permission': requested_command=message['params'].get('toolCall',{}).get('rawInput',{}).get('command') @@ -143,16 +161,23 @@ with contextlib.ExitStack() as cleanup: while time.monotonic() { it.each(["/yolo on", " /allow-all", "\n/autopilot", "\t/plan", "\ufeff/cwd /elsewhere", "/unknown-future-command", "/custom-skill"])("rejects native command dispatch %s", text => { expect(() => assertCopilotPromptPolicy(text)).toThrowError(expect.objectContaining({ code: "COPILOT_POLICY_VIOLATION", retryable: false })); }); + it.each(["tool_call", "tool_call_update"])("rejects native detach independent of tool name in %s", sessionUpdate => { + for (const tool of ["bash", "write_bash", "powershell", "unknown"]) { + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { detach: true } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", retryable: false })); + expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate, tool, rawInput: { mode: "async", detach: false } })).not.toThrow(); + } + }); it("preserves ordinary prose mentioning commands and natural-language questions", () => { for (const text of ["Explain /plan", "Ask me before proceeding", "Read this file: /workspace/file", "Plan the work\n/yolo on"]) { expect(() => assertCopilotPromptPolicy(text)).not.toThrow(); @@ -75,6 +82,13 @@ describe("Copilot connection authority", () => { expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", configOptions: safe.configOptions } } })).toThrow(); } }); + it("also denies detachment carried only by permission rawInput", () => { + const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); + expect(() => guard("inbound", { id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { rawInput: { detach: true } } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + expect(() => guard("outbound", { id: 0, result: { outcome: { outcome: "selected", optionId: "allow_once" } } })) + .toThrowError(expect.objectContaining({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" })); + }); it("does not turn native unresolved input into delivered answers or successful continuation", () => { for (const type of ["user_input.requested", "exit_plan_mode.requested", "elicitation.requested"]) { const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts index 51a892cd226..cfc467c5fd9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-policy.ts @@ -2,7 +2,7 @@ export const COPILOT_AGENT_MODE = "https://agentclientprotocol.com/protocol/session-modes#agent"; export class CopilotPolicyViolationError extends Error { - readonly code = "COPILOT_POLICY_VIOLATION"; + readonly code: string = "COPILOT_POLICY_VIOLATION"; readonly retryable = false; constructor() { super("Copilot session configuration or command cannot preserve the admitted permission policy."); @@ -10,6 +10,15 @@ export class CopilotPolicyViolationError extends Error { } } +export class CopilotDetachedWorkUnsupportedError extends CopilotPolicyViolationError { + override readonly code = "COPILOT_DETACHED_WORK_UNSUPPORTED"; + constructor() { + super(); + this.name = "CopilotDetachedWorkUnsupportedError"; + this.message = "Native detached commands are unsupported in this Copilot runner. Run the command attached or use a managed runtime service."; + } +} + /** * Native ACP interprets a leading slash as a CLI command, before inference. * Reject every such command rather than maintaining an incomplete list of @@ -56,9 +65,15 @@ export function assertCopilotConfigPolicy(value: unknown): void { } } +/** Reject native detachment before permission handling, regardless of tool name. */ +export function assertCopilotToolPolicy(value: unknown): void { + if (record(record(value).rawInput).detach === true) throw new CopilotDetachedWorkUnsupportedError(); +} + /** Call synchronously for owned-session updates; any exception aborts authority. */ export function assertCopilotSessionUpdatePolicy(value: unknown): void { const update = record(value); + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") assertCopilotToolPolicy(update); if (update.sessionUpdate === "current_mode_update" && update.currentModeId !== COPILOT_AGENT_MODE) { throw new CopilotPolicyViolationError(); } @@ -74,9 +89,9 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " const options = record(result).configOptions; return Array.isArray(options) ? record(options.find(option => record(option).id === "model")).currentValue : undefined; }; - let failed = false; + let failure: CopilotPolicyViolationError | undefined; return (direction, value) => { - if (failed) throw new CopilotPolicyViolationError(); + if (failure) throw failure; try { const message = record(value); const params = record(message.params); @@ -118,6 +133,7 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " } else if (request.method === "session/close" && request.sessionId) sessions.delete(request.sessionId); } } + if (direction === "inbound" && method === "session/request_permission") assertCopilotToolPolicy(params.toolCall); if (direction === "inbound" && method === "session/update") { // Replay can precede load's response. It still cannot introduce unsafe // configuration, and it cannot grant admission before that response. @@ -134,12 +150,12 @@ export function createCopilotProtocolGuard(expectedModel: string): (direction: " if (params.type === "session.permissions_changed" && data.mode !== "manual") throw new CopilotPolicyViolationError(); if (params.type === "session.mode_changed" && data.newMode !== "interactive" && data.newMode !== "agent") throw new CopilotPolicyViolationError(); } - } catch { - failed = true; + } catch (error) { + failure = error instanceof CopilotPolicyViolationError ? error : new CopilotPolicyViolationError(); sessions.clear(); models.clear(); pending.clear(); - throw new CopilotPolicyViolationError(); + throw failure; } }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts index 2cc2bb36650..f2349dd5a1d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts @@ -61,4 +61,19 @@ describe("patched ACPX admission guard", () => { expect(c.kill).not.toHaveBeenCalled(); c.inbound.close(); }); + it("blocks detached command before the permission request can authorize a side effect", async () => { + const c = connection(); + await c.writer.write({ id: 0, method: "session/new", params: {} }); + c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); + await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Run task" }] } }); + const read = c.reader.read(); + c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { + sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", + rawInput: { command: "fixture", mode: "async", detach: true }, + } } }); + c.inbound.enqueue({ id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { toolCallId: "native-detached" } } }); + await expect(read).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", message: expect.stringContaining("Run the command attached") }); + expect(c.written).toHaveLength(2); // Only session/new and session/prompt; no permission response. + expect(c.kill).toHaveBeenCalledWith("SIGTERM"); + }); }); diff --git a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json index 13da8dc2b56..5cda9be4296 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json +++ b/packages/paperclip-runner/test/fixtures/copilot-policy-conformance-2026-09-29.json @@ -169,9 +169,89 @@ "postTerminalObservationMs": 3000, "disposition": "provider_behavior_failure", "failedAssertion": "Detached shell must finish before prompt terminal result" + }, + { + "evidenceFile": "detached-shell-policy-denied.json", + "sha256": "a941e02818297025866b76f541a23d078515dcfd4a43bf26421ffd46025ed720", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "detached-shell-policy-denied-final.json", + "sha256": "4a2e91b561300ec6e2bad7ebb9d599ca502692883ffaa3a30d24d606f3c0284b", + "providerVersion": "1.0.88", + "scenario": "detached-shell", + "loopbackModelRequests": 1, + "permissionResponseCount": 0, + "markerAtPromptResult": false, + "markerAfterObservation": false, + "policyRejection": "COPILOT_DETACHED_WORK_UNSUPPORTED", + "disposition": "policy_denied_before_effect", + "scope": "exact repository tool-update policy source, not full production admission" + }, + { + "evidenceFile": "comparison-1.0.89-attached.json", + "sha256": "95c87df86e91483ac67d7f02ffd76cf2c968e4be5c0f79679c0a1e8f289d6b20", + "providerVersion": "1.0.89", + "scenario": "attached-shell", + "loopbackModelRequests": 3, + "permissionResponseCount": 1, + "markerAtPromptResult": true, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "passed", + "scope": "unguarded native protocol" + }, + { + "evidenceFile": "comparison-1.0.89-detached.json", + "sha256": "0dd99fa9fe72c831f09bebfa99d9effae16fe895964f9ef726462b92ca1cf0f3", + "providerVersion": "1.0.89", + "scenario": "detached-shell", + "loopbackModelRequests": 2, + "permissionResponseCount": 1, + "markerAtPromptResult": false, + "markerAfterObservation": true, + "policyRejection": null, + "disposition": "unsupported_native_lifetime", + "scope": "unguarded native protocol" } ], "emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write.", "providerDeathDiagnosis": "One allowed seed mutation appends X. The exact provider is SIGKILLed and replaced against the same private state, then session/load succeeds. X occurs once after load and the next denied write remains absent. Request ID 0 is reused by the new process and requires fresh permission authority.", - "detachedSettlementDiagnosis": "Pinned Copilot returns end_turn and completed tool status for detach:true before the finite shell creates its marker. A separate diagnostic observes three seconds after terminal and proves the marker appears late. Empty background-task notices cannot prove task identity/count or settlement; no ACP task-list control is advertised. Retained failures are not converted into passes." + "detachedSettlementDiagnosis": "Explicit native detach returns end_turn before command completion in both 1.0.88 and isolated 1.0.89. Original failed settlement assertions are retained. This intentional native lifetime is unsupported by the runner, not proof that attached-shell upstream issue #4743 remains open. The production guard denies rawInput.detach:true before permission dispatch; native no-side-effect probe passes. Native Rust schemas/platform/feature variants and shell escape lifetimes remain outside comprehensive qualification.", + "comparisonRelease": { + "package": "@github/copilot-darwin-arm64", + "version": "1.0.89", + "tarball": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.89.tgz", + "integrity": "sha512-CaBNRZ1gRgatEdlgL2l/Ykw+79sOOYhBWFh3ohifYL7XgPBLf4xnXf59WlOTRT3Ojm4RSbbu/qVwBAa/elIdPQ==", + "tarballSha256": "7292cd2b3f0275e90bed9306bee23c5b7b1e22d604d8ea121f67c24f7192ad49", + "executableSha256": "97c12874d9adb9738374a9fb8a52cd724b81132ff815140f7e017bac706feba7" + }, + "toolSchemaEvidence": { + "evidenceFile": "native-tool-schemas.json", + "sha256": "68463a6afc5cf6b247998d76d4094fdc44789909197b2cd7a380b1649c8b4062", + "detachParameterTools": [ + "bash" + ], + "noDetachParameterTools": [ + "read_bash", + "stop_bash", + "task" + ], + "notAdvertised": [ + "write_bash", + "powershell", + "write_powershell", + "read_powershell" + ], + "comprehensiveAcrossPlatformsAndFlags": false + }, + "detachedPolicySourceSha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3" } From 860ac89c8635fde1f56bef4fb6c39ff64c2056d7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 09:58:50 -0500 Subject: [PATCH 06/12] Bind Copilot v3 identity to native detached-work admission Co-Authored-By: Paperclip --- .../paperclip-runner/scripts/build-copilot-distribution.mjs | 2 +- .../paperclip-runner/src/drivers/acpx/qualified-profiles.ts | 2 +- .../test/fixtures/copilot-profile-v3-identity.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index 050c588934c..d50823f5ed4 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7"; +const PROFILE_DIGEST = "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index d333a9f8635..03af03ded97 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -74,7 +74,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 3, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7", + commandDigest: "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json index b14ef739fb5..5c5f4713575 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:4dbd3b0bead1dfcbe4b9b0d9a82a6660ae49c2b1a118862ec3ef654f3b76f8d7", + "commandDigest": "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -8,7 +8,7 @@ "agentServerVersion": "1.0.88", "protocolPolicyRevision": "copilot-agent-manual-v1", "acpxPatchSha256": "cefbc196b0c46b5e34aa991d2265cb7466a955b473f07a2ba123fa44ca398579", - "policySha256": "48aec06d6232175b07a9ef917eb7df3042a1bc99cf4254a02aedd86c2ac68355", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", "agentFilesBinding": "registered-runtime-context-v1" } From 7668cd822db733809df17ca278901b831dd697f2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:00:20 -0500 Subject: [PATCH 07/12] Preserve Copilot policy rejection codes across sidecar transport Co-Authored-By: Paperclip --- .../crates/runner-core/src/acpx_sidecar_transport.rs | 7 +++++++ .../paperclip-runner/src/cli/acpx-sidecar-input.test.ts | 6 ++++++ packages/paperclip-runner/src/cli/acpx-sidecar-input.ts | 2 ++ 3 files changed, 15 insertions(+) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 9bb2c3b6c0b..e111ce23553 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -697,6 +697,8 @@ fn response_error_classification(error: &ResponseError) -> &'static str { "AGENT_DISCONNECTED" => return "agent_disconnected", "AUTH_REQUIRED" => return "authentication_required", "COPILOT_AUTH_REQUIRED" => return "authentication_required", + "COPILOT_POLICY_VIOLATION" => return "copilot_policy_violation", + "COPILOT_DETACHED_WORK_UNSUPPORTED" => return "copilot_detached_work_unsupported", "COPILOT_ENTITLEMENT_DENIED" => return "provider_entitlement_denied", "COPILOT_MODEL_UNAVAILABLE" => return "requested_model_unsupported", "SESSION_RESUME_REQUIRED" => return "session_resume_required", @@ -888,6 +890,11 @@ mod tests { "provider_lifetime_owned" ); let admission_failures = [ + ("COPILOT_POLICY_VIOLATION", "copilot_policy_violation"), + ( + "COPILOT_DETACHED_WORK_UNSUPPORTED", + "copilot_detached_work_unsupported", + ), ( "ACPX_RUNTIME_ADMISSION_VERIFICATION_TIMEOUT", "runtime_admission_verification_timeout", diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts index 4ce9ef20a2a..bed92b7cef0 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -114,6 +114,12 @@ describe("ACPX sidecar input sequencing", () => { expect(acpxBootstrapBlockedError(null, "turn.start")).toBeNull(); }); + it.each(["COPILOT_POLICY_VIOLATION", "COPILOT_DETACHED_WORK_UNSUPPORTED"])("preserves Copilot policy identity %s", code => { + const error = Object.assign(new Error("safe fixed policy message"), { code }); + expect(acpxSidecarErrorCode(error)).toBe(code); + expect(acpxSidecarErrorCode(new Error("wrapped", { cause: error }))).toBe(code); + }); + it("preserves stable ACPX error identities without copying startup stderr", () => { const missingModule = Object.assign(new Error("provider exited"), { detailCode: "AGENT_STARTUP_FAILED", diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index 9f508bb2898..1887b912ce6 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -130,6 +130,8 @@ const GENERIC_ACPX_OUTPUT_CODES = new Set([ ]); const STABLE_ACPX_SIDECAR_CODES = new Set([ + "COPILOT_POLICY_VIOLATION", + "COPILOT_DETACHED_WORK_UNSUPPORTED", "ACP_MODEL_UNSUPPORTED", "ACP_SESSION_INIT_FAILED", "AGENT_DISCONNECTED", From fa393bff17882ae479c585b5e356641c2d95967f Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:03:28 -0500 Subject: [PATCH 08/12] Preserve authoritative Copilot guard errors for pending ACP requests Co-Authored-By: Paperclip --- .../scripts/build-copilot-distribution.mjs | 2 +- .../acpx/copilot-protocol-guard.test.ts | 5 ++- .../src/drivers/acpx/qualified-profiles.ts | 2 +- .../fixtures/copilot-profile-v3-identity.json | 4 +- patches/acpx@0.13.1.patch | 37 ++++++++++--------- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index d50823f5ed4..8b727eee3f0 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374"; +const PROFILE_DIGEST = "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts index f2349dd5a1d..0ab47a31e63 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-protocol-guard.test.ts @@ -23,7 +23,7 @@ function connection(guarded = true) { readable: new ReadableStream({ start(controller) { inbound = controller; } }), writable: new WritableStream({ write(message) { written.push(message); } }), }); - return { writer: stream.writable.getWriter(), reader: stream.readable.getReader(), inbound, written, kill }; + return { client, writer: stream.writable.getWriter(), reader: stream.readable.getReader(), inbound, written, kill }; } describe("patched ACPX admission guard", () => { @@ -67,12 +67,15 @@ describe("patched ACPX admission guard", () => { c.inbound.enqueue({ id: 0, result: safe }); await c.reader.read(); await c.writer.write({ id: 1, method: "session/prompt", params: { sessionId: "s", prompt: [{ type: "text", text: "Run task" }] } }); const read = c.reader.read(); + const pendingSdkRequest = c.client.runConnectionRequest(() => new Promise(() => {})); + const pendingRejection = expect(pendingSdkRequest).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED" }); c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "tool_call", toolCallId: "native-detached", kind: "execute", status: "pending", rawInput: { command: "fixture", mode: "async", detach: true }, } } }); c.inbound.enqueue({ id: 0, method: "session/request_permission", params: { sessionId: "s", toolCall: { toolCallId: "native-detached" } } }); await expect(read).rejects.toMatchObject({ code: "COPILOT_DETACHED_WORK_UNSUPPORTED", message: expect.stringContaining("Run the command attached") }); + await pendingRejection; expect(c.written).toHaveLength(2); // Only session/new and session/prompt; no permission response. expect(c.kill).toHaveBeenCalledWith("SIGTERM"); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 03af03ded97..dccf35b5e18 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -74,7 +74,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 3, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", + commandDigest: "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json index 5c5f4713575..d5e125b240d 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v3-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:31554905c7e37f44d9dd90b4858e6580271264ab2c871e6fd10fad194d00e374", + "commandDigest": "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -7,7 +7,7 @@ "acpxVersion": "0.13.1", "agentServerVersion": "1.0.88", "protocolPolicyRevision": "copilot-agent-manual-v1", - "acpxPatchSha256": "cefbc196b0c46b5e34aa991d2265cb7466a955b473f07a2ba123fa44ca398579", + "acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933", "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", "distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f", "agentFilesBinding": "registered-runtime-context-v1" diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index 87d0ba1bdc0..7f5fa998770 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -20,7 +20,7 @@ index 5e2113a..45aff03 100644 private setSessionModelThroughConfig; private setSessionModelThroughLegacyMethod; diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js -index d454fd7..bf3a537 100644 +index d454fd7..2386544 100644 --- a/dist/live-checkpoint-BSIrfgVo.js +++ b/dist/live-checkpoint-BSIrfgVo.js @@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) { @@ -268,7 +268,7 @@ index d454fd7..bf3a537 100644 clientInfo: resolveClientInfo(launch.devinAcp) }); const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise; -@@ -4358,6 +4430,48 @@ var AcpClient = class { +@@ -4358,6 +4430,49 @@ var AcpClient = class { throw normalizedError; } createTappedStream(base) { @@ -278,6 +278,7 @@ index d454fd7..bf3a537 100644 + const enforceProtocol = (direction, message) => { + try { protocolGuard?.(direction, message); } + catch (error) { ++ this.rejectPendingConnectionRequests(error); + abortResponseDeliveries(); + this.activePrompt?.elicitationController.abort(error); + try { this.agent?.kill("SIGTERM"); } catch {} @@ -317,7 +318,7 @@ index d454fd7..bf3a537 100644 const onAcpMessage = () => this.eventHandlers.onAcpMessage; const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage; const elicitationRequestIds = /* @__PURE__ */ new Set(); -@@ -4367,8 +4481,12 @@ var AcpClient = class { +@@ -4367,8 +4482,12 @@ var AcpClient = class { const shouldSuppressInboundReplaySessionUpdate = (message) => { return this.suppressReplaySessionUpdateMessages && isSessionUpdateNotification(message); }; @@ -331,7 +332,7 @@ index d454fd7..bf3a537 100644 if (requestId !== void 0) { elicitationRequestIds.add(requestId); return; -@@ -4379,6 +4497,7 @@ var AcpClient = class { +@@ -4379,6 +4498,7 @@ var AcpClient = class { } }; return { @@ -339,7 +340,7 @@ index d454fd7..bf3a537 100644 readable: new ReadableStream({ async start(controller) { const reader = base.readable.getReader(); try { -@@ -4386,16 +4505,27 @@ var AcpClient = class { +@@ -4386,16 +4506,27 @@ var AcpClient = class { const { value, done } = await reader.read(); if (done) break; if (!value) continue; @@ -369,7 +370,7 @@ index d454fd7..bf3a537 100644 const promptOwner = promptRequestOwner(message); if (promptOwner) bindPromptOwner(promptOwner); const id = responseId(message); -@@ -4403,10 +4533,20 @@ var AcpClient = class { +@@ -4403,10 +4534,20 @@ var AcpClient = class { onAcpOutputMessage()?.("outbound", message); onAcpMessage()?.("outbound", message); } @@ -390,7 +391,7 @@ index d454fd7..bf3a537 100644 writer.releaseLock(); } } }) -@@ -4422,7 +4562,10 @@ var AcpClient = class { +@@ -4422,7 +4563,10 @@ var AcpClient = class { const createPromise = this.runConnectionRequest(() => connection.newSession({ cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], @@ -402,7 +403,7 @@ index d454fd7..bf3a537 100644 })); result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; } catch (error) { -@@ -4864,7 +5007,7 @@ var AcpClient = class { +@@ -4864,7 +5008,7 @@ var AcpClient = class { } selectAuthMethod(methods) { for (const method of methods) { @@ -411,7 +412,7 @@ index d454fd7..bf3a537 100644 if (envCredential) return { methodId: method.id, credential: envCredential, -@@ -4890,7 +5033,7 @@ var AcpClient = class { +@@ -4890,7 +5034,7 @@ var AcpClient = class { } readAgentSpecificEnvCredential(methodId) { if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; @@ -420,7 +421,7 @@ index d454fd7..bf3a537 100644 return typeof value === "string" && value.trim().length > 0 ? value : void 0; } selectAgentManagedAuthMethod(methodId) { -@@ -4915,9 +5058,9 @@ var AcpClient = class { +@@ -4915,9 +5059,9 @@ var AcpClient = class { await connection.authenticate({ methodId: selected.methodId }); this.log(`authenticated with method ${selected.methodId} (${selected.source})`); } @@ -432,7 +433,7 @@ index d454fd7..bf3a537 100644 if (hostResponse) return hostResponse; const { response, recorded } = await this.resolvePermissionRequestFromMode(params); if (!recorded) { -@@ -4926,14 +5069,81 @@ var AcpClient = class { +@@ -4926,14 +5070,81 @@ var AcpClient = class { } return response; } @@ -516,7 +517,7 @@ index d454fd7..bf3a537 100644 })).then((response) => ({ kind: "response", response -@@ -4970,7 +5180,7 @@ var AcpClient = class { +@@ -4970,7 +5181,7 @@ var AcpClient = class { isElicitationSessionCancelling(sessionId) { return this.closing || this.cancellingSessionIds.has(sessionId); } @@ -525,7 +526,7 @@ index d454fd7..bf3a537 100644 if (!this.options.onPermissionRequest) return; const signal = this.cancellationSignalForSession(params.sessionId); try { -@@ -4978,7 +5188,7 @@ var AcpClient = class { +@@ -4978,7 +5189,7 @@ var AcpClient = class { sessionId: params.sessionId, raw: params, inferredKind: inferToolKind(params) @@ -534,7 +535,7 @@ index d454fd7..bf3a537 100644 return this.hostPermissionDecisionResponse(params, signal, decision); } catch (error) { return this.hostPermissionErrorResponse(params, signal, error); -@@ -5028,6 +5238,12 @@ var AcpClient = class { +@@ -5028,6 +5239,12 @@ var AcpClient = class { attachAgentLifecycleObservers(child) { child.once("exit", (exitCode, signal) => { this.recordAgentExit("process_exit", exitCode, signal); @@ -547,7 +548,7 @@ index d454fd7..bf3a537 100644 }); child.once("close", (exitCode, signal) => { this.recordAgentExit("process_close", exitCode, signal); -@@ -5771,10 +5987,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN +@@ -5771,10 +5988,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN trimConversationForRuntime(conversation); return acpx; } @@ -570,7 +571,7 @@ index d454fd7..bf3a537 100644 updateConversationTimestamp(conversation, timestamp); trimConversationForRuntime(conversation); return true; -@@ -6435,6 +6661,27 @@ async function withConnectedSession(options) { +@@ -6435,6 +6662,27 @@ async function withConnectedSession(options) { //#region src/runtime/engine/prompt-turn.ts const SESSION_REPLY_IDLE_MS = 1e3; const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; @@ -598,7 +599,7 @@ index d454fd7..bf3a537 100644 async function runPromptTurn(params) { try { const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,7 +6691,21 @@ async function runPromptTurn(params) { +@@ -6444,7 +6692,21 @@ async function runPromptTurn(params) { idleMs: SESSION_REPLY_IDLE_MS, timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS }).catch(() => {}); @@ -620,7 +621,7 @@ index d454fd7..bf3a537 100644 return { stopReason: response.stopReason, source: "rpc" -@@ -6518,4 +6779,4 @@ var LiveSessionCheckpoint = class { +@@ -6518,4 +6780,4 @@ var LiveSessionCheckpoint = class { //#endregion export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt }; From 59bba2819295407d8e76bc70bf0221ca1384aa1a Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:04:53 -0500 Subject: [PATCH 09/12] Follow current Copilot profile identity in native installation admission Co-Authored-By: Paperclip --- .../src/drivers/acpx/copilot-installation.test.ts | 13 ++++++++++++- .../src/drivers/acpx/copilot-installation.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 7eed92a785a..66cf3b83501 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -10,6 +10,17 @@ import { QUALIFIED_ACPX_PROFILES } from "./qualified-profiles.js"; vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { + it("admits the current v3 declaration and binds its source policy/patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v3-identity.json", import.meta.url), "utf8")); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(identity.declaration.agentProfileVersion); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe(identity.commandDigest); + const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); + expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(identity.commandDigest); + for (const [relative, field] of [["copilot-policy.ts", "policySha256"], ["../../../../../patches/acpx@0.13.1.patch", "acpxPatchSha256"]]) { + expect(createHash("sha256").update(readFileSync(new URL(relative!, import.meta.url))).digest("hex")).toBe(identity.declaration[field!]); + } + expect(readFileSync(new URL("../../../scripts/build-copilot-distribution.mjs", import.meta.url), "utf8")).toContain(identity.commandDigest); + }); it("pins the materializer's complete executable closure on every target platform", () => { const script = readFileSync(new URL("../../../scripts/materialize-copilot-binary.mjs", import.meta.url), "utf8"); const records = [...script.matchAll(/"(darwin-arm64|darwin-x64|linux-x64)": Object\.freeze\(\{ packageName: "[^"]+", executableDigest: "([a-f0-9]+)", size: (\d+)/g)]; @@ -38,7 +49,7 @@ describe("Copilot build-owned installation", () => { }); it("rejects changed profile identities before native file access", async () => { vi.mocked(verifyNativeAcpxInstallation).mockClear(); - for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }]) { + for (const override of [{ agentServerVersion: "latest" }, { commandDigest: "sha256:untrusted" }, { agent: "cursor" }, { agentProfileVersion: 1 }, { agentProfileVersion: 2 }, { agentProfileVersion: 4 }]) { await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override } as never)).rejects.toThrow("exact pinned"); } expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts index d3347bcf73d..c57994a2f47 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.ts @@ -13,7 +13,7 @@ export const COPILOT_CLOSURE_SHA256 = Object.freeze({ /** Admission primitive only. Pending candidates remain gated by qualification. */ export async function verifyCopilotInstallation(profile: QualifiedAcpxProfile): Promise { const expected = QUALIFIED_ACPX_PROFILES.copilot; - if (profile.agent !== "copilot" || profile.agentProfileVersion !== 2 + if (profile.agent !== "copilot" || profile.agentProfileVersion !== expected.agentProfileVersion || profile.acpxVersion !== expected.acpxVersion || profile.agentServerPackage !== "@github/copilot" || profile.agentServerVersion !== COPILOT_VERSION || profile.commandDigest !== expected.commandDigest || profile.agentRuntimePackage !== null || profile.agentRuntimeVersion !== null) { From ff37a38ed3367bd06893b2542c648e94c04b60bb Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:07:49 -0500 Subject: [PATCH 10/12] Admit current ACP profiles and verify cross-language pin agreement Co-Authored-By: Paperclip --- .../src/contracts/native-execution.test.ts | 25 +++++++++++++++++-- .../src/contracts/native-execution.ts | 4 +-- .../drivers/acpx/qualified-profiles.test.ts | 16 ++++++++++++ .../src/drivers/acpx/qualified-profiles.ts | 4 +-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 65997242524..ed082d720de 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it } from "vitest"; import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, type NativeExecutionInputV1 } from "./native-execution.js"; @@ -280,7 +281,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3, 4, 5] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + it.each([1, 2, 3, 4, 5, 6] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -312,7 +313,7 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 6, 1.5, "5", null]) { + for (const unsupportedVersion of [0, 7, 1.5, "6", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, @@ -332,6 +333,26 @@ describe("NativeExecutionInputV1", () => { })).toThrow("does not match"); }); + it.each(Object.values(QUALIFIED_ACPX_PROFILES))( + "admits the current $agent profile through the native execution boundary", + (declaration) => { + const { qualificationModel, reportedModelId: _reported, permissionPolicy, + modelPolicy: _modelPolicy, qualificationStatus: _status, ...profile } = declaration; + const provider = { + kind: "acpx", agent: declaration.agent, + model: qualificationModel || "explicit-provider-model", + permissionPolicy, profile, + }; + const parsed = parseNativeExecutionInput({ + ...input, + session: { ...input.session, driverKind: "acpx_runtime" }, + provider, + }); + expect(parsed.provider).toEqual(provider); + expect(parseNativeExecutionInput(parsed)).toEqual(parsed); + }, + ); + it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => { const legacy = structuredClone(input) as Record; delete (legacy.session as Record).lifecyclePolicy; diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 2d1ec54a8b2..157ce11f22a 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3 | 4 | 5; + agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -596,7 +596,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 9cde5dd0a96..2716e9e336c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; import { QUALIFIED_ACPX_PROFILES, @@ -6,6 +7,21 @@ import { } from "./qualified-profiles.js"; describe("qualified ACPX profiles", () => { + it.each(Object.values(QUALIFIED_ACPX_PROFILES))( + "keeps the Rust admission digest synchronized with the current $agent profile", + (profile) => { + const rust = readFileSync(new URL("../../../runner/crates/runner-core/src/acpx_provider_backend.rs", import.meta.url), "utf8"); + const start = rust.indexOf(`"${profile.agent}" => (`); + expect(start).toBeGreaterThan(0); + const end = rust.indexOf("\n ),", start); + expect(end).toBeGreaterThan(start); + const admission = rust.slice(start, end); + expect(admission).toContain(JSON.stringify(profile.agentServerPackage)); + expect(admission).toContain(JSON.stringify(profile.agentServerVersion)); + expect(admission).toContain(JSON.stringify(profile.commandDigest)); + }, + ); + it("binds each agent to one immutable package and model declaration", () => { for (const agent of ["pi", "claude", "codex", "grok"] as const) { const profile = QUALIFIED_ACPX_PROFILES[agent]; diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 1d88bf14da1..b66c7dc3768 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -2,7 +2,7 @@ export const QUALIFIED_ACPX_VERSION = "0.13.1" as const; export const ACPX_DRIVER_KIND = "acpx_runtime" as const; export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const; -import type { NativeAcpxAgent } from "../../contracts/native-execution.js"; +import type { NativeAcpxAgent, NativeAcpxProfileSnapshot } from "../../contracts/native-execution.js"; export type QualifiedAcpxAgent = NativeAcpxAgent; @@ -11,7 +11,7 @@ export interface QualifiedAcpxProfile { readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION; readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; readonly agent: QualifiedAcpxAgent; - readonly agentProfileVersion: 1 | 2 | 3 | 4 | 5; + readonly agentProfileVersion: NativeAcpxProfileSnapshot["agentProfileVersion"]; readonly qualificationStatus?: "pending"; readonly modelPolicy?: "explicit-provider-verified"; /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ From 70d9fc4beeca518c1cba24b86716ac5d6eda79d0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:08:55 -0500 Subject: [PATCH 11/12] Admit the verified Cursor v3 identity in Rust Co-Authored-By: Paperclip --- .../runner/crates/runner-core/src/acpx_provider_backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index c2408ec731a..59867d8f05c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -178,7 +178,7 @@ impl AcpxProviderDescriptor { "2026.09.26-dd393fe", None, None, - "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", ), "copilot" => ( self.model.as_str(), @@ -2722,7 +2722,7 @@ mod tests { "cursor", "cursor-agent", "2026.09.26-dd393fe", - "sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899", + "sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2", None, None, "explicit-model", From 3d0c45920c326821c2f5ff48dc985ef8158b46e4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 29 Sep 2026 10:09:15 -0500 Subject: [PATCH 12/12] Admit the verified Copilot v3 identity in Rust Co-Authored-By: Paperclip --- .../runner/crates/runner-core/src/acpx_provider_backend.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 59867d8f05c..cb1bde874e2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -186,7 +186,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", ), "grok" => ( "grok-4.7", @@ -2731,7 +2731,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457", + "sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939", None, None, "explicit-model",