mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Merge branch 'codex/runner-copilot-acp' into codex/runner-acp-production
* codex/runner-copilot-acp: Bind Copilot v3 identity to native detached-work admission Deny native Copilot detached work before permission dispatch Bind Daytona image identity to Cursor isolation patch Retain Copilot detached-command early completion regression Prove Copilot permission recovery after provider death without replay
This commit is contained in:
commit
37507835dd
12 files changed
+344
-35
No files matched your search
@@ -41,16 +41,22 @@ 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 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
|
||||
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
|
||||
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.
|
||||
@@ -64,6 +70,48 @@ 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.
|
||||
|
||||
### 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
|
||||
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 `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
|
||||
|
||||
The audit inspected the exact `@github/copilot@1.0.88` platform archives, the
|
||||
@@ -206,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. 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. 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. |
|
||||
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -5,18 +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('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write')
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
@@ -39,29 +46,38 @@ 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')
|
||||
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
|
||||
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>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,11 +89,13 @@ 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
|
||||
|
||||
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
|
||||
@@ -87,9 +105,17 @@ 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':
|
||||
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 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})
|
||||
@@ -102,37 +128,59 @@ 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
|
||||
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()<observation_deadline:pump(.1)
|
||||
else:
|
||||
for _ in range(3):pump(.1)
|
||||
except PolicyRejected:
|
||||
# No permission response was sent. Wait past the fixture delay to prove no effect.
|
||||
time.sleep(3)
|
||||
finally:
|
||||
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}
|
||||
marker_exists=(root/('settlement.txt' if args.scenario in ('attached-shell','detached-shell') else 'denied.txt')).exists()
|
||||
report={'schema':'paperclip.copilot-acp-evidence/v1','harnessVersion':metadata['version'],'package':metadata['name'],'executableSha256':pins[metadata['name']],'modelSource':'deterministic loopback fixture, COPILOT_OFFLINE=true; not a live model qualification','scenario':args.scenario,'toolPolicyScope':'repository tool-update admission only' if args.tool_policy_node else None,'toolPolicyRejection':policy_rejection,'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,'modelToolSchemas':model_tool_schemas,'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:
|
||||
if failure is None:raise
|
||||
|
||||
if args.tool_policy_node:
|
||||
assert policy_rejection=='COPILOT_DETACHED_WORK_UNSUPPORTED' and not permission_responses and not marker_exists, 'Detached policy failed to prevent native side effects'
|
||||
sys.exit(0)
|
||||
|
||||
if not session or 'result' not in session or responses.get(final_prompt_id,{}).get('result',{}).get('stopReason')!='end_turn':
|
||||
raise RuntimeError('Copilot did not complete the fixture turn')
|
||||
if marker_at_prompt_result != (args.scenario=='attached-shell') or marker_exists != marker_at_prompt_result:
|
||||
if marker_at_prompt_result != (args.scenario in ('attached-shell','detached-shell')) or marker_exists != marker_at_prompt_result:
|
||||
raise RuntimeError('Copilot violated the fixture filesystem expectation')
|
||||
|
||||
if args.scenario.startswith('deny-') and not permission_responses:
|
||||
@@ -144,3 +192,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')
|
||||
@@ -0,0 +1,10 @@
|
||||
// Credential-free native probe bridge to the exact production policy source.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { assertCopilotSessionUpdatePolicy, CopilotDetachedWorkUnsupportedError } from "../src/drivers/acpx/copilot-policy.ts";
|
||||
try {
|
||||
assertCopilotSessionUpdatePolicy(JSON.parse(readFileSync(0, "utf8")));
|
||||
} catch (error) {
|
||||
if (!(error instanceof CopilotDetachedWorkUnsupportedError)) throw error;
|
||||
process.stdout.write(error.code);
|
||||
process.exitCode = 42;
|
||||
}
|
||||
@@ -17,6 +17,13 @@ describe("Copilot native policy fence", () => {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,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",
|
||||
|
||||
+132
-1
@@ -120,7 +120,138 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "detached-shell.json",
|
||||
"sha256": "8b99a6e010e085aad37d39419e3159743f74bb013b731e7856d572f70cce9c89",
|
||||
"scenario": "detached-shell",
|
||||
"mode": "agent",
|
||||
"loopbackModelRequests": 2,
|
||||
"permissionRequestCount": 1,
|
||||
"markerAtPromptResult": false,
|
||||
"markerAfterObservation": false,
|
||||
"postTerminalObservationMs": 300,
|
||||
"disposition": "provider_behavior_failure",
|
||||
"failedAssertion": "Detached shell must finish before prompt terminal result"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "detached-shell-observe-late.json",
|
||||
"sha256": "095b0aed787575bbc22f5e80ea8eff74152a696dccdf850201f3aba629ebc978",
|
||||
"scenario": "detached-shell",
|
||||
"mode": "agent",
|
||||
"loopbackModelRequests": 2,
|
||||
"permissionRequestCount": 1,
|
||||
"markerAtPromptResult": false,
|
||||
"markerAfterObservation": true,
|
||||
"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."
|
||||
"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": "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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [
|
||||
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/materialize-copilot-binary.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",
|
||||
|
||||
@@ -203,6 +203,7 @@ describe("runner E2E Daytona image contract", () => {
|
||||
"packages/paperclip-runner/scripts/build-copilot-distribution.mjs",
|
||||
"packages/paperclip-runner/scripts/materialize-copilot-binary.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/scripts/materialize-pi-distribution.mjs",
|
||||
@@ -346,6 +347,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-"),
|
||||
|
||||
Reference in new issue
Block a user