mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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:
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",
|
||||
|
||||
+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: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"
|
||||
}
|
||||
|
||||
+236
@@ -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
@@ -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",
|
||||
|
||||
@@ -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