Merge branch 'codex/runner-copilot-acp' into codex/runner-pi-acp

* codex/runner-copilot-acp:
  Admit the verified Copilot v3 identity in Rust
  Admit the verified Cursor v3 identity in Rust
  Admit current ACP profiles and verify cross-language pin agreement
  Follow current Copilot profile identity in native installation admission
  Preserve authoritative Copilot guard errors for pending ACP requests
  Preserve Copilot policy rejection codes across sidecar transport
  Bind Copilot v3 identity to native detached-work admission
  Deny native Copilot detached work before permission dispatch
  Prove Cursor SDK interactions and retain profile v3 build evidence
  Bind Daytona image identity to Cursor isolation patch
  Retain Copilot detached-command early completion regression
  Prove Copilot permission recovery after provider death without replay

# Conflicts:
#	packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts
This commit is contained in:
Dotta committed 2026-09-29 10:10:17 -05:00
commit 6766b9128e
25 files changed
+784 -67

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. |
@@ -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.
@@ -178,7 +178,7 @@ impl AcpxProviderDescriptor {
"2026.09.26-dd393fe",
None,
None,
"sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
"sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2",
),
"copilot" => (
self.model.as_str(),
@@ -186,7 +186,7 @@ impl AcpxProviderDescriptor {
"1.0.88",
None,
None,
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
"sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939",
),
"grok" => (
"grok-4.7",
@@ -2722,7 +2722,7 @@ mod tests {
"cursor",
"cursor-agent",
"2026.09.26-dd393fe",
"sha256:1157a5d071abbd57ab132f22bace75c65e84cc47a045b0023475488755e14899",
"sha256:c91aa592ec867071ec4457b9b7230399ce42ad918787ceed99b4b651ea5607e2",
None,
None,
"explicit-model",
@@ -2731,7 +2731,7 @@ mod tests {
"copilot",
"@github/copilot",
"1.0.88",
"sha256:b18c01603dd0169d233140709cfaa8bf5304a03cf5de78ca4f625f30013e8457",
"sha256:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939",
None,
None,
"explicit-model",
@@ -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",
@@ -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: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 } = {}) {
@@ -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;
}
@@ -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",
@@ -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",
@@ -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<string, unknown>;
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
@@ -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");
}
@@ -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();
@@ -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<VerifiedAcpxInstallation> {
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) {
@@ -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;
}
};
}
@@ -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", () => {
@@ -61,4 +61,22 @@ 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();
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");
});
});
@@ -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<void> };
async function withClient(onRequest: (method: string, params: Record<string, unknown>, context: Context) => Promise<unknown>, run: (client: InstanceType<typeof AcpClient>, received: unknown[]) => Promise<void>) {
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<void>[] = []; 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<void>(); const response = Promise.withResolvers<unknown>(); 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([]);
});
});
@@ -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];
@@ -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 | 6;
readonly agentProfileVersion: NativeAcpxProfileSnapshot["agentProfileVersion"];
readonly qualificationStatus?: "pending";
readonly modelPolicy?: "explicit-provider-verified";
/** Wire identity: an npm package name or a runner-owned builtin: identifier. */
@@ -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: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",
@@ -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:527f9cbbad2f3e75169cae70d7aa5b189200e57f1ab7f9e1523357b5606b0939",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
@@ -7,8 +7,8 @@
"acpxVersion": "0.13.1",
"agentServerVersion": "1.0.88",
"protocolPolicyRevision": "copilot-agent-manual-v1",
"acpxPatchSha256": "cefbc196b0c46b5e34aa991d2265cb7466a955b473f07a2ba123fa44ca398579",
"policySha256": "48aec06d6232175b07a9ef917eb7df3042a1bc99cf4254a02aedd86c2ac68355",
"acpxPatchSha256": "a64405199b381688ffd72fdbb88396e423394cb407813d7b6f1db43bae9c3933",
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
"distributionSourceSha256": "bb3dcf14972e82f6578c8250540cfbc6ec2ad953bea5d4f10d28c8b461011f3f",
"agentFilesBinding": "registered-runtime-context-v1"
}
@@ -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"
}
}
+19 -18
View File
@@ -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 };
@@ -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",
+23
View File
@@ -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-"),