mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
Fence Copilot ACP connection policy before protocol dispatch
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9f4476feb8
commit
d01c2439e0
9 files changed
+631
-59
No files matched your search
@@ -11,6 +11,59 @@ Real executable offline probes cost $0; the live token receipt has no verified U
|
||||
the shared $100 hard stop and verifiable spend. Do not expose this profile as
|
||||
supported until the required local and Daytona qualification passes.
|
||||
|
||||
## Connection policy audit, 2026-09-29
|
||||
|
||||
The pinned server accepts permission-changing `session/set_config_option`,
|
||||
`session/set_mode`, and leading-slash CLI commands. Entering autopilot enables
|
||||
allow-all; loading an autopilot conversation enables it again. A clean launch
|
||||
configuration alone therefore cannot establish safe restored-session policy.
|
||||
|
||||
`copilot-policy.ts` and the optional ACPX `protocolGuardFactory` enforce policy
|
||||
on each actual stdio connection, before bytes are delivered to the SDK or written
|
||||
to the provider. Copilot admission requires the full new/load/resume result to
|
||||
report the exact agent-mode URI and `allow_all: off`. Missing, duplicated,
|
||||
unknown-authority, or custom-agent configuration fails closed. A connection may
|
||||
select only its explicitly admitted model; a prompt requires proof of that
|
||||
model. The guard rejects leading-slash commands, native mode/config controls,
|
||||
and unsafe mode/config drift. Its authority is fresh for every connection and
|
||||
remains invalid after a violation. It does not restore trust from cached ACPX
|
||||
status. The stream errors, aborts outstanding response delivery, and terminates
|
||||
the provider on violation; the existing runner owns bounded process cleanup.
|
||||
Other providers do not install this guard.
|
||||
|
||||
The native `ask_user` and `exit_plan_mode` tools are absent from both agent and
|
||||
plan mode in the pinned deterministic tool catalogs. Forced model tool calls
|
||||
return explicit tool-unavailable results rather than producing a blocking
|
||||
request. No `--no-ask-user` or tool-exclusion flag was used. Agent mode is the
|
||||
only admitted production mode. If any native input-request event nevertheless
|
||||
arrives, the guard terminates the connection; it never presents a form whose
|
||||
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
|
||||
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.
|
||||
|
||||
All these probes use the verified 1.0.88 ARM64 binary, an explicitly constructed
|
||||
credential-free environment, `COPILOT_OFFLINE=true`, and a synthetic OpenAI-style
|
||||
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.
|
||||
There are zero paid provider calls and zero model spend. Raw evidence, including
|
||||
the failed empty-session attempt, stays in the private
|
||||
`copilot-policy-20260929` artifact directory.
|
||||
|
||||
The prior Product question failure remains a model-behavior failure: the retained
|
||||
snapshot contains the exact requested marker in the task instructions, the
|
||||
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.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
The audit inspected the exact `@github/copilot@1.0.88` platform archives, the
|
||||
@@ -144,8 +197,8 @@ Daytona claims require the separate live product qualification.
|
||||
| Tools / correlation | Standard `tool_call`/`tool_call_update`; parent identity in `_meta["github.com/copilot"].agentId`. HTTP/SSE MCP supported. | Shared tool activity, authenticated runner-owned MCP bridge. | Real create/bash/read_bash traffic and canonical authenticated get-task-context pass; Product semantic question and plan calls observed. Full adversarial company-boundary coverage remains pending. |
|
||||
| MCP transport selection | Both HTTP and SSE are advertised. | The assigned Paperclip gateway uses the controlled HTTP bridge. Arbitrary SSE endpoint configuration is not exposed. | Observed initialize; SSE remains unused, P2 only if a governed connection requires it. |
|
||||
| Scoped approvals | `session/request_permission`, actual options allow_once/allow_always/reject_once. | Shared durable permissions; only received decisions offered, policy enforced. | Real-service wire ID 0 denied before file creation, with no side effect through cleanup. Durable Product restrictive-mode recovery and wider tool denial remain unqualified. |
|
||||
| Structured questions | Native `ask_user` callback and `user_input.requested`; current ACP adapter does not wire the responder. | Emits capability-gap notice if native notification arrives; cannot claim answer delivery. | Actual agent-mode tool list omits `ask_user` without a suppression flag. Paperclip semantic questions are available: restart case passes, while the separate question case failed its exact marker. P0 qualify other native blocking modes. |
|
||||
| Plan approval | Native `exit_plan_mode` callback; notification contains plan content/actions but lacks qualified ACP responder. | Capability-gap notice only; never synthesize plan acceptance. | Agent-mode tool list omits exit_plan_mode. Paperclip semantic plan/revision approval passes through the UI; native plan mode still requires explicit qualification, P0. |
|
||||
| Structured questions | Native `ask_user` callback and `user_input.requested`; current ACP adapter does not wire the responder. | Emits capability-gap notice if native notification arrives; cannot claim answer delivery. | Actual agent-mode tool list omits `ask_user` without a suppression flag. Paperclip semantic questions are available: restart case passes, while the separate question case failed its exact marker. Pinned offline agent/plan catalogs now prove both tools unavailable; the production guard admits agent mode and terminates any unexpected native blocking request. |
|
||||
| Plan approval | Native `exit_plan_mode` callback; notification contains plan content/actions but lacks qualified ACP responder. | Capability-gap notice only; never synthesize plan acceptance. | Agent-mode tool list omits exit_plan_mode. Paperclip semantic plan/revision approval passes through the UI; Native plan-mode input is confirmed unavailable offline; production mode controls remain disabled. |
|
||||
| Plan progress | Standard plan from todos SQL; native `session.plan_changed` has operation only. | Existing ACP plan/activity; native operation preserved, `planContentAvailable:false`. | Source; plan document reads require native interface. P1. |
|
||||
| Models / reasoning / config | Source `session/set_model`, config options for model, reasoning, mode, custom agents, allow_all. | Explicit model admission. Mode/governance changes must remain policy-gated. | Authenticated catalog, exact set_model/config echo and real inference verified for gpt-5.6-luna. Other models and config-mode changes remain unqualified. |
|
||||
| Usage | Standard prompt usage and context usage; native assistant usage, AI-unit checkpoint. | Token/counter metadata with source; multiplier and nano-AI-units distinct from USD. | Real token counters retain GitHub provenance; authoritative per-turn USD is unavailable. External included-credit snapshots are separate, with additional cash billing disabled. CLI requested-cost coverage fails closed when unknown. Never double-count passthrough. |
|
||||
@@ -171,7 +224,7 @@ Reasons and priorities are explicit:
|
||||
| --- | --- |
|
||||
| Standard text/reasoning/tool/plan/config events | Standard ACP already transports the user-facing content. Additional native metadata is not assumed preserved. P2 compare native field inventory against normalization before adding fields; avoid duplicate output and raw reasoning. |
|
||||
| Standard ACP parent tool content and locations | Confirmed shared normalization gap: structured `content` diff/image blocks, `rawInput`, and locations after the first safe relative path are not carried into canonical tool events. Only bounded `rawOutput`, `inputUpdated`, tool lifecycle/identity, and that first path survive. P1 introduce validated diff/image artifact schemas and preserve every safe location with attribution; retain secret redaction and workspace containment rather than forwarding raw provider objects. The retained offline diff proves harness exposure only. |
|
||||
| Native user/plan/elicitation requests and completions | No qualified ACP responder/correlation acknowledgment. P0 prove there is no blocking input before qualification; add an upstream responder or owned wrapper before displaying an answerable UI. |
|
||||
| Native user/plan/elicitation requests and completions | No qualified ACP responder/correlation acknowledgment. The 2026-09-29 agent/plan probes prove these two native tools unavailable on the pinned offline surface; the connection guard fails closed if any native request appears. Add a versioned upstream responder before displaying an answerable UI. |
|
||||
| Native permission authorization internals, sandbox decisions, recovery | Standard permission callback is the policy decision boundary. P1 collect sanitized denial diagnostics; never let native carried-forward/assent events authorize actions. |
|
||||
| Native usage diagnostics omitted from subscribed assistant.usage | Quota snapshots, reasoning summaries, fusion/RTE payloads and upstream service/cache diagnostics are not normalized. P2 type/redact useful performance details; quotas and model multipliers cannot substitute for verifiable dollar spend. |
|
||||
| Native compaction summaries/checkpoint paths/custom instructions | Avoid copying private instruction/summary bodies or treating provider paths as task paths. P2 add explicit safe metadata schema/artifact retrieval where useful. |
|
||||
|
||||
@@ -5,7 +5,9 @@ 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','attached-shell'],default='deny-write')
|
||||
parser.add_argument('--scenario',choices=['deny-write','deny-shell','deny-read','attached-shell','discover-inputs','native-question','native-plan'],default='deny-write')
|
||||
parser.add_argument('--mode',choices=['agent','plan','autopilot'],default='agent')
|
||||
parser.add_argument('--resume',action='store_true',help='Close and load the same native session before prompting')
|
||||
args=parser.parse_args()
|
||||
package_root=pathlib.Path(args.package_root).resolve()
|
||||
metadata=json.loads((package_root/'package.json').read_text())
|
||||
@@ -14,6 +16,7 @@ assert metadata['version']=='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=[]
|
||||
command='sleep 2; printf ACP_SHELL_DONE > settlement.txt'
|
||||
|
||||
binary=str(package_root/'copilot')
|
||||
@@ -34,22 +37,31 @@ with contextlib.ExitStack() as cleanup:
|
||||
root=pathlib.Path(tempfile.mkdtemp(prefix='paperclip-copilot-offline-'))
|
||||
cleanup.callback(shutil.rmtree, root)
|
||||
calls=0
|
||||
total_model_calls=0
|
||||
session=None
|
||||
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)
|
||||
marker_at_prompt_result=False
|
||||
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,model_tool_names
|
||||
global calls,total_model_calls,model_tool_names,model_tool_results
|
||||
calls+=1
|
||||
total_model_calls+=1
|
||||
data=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))
|
||||
model_tool_names=[x.get('function',{}).get('name') for x in data.get('tools',[])]
|
||||
if calls>4:raise RuntimeError('Fixture model exceeded its bounded request count')
|
||||
model_tool_names=sorted(set(model_tool_names+[x.get('function',{}).get('name') for x in data.get('tools',[]) if x.get('function',{}).get('name')]))
|
||||
model_tool_results.extend(x.get('content') for x in data.get('messages',[]) if x.get('role')=='tool')
|
||||
if total_model_calls>5:raise RuntimeError('Fixture model exceeded its bounded request count')
|
||||
if 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: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':'create' if args.scenario=='deny-write' else 'bash','arguments':json.dumps({'path':str(root/'denied.txt'),'file_text':'MUST NOT EXIST'} if args.scenario=='deny-write' else {'command':command,'description':'Bounded settlement fixture','mode':'async','detach':False})}}]},'finish_reason':None}]},{'id':'fixture-1','object':'chat.completion.chunk','choices':[{'index':0,'delta':{},'finish_reason':'tool_calls'}]}]
|
||||
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'}]}]
|
||||
for value in values:self.wfile.write(('data: '+json.dumps(value)+'\n\n').encode())
|
||||
self.wfile.write(b'data: [DONE]\n\n')
|
||||
else:
|
||||
@@ -58,8 +70,8 @@ with contextlib.ExitStack() as cleanup:
|
||||
cleanup.callback(server.server_close)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
cleanup.callback(server.shutdown)
|
||||
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_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'):pathlib.Path(env[key]).mkdir()
|
||||
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)
|
||||
cleanup.callback(stop_process, p)
|
||||
@@ -93,20 +105,42 @@ with contextlib.ExitStack() as cleanup:
|
||||
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'}})
|
||||
session=request('session/new',{'cwd':str(root),'mcpServers':[]})
|
||||
if 'result' in session:
|
||||
request('session/prompt',{'sessionId':session['result']['sessionId'],'prompt':[{'type':'text','text':'Reply with Fixture complete.'}]})
|
||||
marker_at_prompt_result=(root/('denied.txt' if args.scenario=='deny-write' else 'settlement.txt')).exists()
|
||||
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:
|
||||
# A native session with no conversation is not durably resumable.
|
||||
calls=-1
|
||||
request('session/prompt',{'sessionId':session_id,'prompt':[{'type':'text','text':'Initialize the fixture conversation.'}]})
|
||||
calls=0
|
||||
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)
|
||||
finally:
|
||||
failure=sys.exc_info()[1]
|
||||
try:
|
||||
marker_exists=(root/('denied.txt' if args.scenario=='deny-write' else 'settlement.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,'costUsd':0,'filesystemMarkerExistedAtPromptResult':marker_at_prompt_result,'filesystemMarkerExistedAfterPrompt':marker_exists,'modelCalls':calls,'modelToolNames':model_tool_names,'permissionResponses':permission_responses,'wire':wire}
|
||||
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}
|
||||
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 not session or 'result' not in session or responses.get(3,{}).get('result',{}).get('stopReason')!='end_turn':
|
||||
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:
|
||||
raise RuntimeError('Copilot violated the fixture filesystem expectation')
|
||||
|
||||
if args.scenario.startswith('deny-') and not permission_responses:
|
||||
raise RuntimeError('Copilot did not request permission before the denied operation')
|
||||
if args.scenario=='deny-read' and any('PRIVATE_FIXTURE_SENTINEL' in str(result) for result in model_tool_results):
|
||||
raise RuntimeError('Copilot leaked the denied file to the model')
|
||||
if args.scenario in ('native-question','native-plan','discover-inputs') and any(name in model_tool_names for name in ('ask_user','exit_plan_mode')):
|
||||
raise RuntimeError('Native blocking input is advertised without a qualified responder')
|
||||
|
||||
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')
|
||||
@@ -28,6 +28,24 @@ const HANDLE: AcpRuntimeHandle = {
|
||||
};
|
||||
|
||||
describe("Codex ACPX runtime adapter", () => {
|
||||
it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => {
|
||||
for (const agent of ["copilot", "codex", "claude", "grok"] as const) {
|
||||
const runtime = fakeRuntime();
|
||||
let created!: AcpRuntimeOptions;
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent };
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
||||
});
|
||||
if (agent === "copilot") {
|
||||
expect(created.protocolGuardFactory).toBeTypeOf("function");
|
||||
expect(created.protocolGuardFactory!()).not.toBe(created.protocolGuardFactory!());
|
||||
expect(() => port.startTurn({ text: "\n/yolo on", requestId: "bad" })).toThrow(/admitted permission policy/);
|
||||
expect(runtime.startTurn).not.toHaveBeenCalled();
|
||||
} else expect(created.protocolGuardFactory).toBeUndefined();
|
||||
await port.close({ reason: "test complete" });
|
||||
}
|
||||
});
|
||||
it("rejects forged permission session identifiers before delegating or applying full-auto policy", async () => {
|
||||
const pending = pendingExtensionTurn("turn-1");
|
||||
const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import type { AcpxModelStatus } from "./model-verification.js";
|
||||
import { AcpxApprovalRequiredError, decideAcpxPermission } from "./permission-policy.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "./capability-profiles.js";
|
||||
import { assertCopilotPromptPolicy, createCopilotProtocolGuard } from "./copilot-policy.js";
|
||||
|
||||
const VERIFIED_COMMAND_SENTINEL = "paperclip-verified-acpx-command";
|
||||
const DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS = 2_000;
|
||||
@@ -315,6 +316,9 @@ export async function openQualifiedAcpxRuntime(
|
||||
elicitationModes: ["form"],
|
||||
...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities) }),
|
||||
extensionMethods: [...new Set([...extensionRequests, ...extensionNotifications])],
|
||||
...(options.profile.agent === "copilot" ? {
|
||||
protocolGuardFactory: () => createCopilotProtocolGuard(options.profile.reportedModelId),
|
||||
} : {}),
|
||||
onExtensionRequest: async (method, params, context) => {
|
||||
const active = extensionBoundary.active;
|
||||
if (!extensionRequests.has(method) || !active?.onRequest || !ownsExtensionTurn(active, params) || context.signal.aborted) {
|
||||
@@ -531,6 +535,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
commandLaunches,
|
||||
permissionBoundary,
|
||||
extensionBoundary,
|
||||
options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanupReason = "ACPX runtime identity validation failed";
|
||||
@@ -960,6 +965,7 @@ function runtimePort(
|
||||
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
|
||||
permissionBoundary: { active: AbortController | null; handler?: AcpRuntimeOptions["onPermissionRequest"] },
|
||||
extensionBoundary: AcpxRuntimeExtensionBoundary,
|
||||
assertPromptPolicy?: (text: string) => void,
|
||||
): AcpxRuntimePort {
|
||||
extensionBoundary.sessionIds = new Set([identity.backendSessionId]);
|
||||
let extensionControls: Promise<void> = Promise.resolve();
|
||||
@@ -1317,6 +1323,7 @@ function runtimePort(
|
||||
}
|
||||
: {}),
|
||||
startTurn(input) {
|
||||
assertPromptPolicy?.(input.text);
|
||||
if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn");
|
||||
const approval = new AbortController();
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_AGENT_MODE, assertCopilotConfigPolicy, assertCopilotControlPolicy,
|
||||
assertCopilotPromptPolicy, assertCopilotSessionPolicy, assertCopilotSessionUpdatePolicy,
|
||||
createCopilotProtocolGuard,
|
||||
} from "./copilot-policy.js";
|
||||
|
||||
const fixture = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-acp-denial-1.0.88.json", import.meta.url), "utf8"));
|
||||
const session = fixture.wire.find((message: { result?: { modes?: unknown } }) => message.result?.modes).result;
|
||||
|
||||
describe("Copilot native policy fence", () => {
|
||||
it("admits the pinned real-binary agent/manual snapshot", () => {
|
||||
expect(() => assertCopilotSessionPolicy(session)).not.toThrow();
|
||||
expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "config_option_update", configOptions: session.configOptions })).not.toThrow();
|
||||
});
|
||||
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("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();
|
||||
}
|
||||
});
|
||||
it.each([undefined, null, {}, { modes: session.modes }, { configOptions: session.configOptions },
|
||||
{ ...session, modes: { currentModeId: "https://agentclientprotocol.com/protocol/session-modes#autopilot" } },
|
||||
{ ...session, modes: { currentModeId: "https://agentclientprotocol.com/protocol/session-modes#plan" } },
|
||||
])("rejects missing or unsafe connection proof", state => {
|
||||
expect(() => assertCopilotSessionPolicy(state)).toThrow(/admitted permission policy/);
|
||||
});
|
||||
it("rejects bypass, duplicate, missing, custom-agent and future config authority", () => {
|
||||
const config = session.configOptions;
|
||||
for (const value of [[], [...config, config[0]], config.filter((option: { id: string }) => option.id !== "allow_all"),
|
||||
config.map((option: { id: string }) => option.id === "allow_all" ? { ...option, currentValue: "on" } : option),
|
||||
[...config, { id: "agent", currentValue: "privileged-agent" }], [...config, { id: "new-authority", currentValue: "off" }]]) {
|
||||
expect(() => assertCopilotConfigPolicy(value)).toThrow(/admitted permission policy/);
|
||||
}
|
||||
});
|
||||
it("only permits selecting the admitted explicit model", () => {
|
||||
expect(() => assertCopilotControlPolicy("session/set_model", { modelId: "gpt-5.6-luna" }, "gpt-5.6-luna")).not.toThrow();
|
||||
expect(() => assertCopilotControlPolicy("session/set_config_option", { configId: "model", value: "gpt-5.6-luna" }, "gpt-5.6-luna")).not.toThrow();
|
||||
for (const [method, params] of [["session/set_mode", { modeId: COPILOT_AGENT_MODE }],
|
||||
["session/set_config_option", { configId: "allow_all", value: "on" }],
|
||||
["session/set_config_option", { configId: "mode", value: COPILOT_AGENT_MODE }],
|
||||
["session/set_model", { modelId: "auto" }], ["session/set_model", { modelId: "other" }]] as const) {
|
||||
expect(() => assertCopilotControlPolicy(method, params, "gpt-5.6-luna")).toThrow(/admitted permission policy/);
|
||||
}
|
||||
});
|
||||
it("rejects policy drift without logging provider strings", () => {
|
||||
expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "current_mode_update", currentModeId: "secret-provider-value" }))
|
||||
.toThrowError(expect.objectContaining({ message: expect.not.stringContaining("secret-provider-value") }));
|
||||
expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "current_mode_update", currentModeId: COPILOT_AGENT_MODE })).not.toThrow();
|
||||
expect(() => assertCopilotSessionUpdatePolicy({ sessionUpdate: "config_option_update" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Copilot connection authority", () => {
|
||||
const safe = { ...session, sessionId: "s", configOptions: [...session.configOptions, { id: "model", currentValue: "gpt-5.6-luna" }] };
|
||||
const request = (guard: ReturnType<typeof createCopilotProtocolGuard>, method: string, params: unknown, id = 0) => guard("outbound", { jsonrpc: "2.0", id, method, params });
|
||||
const admit = (guard: ReturnType<typeof createCopilotProtocolGuard>, method = "session/new") => {
|
||||
request(guard, method, method === "session/new" ? {} : { sessionId: "s" });
|
||||
guard("inbound", { id: 0, result: safe });
|
||||
};
|
||||
it.each(["session/new", "session/load", "session/resume"])("requires fresh safe %s response on each connection", method => {
|
||||
const guard = createCopilotProtocolGuard("gpt-5.6-luna");
|
||||
admit(guard, method);
|
||||
expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Complete task" }] }, 1)).not.toThrow();
|
||||
const reconnected = createCopilotProtocolGuard("gpt-5.6-luna");
|
||||
expect(() => request(reconnected, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Complete task" }] })).toThrow();
|
||||
});
|
||||
it("invalidates authority permanently when resumed policy or current policy drifts", () => {
|
||||
for (const bad of [{ sessionUpdate: "current_mode_update", currentModeId: "autopilot" }, { sessionUpdate: "config_option_update", configOptions: [] }]) {
|
||||
const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard);
|
||||
expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: bad } })).toThrow();
|
||||
expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", configOptions: safe.configOptions } } })).toThrow();
|
||||
}
|
||||
});
|
||||
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);
|
||||
expect(() => guard("inbound", { method: "github.com/copilot/sessionEvent", params: { sessionId: "s", type, data: { requestId: 0 } } })).toThrow();
|
||||
}
|
||||
});
|
||||
it("requires the selected model before prompting, including reconnect", () => {
|
||||
const guard = createCopilotProtocolGuard("gpt-5.6-luna"); request(guard, "session/load", { sessionId: "s" });
|
||||
guard("inbound", { id: 0, result: session });
|
||||
expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: "Run" }] }, 1)).toThrow();
|
||||
});
|
||||
it("validates the joined wire text and preserves zero request IDs", () => {
|
||||
const guard = createCopilotProtocolGuard("gpt-5.6-luna"); admit(guard);
|
||||
expect(() => request(guard, "session/prompt", { sessionId: "s", prompt: [{ type: "text", text: " " }, { type: "text", text: "/yolo on" }] })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/** Policy for the exact native Copilot 1.0.88 ACP surface. */
|
||||
export const COPILOT_AGENT_MODE = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
|
||||
export class CopilotPolicyViolationError extends Error {
|
||||
readonly code = "COPILOT_POLICY_VIOLATION";
|
||||
readonly retryable = false;
|
||||
constructor() {
|
||||
super("Copilot session configuration or command cannot preserve the admitted permission policy.");
|
||||
this.name = "CopilotPolicyViolationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native ACP interprets a leading slash as a CLI command, before inference.
|
||||
* Reject every such command rather than maintaining an incomplete list of
|
||||
* aliases/skills that can change mode, permissions, cwd, MCP or remote state.
|
||||
* Apply to the final joined ACP text, including resumed prompts.
|
||||
*/
|
||||
export function assertCopilotPromptPolicy(text: string): void {
|
||||
if (text.trimStart().startsWith("/")) throw new CopilotPolicyViolationError();
|
||||
}
|
||||
|
||||
/** Only explicit model selection is a runner-owned configuration operation. */
|
||||
export function assertCopilotControlPolicy(method: string, params: unknown, expectedModel: string): void {
|
||||
const input = record(params);
|
||||
if (!expectedModel || expectedModel === "auto") throw new CopilotPolicyViolationError();
|
||||
if (method === "session/set_model" && input.modelId === expectedModel) return;
|
||||
if (method === "session/set_config_option" && input.configId === "model" && input.value === expectedModel) return;
|
||||
throw new CopilotPolicyViolationError();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the actual new/load response on every connection, before prompting.
|
||||
* Persisted ACPX status is insufficient: load can restore autopilot and turn
|
||||
* allow-all back on inside the native server. Missing proof fails closed.
|
||||
*/
|
||||
export function assertCopilotSessionPolicy(response: unknown): void {
|
||||
const state = record(response);
|
||||
if (record(state.modes).currentModeId !== COPILOT_AGENT_MODE) throw new CopilotPolicyViolationError();
|
||||
assertCopilotConfigPolicy(state.configOptions);
|
||||
}
|
||||
|
||||
/** Copilot emits full config snapshots, not patches, in config_option_update. */
|
||||
export function assertCopilotConfigPolicy(value: unknown): void {
|
||||
if (!Array.isArray(value) || value.length > 32) throw new CopilotPolicyViolationError();
|
||||
const options = new Map<string, Record<string, unknown>>();
|
||||
for (const candidate of value) {
|
||||
const option = record(candidate);
|
||||
if (typeof option.id !== "string" || options.has(option.id)) throw new CopilotPolicyViolationError();
|
||||
options.set(option.id, option);
|
||||
if (!["mode", "allow_all", "model", "reasoning_effort", "agent"].includes(option.id)) throw new CopilotPolicyViolationError();
|
||||
if (option.id === "agent" && option.currentValue !== "") throw new CopilotPolicyViolationError();
|
||||
}
|
||||
if (options.get("mode")?.currentValue !== COPILOT_AGENT_MODE || options.get("allow_all")?.currentValue !== "off") {
|
||||
throw new CopilotPolicyViolationError();
|
||||
}
|
||||
}
|
||||
|
||||
/** Call synchronously for owned-session updates; any exception aborts authority. */
|
||||
export function assertCopilotSessionUpdatePolicy(value: unknown): void {
|
||||
const update = record(value);
|
||||
if (update.sessionUpdate === "current_mode_update" && update.currentModeId !== COPILOT_AGENT_MODE) {
|
||||
throw new CopilotPolicyViolationError();
|
||||
}
|
||||
if (update.sessionUpdate === "config_option_update") assertCopilotConfigPolicy(update.configOptions);
|
||||
}
|
||||
|
||||
/** One guard per actual stdio connection; never restore admission from disk. */
|
||||
export function createCopilotProtocolGuard(expectedModel: string): (direction: "inbound" | "outbound", value: unknown) => void {
|
||||
const pending = new Map<string | number, { method: string; sessionId?: string }>();
|
||||
const sessions = new Set<string>();
|
||||
const models = new Map<string, unknown>();
|
||||
const modelFrom = (result: unknown) => {
|
||||
const options = record(result).configOptions;
|
||||
return Array.isArray(options) ? record(options.find(option => record(option).id === "model")).currentValue : undefined;
|
||||
};
|
||||
let failed = false;
|
||||
return (direction, value) => {
|
||||
if (failed) throw new CopilotPolicyViolationError();
|
||||
try {
|
||||
const message = record(value);
|
||||
const params = record(message.params);
|
||||
const id = message.id;
|
||||
const method = message.method;
|
||||
if (direction === "outbound" && typeof method === "string") {
|
||||
if (method.startsWith("session/") && !["session/new", "session/load", "session/resume"].includes(method)) {
|
||||
if (typeof params.sessionId !== "string" || !sessions.has(params.sessionId)) throw new CopilotPolicyViolationError();
|
||||
}
|
||||
if (method === "session/prompt") {
|
||||
if (models.get(String(params.sessionId)) !== expectedModel) throw new CopilotPolicyViolationError();
|
||||
if (!Array.isArray(params.prompt) || params.prompt.some(block => record(block).type !== "text" || typeof record(block).text !== "string")) throw new CopilotPolicyViolationError();
|
||||
assertCopilotPromptPolicy(params.prompt.map(block => record(block).text).join("\n"));
|
||||
} else if (method.startsWith("session/set_")) {
|
||||
assertCopilotControlPolicy(method, params, expectedModel);
|
||||
} else if (!["initialize", "authenticate", "session/new", "session/load", "session/resume", "session/cancel", "session/close"].includes(method)) {
|
||||
throw new CopilotPolicyViolationError();
|
||||
}
|
||||
if (typeof id === "string" || typeof id === "number") {
|
||||
if (pending.has(id) || pending.size >= 128) throw new CopilotPolicyViolationError();
|
||||
pending.set(id, { method, ...(typeof params.sessionId === "string" ? { sessionId: params.sessionId } : {}) });
|
||||
}
|
||||
}
|
||||
if (direction === "inbound" && method === undefined && (typeof id === "string" || typeof id === "number")) {
|
||||
const request = pending.get(id);
|
||||
pending.delete(id);
|
||||
if (request && message.error === undefined) {
|
||||
if (["session/new", "session/load", "session/resume"].includes(request.method)) {
|
||||
assertCopilotSessionPolicy(message.result);
|
||||
const sessionId = request.sessionId ?? record(message.result).sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId) throw new CopilotPolicyViolationError();
|
||||
sessions.add(sessionId);
|
||||
models.set(sessionId, modelFrom(message.result));
|
||||
} else if (request.method === "session/set_config_option") {
|
||||
assertCopilotConfigPolicy(record(message.result).configOptions);
|
||||
if (request.sessionId) models.set(request.sessionId, modelFrom(message.result));
|
||||
} else if (request.method === "session/set_model" && request.sessionId) {
|
||||
models.set(request.sessionId, expectedModel);
|
||||
} else if (request.method === "session/close" && request.sessionId) sessions.delete(request.sessionId);
|
||||
}
|
||||
}
|
||||
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.
|
||||
assertCopilotSessionUpdatePolicy(params.update);
|
||||
if (record(params.update).sessionUpdate === "config_option_update" && typeof params.sessionId === "string" && sessions.has(params.sessionId)) {
|
||||
const model = modelFrom(params.update);
|
||||
if (models.get(params.sessionId) === expectedModel && model !== expectedModel) throw new CopilotPolicyViolationError();
|
||||
models.set(params.sessionId, model);
|
||||
}
|
||||
}
|
||||
if (direction === "inbound" && method === "github.com/copilot/sessionEvent") {
|
||||
if (["user_input.requested", "exit_plan_mode.requested", "elicitation.requested"].includes(String(params.type))) throw new CopilotPolicyViolationError();
|
||||
const data = record(params.data);
|
||||
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;
|
||||
sessions.clear();
|
||||
models.clear();
|
||||
pending.clear();
|
||||
throw new CopilotPolicyViolationError();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { COPILOT_AGENT_MODE, createCopilotProtocolGuard } from "./copilot-policy.js";
|
||||
|
||||
// Exercise the installed, patched wire stream, not a mock observer. This is a
|
||||
// pinned internal bundle export; the package-contract suite guards its version.
|
||||
const bundle = new URL("./dist/live-checkpoint-BSIrfgVo.js", pathToFileURL(createRequire(import.meta.url).resolve("acpx/package.json")));
|
||||
const { k: AcpClient } = await import(bundle.href);
|
||||
const safe = { sessionId: "s", modes: { currentModeId: COPILOT_AGENT_MODE }, configOptions: [
|
||||
{ id: "mode", currentValue: COPILOT_AGENT_MODE }, { id: "allow_all", currentValue: "off" }, { id: "model", currentValue: "gpt-5.6-luna" },
|
||||
] };
|
||||
|
||||
function connection(guarded = true) {
|
||||
const written: unknown[] = [];
|
||||
let inbound!: ReadableStreamDefaultController;
|
||||
const kill = vi.fn();
|
||||
const client = new AcpClient({ cwd: "/tmp", agentCommand: "fixture-never-spawned", permissionMode: "deny-all",
|
||||
...(guarded ? { protocolGuardFactory: () => createCopilotProtocolGuard("gpt-5.6-luna") } : {}),
|
||||
});
|
||||
client.agent = { kill };
|
||||
const stream = client.createTappedStream({
|
||||
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 };
|
||||
}
|
||||
|
||||
describe("patched ACPX admission guard", () => {
|
||||
it.each(["session/new", "session/load", "session/resume"])("rejects unsafe %s before it reaches the SDK", async method => {
|
||||
const c = connection();
|
||||
await c.writer.write({ id: 0, method, params: { sessionId: "s" } });
|
||||
const read = c.reader.read();
|
||||
c.inbound.enqueue({ id: 0, result: { ...safe, modes: { currentModeId: "autopilot" } } });
|
||||
await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" });
|
||||
expect(c.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
it("rejects permission controls before any bytes reach the provider", 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 expect(c.writer.write({ id: 1, method: "session/set_config_option", params: { sessionId: "s", configId: "allow_all", value: "on" } })).rejects.toThrow(/admitted permission policy/);
|
||||
expect(c.written).toHaveLength(1); expect(c.kill).toHaveBeenCalledOnce();
|
||||
c.inbound.close();
|
||||
});
|
||||
it("errors and stops the provider on drift even without a notification observer", 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: "Perform task" }] } });
|
||||
const read = c.reader.read();
|
||||
c.inbound.enqueue({ method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "current_mode_update", currentModeId: "autopilot" } } });
|
||||
await expect(read).rejects.toMatchObject({ code: "COPILOT_POLICY_VIOLATION" });
|
||||
expect(c.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
it("preserves unguarded provider wire behavior", async () => {
|
||||
const c = connection(false);
|
||||
const message = { method: "session/update", params: { update: { sessionUpdate: "current_mode_update", currentModeId: "other-provider-mode" } } };
|
||||
c.inbound.enqueue(message);
|
||||
expect((await c.reader.read()).value).toEqual(message);
|
||||
expect(c.kill).not.toHaveBeenCalled();
|
||||
c.inbound.close();
|
||||
});
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"schema": "paperclip.copilot-policy-conformance.v1",
|
||||
"providerVersion": "1.0.88",
|
||||
"platform": "darwin-arm64",
|
||||
"executableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3",
|
||||
"sourceBaseRevision": "9f4476feb87df5574edf121b86a1093efb006ac3",
|
||||
"authentication": "none; explicit fresh environment contains no credential values",
|
||||
"modelTransport": "COPILOT_OFFLINE=true; COPILOT_PROVIDER_TYPE=openai; COPILOT_PROVIDER_BASE_URL points only to ephemeral 127.0.0.1 Python HTTPServer; fixture model gpt-4.1 is synthetic",
|
||||
"paidProviderCalls": 0,
|
||||
"modelSpendUsd": 0,
|
||||
"qualification": "offline protocol evidence only; no GitHub model, final pack, or Daytona qualification",
|
||||
"attempts": [
|
||||
{
|
||||
"evidenceFile": "agent-inputs.json",
|
||||
"sha256": "3def3bcf95db103611722d5e04f803bed555c7c8274a1833c661ef9d87a4b757",
|
||||
"scenario": "discover-inputs",
|
||||
"mode": "agent",
|
||||
"resumed": false,
|
||||
"loopbackModelRequests": 1,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": false,
|
||||
"permissionRequestCount": 0,
|
||||
"permissionRequestIds": [],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "plan-inputs.json",
|
||||
"sha256": "446ee3a2b9e5e4ac3249a8ae18a09f1c33fadc75f94a98d8c6f5fb50a8a73b61",
|
||||
"scenario": "discover-inputs",
|
||||
"mode": "plan",
|
||||
"resumed": false,
|
||||
"loopbackModelRequests": 1,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": false,
|
||||
"permissionRequestCount": 0,
|
||||
"permissionRequestIds": [],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "native-question.json",
|
||||
"sha256": "1cdff42aa31e27a796ecceb4d4fea89c271737a0094982956bb228f03752e21d",
|
||||
"scenario": "native-question",
|
||||
"mode": "agent",
|
||||
"resumed": false,
|
||||
"loopbackModelRequests": 2,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": true,
|
||||
"permissionRequestCount": 0,
|
||||
"permissionRequestIds": [],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "native-plan.json",
|
||||
"sha256": "fecfca013ea072e4e880c77b244b42843f25486206c2e8f64411216ad5c738ea",
|
||||
"scenario": "native-plan",
|
||||
"mode": "plan",
|
||||
"resumed": false,
|
||||
"loopbackModelRequests": 2,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": true,
|
||||
"permissionRequestCount": 0,
|
||||
"permissionRequestIds": [],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "deny-read.json",
|
||||
"sha256": "88d1ad8865b3252963ce12178d6a3a3819f50a2c20ddf95ab557f1cacd127975",
|
||||
"scenario": "deny-read",
|
||||
"mode": "agent",
|
||||
"resumed": false,
|
||||
"loopbackModelRequests": 1,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": false,
|
||||
"permissionRequestCount": 1,
|
||||
"permissionRequestIds": [
|
||||
0
|
||||
],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "resumed-deny-shell.json",
|
||||
"sha256": "3826ca1247b99c3000bff1e8e0e53d0244c0c22f8ec3089b6ca83bfba80ae3bb",
|
||||
"scenario": "deny-shell",
|
||||
"mode": "agent",
|
||||
"resumed": true,
|
||||
"loopbackModelRequests": 0,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": false,
|
||||
"permissionRequestCount": 0,
|
||||
"permissionRequestIds": [],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [
|
||||
-32002
|
||||
],
|
||||
"disposition": "probe_setup_failure"
|
||||
},
|
||||
{
|
||||
"evidenceFile": "resumed-deny-shell-attempt-2.json",
|
||||
"sha256": "14bdd262303d9273c3612d502fefb4b6f887ce903af590a58bbaea3e00297ff6",
|
||||
"scenario": "deny-shell",
|
||||
"mode": "agent",
|
||||
"resumed": true,
|
||||
"loopbackModelRequests": 2,
|
||||
"nativeInputToolsAdvertised": [],
|
||||
"forcedToolRejectedAsUnavailable": false,
|
||||
"permissionRequestCount": 1,
|
||||
"permissionRequestIds": [
|
||||
0
|
||||
],
|
||||
"markerPresent": false,
|
||||
"rpcErrorCodes": [],
|
||||
"disposition": "passed"
|
||||
}
|
||||
],
|
||||
"emptySessionLoadDiagnosis": "Copilot does not retain an empty conversation for load. Initial resource-not-found retained; second attempt seeds one loopback-only text turn before close/load. It then requests shell permission and rejects the write."
|
||||
}
|
||||
+72
-44
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts
|
||||
index 5e2113ac0..45aff03c3 100644
|
||||
index 5e2113a..45aff03 100644
|
||||
--- a/dist/client-CxNllqui.d.ts
|
||||
+++ b/dist/client-CxNllqui.d.ts
|
||||
@@ -120,6 +120,8 @@ declare class AcpClient {
|
||||
@@ -20,7 +20,7 @@ index 5e2113ac0..45aff03c3 100644
|
||||
private setSessionModelThroughConfig;
|
||||
private setSessionModelThroughLegacyMethod;
|
||||
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
|
||||
index d454fd7c5..330b80394 100644
|
||||
index d454fd7..bf3a537 100644
|
||||
--- a/dist/live-checkpoint-BSIrfgVo.js
|
||||
+++ b/dist/live-checkpoint-BSIrfgVo.js
|
||||
@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) {
|
||||
@@ -268,10 +268,22 @@ index d454fd7c5..330b80394 100644
|
||||
clientInfo: resolveClientInfo(launch.devinAcp)
|
||||
});
|
||||
const initialized = launch.geminiAcp ? await withTimeout(initializePromise, resolveGeminiAcpStartupTimeoutMs()) : await initializePromise;
|
||||
@@ -4358,6 +4430,36 @@ var AcpClient = class {
|
||||
@@ -4358,6 +4430,48 @@ var AcpClient = class {
|
||||
throw normalizedError;
|
||||
}
|
||||
createTappedStream(base) {
|
||||
+ // Policy admission is distinct from best-effort observation. Each new
|
||||
+ // stdio connection gets fresh authority and a violation tears it down.
|
||||
+ const protocolGuard = this.options.protocolGuardFactory?.();
|
||||
+ const enforceProtocol = (direction, message) => {
|
||||
+ try { protocolGuard?.(direction, message); }
|
||||
+ catch (error) {
|
||||
+ abortResponseDeliveries();
|
||||
+ this.activePrompt?.elicitationController.abort(error);
|
||||
+ try { this.agent?.kill("SIGTERM"); } catch {}
|
||||
+ throw error;
|
||||
+ }
|
||||
+ };
|
||||
+ const deliveries = new Map();
|
||||
+ const scope = new AbortController();
|
||||
+ const abortResponseDeliveries = () => scope.abort(new Error("ACP response connection closed before delivery"));
|
||||
@@ -305,7 +317,7 @@ index d454fd7c5..330b80394 100644
|
||||
const onAcpMessage = () => this.eventHandlers.onAcpMessage;
|
||||
const onAcpOutputMessage = () => this.eventHandlers.onAcpOutputMessage;
|
||||
const elicitationRequestIds = /* @__PURE__ */ new Set();
|
||||
@@ -4367,8 +4469,12 @@ var AcpClient = class {
|
||||
@@ -4367,8 +4481,12 @@ var AcpClient = class {
|
||||
const shouldSuppressInboundReplaySessionUpdate = (message) => {
|
||||
return this.suppressReplaySessionUpdateMessages && isSessionUpdateNotification(message);
|
||||
};
|
||||
@@ -319,7 +331,7 @@ index d454fd7c5..330b80394 100644
|
||||
if (requestId !== void 0) {
|
||||
elicitationRequestIds.add(requestId);
|
||||
return;
|
||||
@@ -4379,6 +4485,7 @@ var AcpClient = class {
|
||||
@@ -4379,6 +4497,7 @@ var AcpClient = class {
|
||||
}
|
||||
};
|
||||
return {
|
||||
@@ -327,9 +339,11 @@ index d454fd7c5..330b80394 100644
|
||||
readable: new ReadableStream({ async start(controller) {
|
||||
const reader = base.readable.getReader();
|
||||
try {
|
||||
@@ -4387,10 +4494,17 @@ var AcpClient = class {
|
||||
@@ -4386,16 +4505,27 @@ var AcpClient = class {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
+ enforceProtocol("inbound", value);
|
||||
observeInbound(value);
|
||||
- if (isExtensionNotification(value)) continue;
|
||||
+ if (onCursorSubagentNotification(value)) continue;
|
||||
@@ -339,14 +353,23 @@ index d454fd7c5..330b80394 100644
|
||||
+ }
|
||||
controller.enqueue(value);
|
||||
}
|
||||
+ controller.close();
|
||||
+ } catch (error) {
|
||||
+ controller.error(error);
|
||||
} finally {
|
||||
+ abortResponseDeliveries();
|
||||
+ for (const receipt of deliveries.values()) receipt.cleanup();
|
||||
+ deliveries.clear();
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
- controller.close();
|
||||
}
|
||||
@@ -4403,10 +4517,20 @@ var AcpClient = class {
|
||||
} }),
|
||||
writable: new WritableStream({ async write(message) {
|
||||
+ enforceProtocol("outbound", message);
|
||||
const promptOwner = promptRequestOwner(message);
|
||||
if (promptOwner) bindPromptOwner(promptOwner);
|
||||
const id = responseId(message);
|
||||
@@ -4403,10 +4533,20 @@ var AcpClient = class {
|
||||
onAcpOutputMessage()?.("outbound", message);
|
||||
onAcpMessage()?.("outbound", message);
|
||||
}
|
||||
@@ -367,7 +390,7 @@ index d454fd7c5..330b80394 100644
|
||||
writer.releaseLock();
|
||||
}
|
||||
} })
|
||||
@@ -4422,7 +4546,10 @@ var AcpClient = class {
|
||||
@@ -4422,7 +4562,10 @@ var AcpClient = class {
|
||||
const createPromise = this.runConnectionRequest(() => connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: this.options.mcpServers ?? [],
|
||||
@@ -379,7 +402,7 @@ index d454fd7c5..330b80394 100644
|
||||
}));
|
||||
result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise;
|
||||
} catch (error) {
|
||||
@@ -4864,7 +4991,7 @@ var AcpClient = class {
|
||||
@@ -4864,7 +5007,7 @@ var AcpClient = class {
|
||||
}
|
||||
selectAuthMethod(methods) {
|
||||
for (const method of methods) {
|
||||
@@ -388,7 +411,7 @@ index d454fd7c5..330b80394 100644
|
||||
if (envCredential) return {
|
||||
methodId: method.id,
|
||||
credential: envCredential,
|
||||
@@ -4890,7 +5017,7 @@ var AcpClient = class {
|
||||
@@ -4890,7 +5033,7 @@ var AcpClient = class {
|
||||
}
|
||||
readAgentSpecificEnvCredential(methodId) {
|
||||
if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return;
|
||||
@@ -397,7 +420,7 @@ index d454fd7c5..330b80394 100644
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : void 0;
|
||||
}
|
||||
selectAgentManagedAuthMethod(methodId) {
|
||||
@@ -4915,9 +5042,9 @@ var AcpClient = class {
|
||||
@@ -4915,9 +5058,9 @@ var AcpClient = class {
|
||||
await connection.authenticate({ methodId: selected.methodId });
|
||||
this.log(`authenticated with method ${selected.methodId} (${selected.source})`);
|
||||
}
|
||||
@@ -409,7 +432,7 @@ index d454fd7c5..330b80394 100644
|
||||
if (hostResponse) return hostResponse;
|
||||
const { response, recorded } = await this.resolvePermissionRequestFromMode(params);
|
||||
if (!recorded) {
|
||||
@@ -4926,14 +5053,81 @@ var AcpClient = class {
|
||||
@@ -4926,14 +5069,81 @@ var AcpClient = class {
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -493,7 +516,7 @@ index d454fd7c5..330b80394 100644
|
||||
})).then((response) => ({
|
||||
kind: "response",
|
||||
response
|
||||
@@ -4970,7 +5164,7 @@ var AcpClient = class {
|
||||
@@ -4970,7 +5180,7 @@ var AcpClient = class {
|
||||
isElicitationSessionCancelling(sessionId) {
|
||||
return this.closing || this.cancellingSessionIds.has(sessionId);
|
||||
}
|
||||
@@ -502,7 +525,7 @@ index d454fd7c5..330b80394 100644
|
||||
if (!this.options.onPermissionRequest) return;
|
||||
const signal = this.cancellationSignalForSession(params.sessionId);
|
||||
try {
|
||||
@@ -4978,7 +5172,7 @@ var AcpClient = class {
|
||||
@@ -4978,7 +5188,7 @@ var AcpClient = class {
|
||||
sessionId: params.sessionId,
|
||||
raw: params,
|
||||
inferredKind: inferToolKind(params)
|
||||
@@ -511,7 +534,7 @@ index d454fd7c5..330b80394 100644
|
||||
return this.hostPermissionDecisionResponse(params, signal, decision);
|
||||
} catch (error) {
|
||||
return this.hostPermissionErrorResponse(params, signal, error);
|
||||
@@ -5028,6 +5222,12 @@ var AcpClient = class {
|
||||
@@ -5028,6 +5238,12 @@ var AcpClient = class {
|
||||
attachAgentLifecycleObservers(child) {
|
||||
child.once("exit", (exitCode, signal) => {
|
||||
this.recordAgentExit("process_exit", exitCode, signal);
|
||||
@@ -524,7 +547,7 @@ index d454fd7c5..330b80394 100644
|
||||
});
|
||||
child.once("close", (exitCode, signal) => {
|
||||
this.recordAgentExit("process_close", exitCode, signal);
|
||||
@@ -5771,10 +5971,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN
|
||||
@@ -5771,10 +5987,20 @@ function recordSessionUpdate(conversation, state, notification, timestamp = isoN
|
||||
trimConversationForRuntime(conversation);
|
||||
return acpx;
|
||||
}
|
||||
@@ -547,7 +570,7 @@ index d454fd7c5..330b80394 100644
|
||||
updateConversationTimestamp(conversation, timestamp);
|
||||
trimConversationForRuntime(conversation);
|
||||
return true;
|
||||
@@ -6435,6 +6645,27 @@ async function withConnectedSession(options) {
|
||||
@@ -6435,6 +6661,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;
|
||||
@@ -575,7 +598,7 @@ index d454fd7c5..330b80394 100644
|
||||
async function runPromptTurn(params) {
|
||||
try {
|
||||
const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation);
|
||||
@@ -6444,7 +6675,21 @@ async function runPromptTurn(params) {
|
||||
@@ -6444,7 +6691,21 @@ async function runPromptTurn(params) {
|
||||
idleMs: SESSION_REPLY_IDLE_MS,
|
||||
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
|
||||
}).catch(() => {});
|
||||
@@ -597,7 +620,7 @@ index d454fd7c5..330b80394 100644
|
||||
return {
|
||||
stopReason: response.stopReason,
|
||||
source: "rpc"
|
||||
@@ -6518,4 +6763,4 @@ var LiveSessionCheckpoint = class {
|
||||
@@ -6518,4 +6779,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 };
|
||||
|
||||
@@ -605,7 +628,7 @@ index d454fd7c5..330b80394 100644
|
||||
\ No newline at end of file
|
||||
+//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map
|
||||
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
|
||||
index e8102acb0..d3a9ba626 100644
|
||||
index e8102ac..377c5d8 100644
|
||||
--- a/dist/runtime.d.ts
|
||||
+++ b/dist/runtime.d.ts
|
||||
@@ -1,7 +1,8 @@
|
||||
@@ -649,7 +672,7 @@ index e8102acb0..d3a9ba626 100644
|
||||
} | {
|
||||
type: "tool_call";
|
||||
text: string;
|
||||
@@ -312,7 +323,49 @@ type AcpRuntimeOptions = {
|
||||
@@ -312,7 +323,51 @@ type AcpRuntimeOptions = {
|
||||
elicitationModes?: readonly AcpElicitationMode[];
|
||||
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
|
||||
signal: AbortSignal;
|
||||
@@ -685,6 +708,8 @@ index e8102acb0..d3a9ba626 100644
|
||||
+ signal: NodeJS.Signals | null;
|
||||
+ exitedAt: string;
|
||||
+ }) => void;
|
||||
+ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */
|
||||
+ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void;
|
||||
+ onAcpMessage?: (direction: "inbound" | "outbound", message: unknown) => void;
|
||||
+ /** Ephemeral full ACP notification callback; never written to session records. */
|
||||
+ onSessionNotification?: (notification: SessionNotification) => void;
|
||||
@@ -699,7 +724,7 @@ index e8102acb0..d3a9ba626 100644
|
||||
};
|
||||
type AcpFileSessionStoreOptions = {
|
||||
stateDir: string;
|
||||
@@ -369,6 +422,7 @@ declare class AcpRuntimeManager {
|
||||
@@ -369,6 +424,7 @@ declare class AcpRuntimeManager {
|
||||
private createAndSaveRuntimeRecord;
|
||||
private retainInitializedSessionOwner;
|
||||
startTurn(input: {
|
||||
@@ -707,7 +732,7 @@ index e8102acb0..d3a9ba626 100644
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
@@ -414,6 +468,7 @@ declare class AcpRuntimeManager {
|
||||
@@ -414,6 +470,7 @@ declare class AcpRuntimeManager {
|
||||
private cleanupRuntimeTurn;
|
||||
private finalizeRuntimeTurnRecord;
|
||||
runTurn(input: {
|
||||
@@ -716,7 +741,7 @@ index e8102acb0..d3a9ba626 100644
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
diff --git a/dist/runtime.js b/dist/runtime.js
|
||||
index a1f4a70a0..fe2484de6 100644
|
||||
index a1f4a70..09f659b 100644
|
||||
--- a/dist/runtime.js
|
||||
+++ b/dist/runtime.js
|
||||
@@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = {
|
||||
@@ -763,7 +788,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
function normalizeUsageCost(value) {
|
||||
if (!isRecord(value)) return;
|
||||
const amount = asOptionalFiniteNumber(value.amount);
|
||||
@@ -812,7 +840,21 @@ var AcpRuntimeManager = class {
|
||||
@@ -812,7 +840,22 @@ var AcpRuntimeManager = class {
|
||||
this.deps = deps;
|
||||
}
|
||||
createClient(options) {
|
||||
@@ -778,6 +803,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
+ onAgentExit: this.options.onAgentExit,
|
||||
+ onAcpMessage: this.options.onAcpMessage,
|
||||
+ clientCapabilities: this.options.clientCapabilities,
|
||||
+ protocolGuardFactory: this.options.protocolGuardFactory,
|
||||
+ extensionMethods: this.options.extensionMethods,
|
||||
+ onExtensionRequest: this.options.onExtensionRequest,
|
||||
+ onExtensionNotification: this.options.onExtensionNotification
|
||||
@@ -786,7 +812,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
}
|
||||
createSessionOwner(input) {
|
||||
const owner = {
|
||||
@@ -821,12 +863,16 @@ var AcpRuntimeManager = class {
|
||||
@@ -821,12 +864,16 @@ var AcpRuntimeManager = class {
|
||||
pendingSessionUpdates: []
|
||||
};
|
||||
input.client.setEventHandlers({
|
||||
@@ -803,7 +829,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
const active = owner.activeTurn;
|
||||
if (active) {
|
||||
const { task, turn } = active;
|
||||
@@ -863,6 +909,9 @@ var AcpRuntimeManager = class {
|
||||
@@ -863,6 +910,9 @@ var AcpRuntimeManager = class {
|
||||
projection.checkpoint.request();
|
||||
}
|
||||
routeOwnedClientOperation(owner, operation) {
|
||||
@@ -813,7 +839,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
const active = owner.activeTurn;
|
||||
if (!active) return;
|
||||
const { task, turn } = active;
|
||||
@@ -1008,6 +1057,8 @@ var AcpRuntimeManager = class {
|
||||
@@ -1008,6 +1058,8 @@ var AcpRuntimeManager = class {
|
||||
record.closedAt = void 0;
|
||||
record.protocolVersion = owner.client.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = owner.client.initializeResult?.agentCapabilities;
|
||||
@@ -822,7 +848,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot());
|
||||
}
|
||||
async finishBufferedOwnerControl(owner, record) {
|
||||
@@ -1086,6 +1137,11 @@ var AcpRuntimeManager = class {
|
||||
@@ -1086,6 +1138,11 @@ var AcpRuntimeManager = class {
|
||||
record.closed = false;
|
||||
record.closedAt = void 0;
|
||||
this.closingActiveRecords.delete(record.acpxRecordId);
|
||||
@@ -834,7 +860,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
await this.options.sessionStore.save(record);
|
||||
return record;
|
||||
}
|
||||
@@ -1149,6 +1205,8 @@ var AcpRuntimeManager = class {
|
||||
@@ -1149,6 +1206,8 @@ var AcpRuntimeManager = class {
|
||||
this.closingActiveRecords.delete(record.acpxRecordId);
|
||||
record.protocolVersion = client.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = client.initializeResult?.agentCapabilities;
|
||||
@@ -843,7 +869,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
applyConfigOptionsToRecord(record, session.sessionResult);
|
||||
const modelApplication = await applyRequestedModelIfAdvertised({
|
||||
client,
|
||||
@@ -1301,6 +1359,7 @@ var AcpRuntimeManager = class {
|
||||
@@ -1301,6 +1360,7 @@ var AcpRuntimeManager = class {
|
||||
client: turn.client,
|
||||
sessionId,
|
||||
prompt: task.promptInput,
|
||||
@@ -851,7 +877,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs,
|
||||
conversation: turn.conversation,
|
||||
promptMessageId: turn.promptMessageId,
|
||||
@@ -1469,6 +1528,10 @@ var AcpRuntimeManager = class {
|
||||
@@ -1469,6 +1529,10 @@ var AcpRuntimeManager = class {
|
||||
setSessionConfigOption: async (configId, value) => {
|
||||
return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response;
|
||||
},
|
||||
@@ -862,7 +888,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value)
|
||||
};
|
||||
}
|
||||
@@ -1575,6 +1638,8 @@ var AcpRuntimeManager = class {
|
||||
@@ -1575,6 +1639,8 @@ var AcpRuntimeManager = class {
|
||||
reconcileAgentSessionId(turn.record, turn.record.agentSessionId);
|
||||
turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion;
|
||||
turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities;
|
||||
@@ -871,7 +897,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
turn.record.acpx = turn.acpxState;
|
||||
applyConversation(turn.record, turn.conversation);
|
||||
applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot());
|
||||
@@ -1707,6 +1772,17 @@ var AcpRuntimeManager = class {
|
||||
@@ -1707,6 +1773,17 @@ var AcpRuntimeManager = class {
|
||||
});
|
||||
await this.options.sessionStore.save(result.record);
|
||||
}
|
||||
@@ -889,7 +915,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
async cancel(handle) {
|
||||
await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt();
|
||||
}
|
||||
@@ -2055,6 +2131,7 @@ var AcpxRuntime = class {
|
||||
@@ -2055,6 +2132,7 @@ var AcpxRuntime = class {
|
||||
const turnPromise = this.getManager().then((manager) => manager.startTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
@@ -897,7 +923,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
sessionMode: state.mode,
|
||||
@@ -2087,6 +2164,7 @@ var AcpxRuntime = class {
|
||||
@@ -2087,6 +2165,7 @@ var AcpxRuntime = class {
|
||||
yield* (await this.getManager()).runTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
@@ -905,7 +931,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
sessionMode: state.mode,
|
||||
@@ -2119,6 +2197,14 @@ var AcpxRuntime = class {
|
||||
@@ -2119,6 +2198,14 @@ var AcpxRuntime = class {
|
||||
const { handle, state } = this.resolveManagerHandle(input.handle);
|
||||
await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode);
|
||||
}
|
||||
@@ -920,7 +946,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
async cancel(input) {
|
||||
const { handle } = this.resolveManagerHandle(input.handle);
|
||||
await (await this.getManager()).cancel(handle);
|
||||
@@ -2178,4 +2264,4 @@ function createRuntimeStore(options) {
|
||||
@@ -2178,4 +2265,4 @@ function createRuntimeStore(options) {
|
||||
//#endregion
|
||||
export { ACPX_BACKEND_ID, AcpRuntimeError, AcpxRuntime, DEFAULT_AGENT_NAME, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, REQUESTED_MODEL_UNSUPPORTED_REASONS, RequestedModelUnsupportedError, createAcpRuntime, createAgentRegistry, createFileSessionStore, createRuntimeStore, decodeAcpxRuntimeHandleState, encodeAcpxRuntimeHandleState, isAcpRuntimeError, isRequestedModelUnsupportedError };
|
||||
|
||||
@@ -928,7 +954,7 @@ index a1f4a70a0..fe2484de6 100644
|
||||
\ No newline at end of file
|
||||
+//# sourceMappingURL=runtime.js.map
|
||||
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
|
||||
index c3da16452..5ccc8dda1 100644
|
||||
index c3da164..05030ec 100644
|
||||
--- a/dist/session-options-DwRDODlr.d.ts
|
||||
+++ b/dist/session-options-DwRDODlr.d.ts
|
||||
@@ -1,4 +1,5 @@
|
||||
@@ -946,7 +972,7 @@ index c3da16452..5ccc8dda1 100644
|
||||
};
|
||||
type AcpElicitationResponseMeta = {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
@@ -116,6 +119,35 @@ type AcpClientOptions = {
|
||||
@@ -116,6 +119,37 @@ type AcpClientOptions = {
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
@@ -979,10 +1005,12 @@ index c3da16452..5ccc8dda1 100644
|
||||
+ signal: NodeJS.Signals | null;
|
||||
+ exitedAt: string;
|
||||
+ }) => void;
|
||||
+ /** Synchronous admission guard. Exceptions fail the connection, never an observer. */
|
||||
+ protocolGuardFactory?: () => (direction: "inbound" | "outbound", message: unknown) => void;
|
||||
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||
@@ -123,6 +155,8 @@ type AcpClientOptions = {
|
||||
@@ -123,6 +157,8 @@ type AcpClientOptions = {
|
||||
onPermissionEscalation?: (event: PermissionEscalationEvent) => void;
|
||||
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
|
||||
signal: AbortSignal;
|
||||
@@ -991,7 +1019,7 @@ index c3da16452..5ccc8dda1 100644
|
||||
}) => Promise<AcpPermissionDecision | undefined>;
|
||||
};
|
||||
declare const SESSION_RECORD_SCHEMA: "acpx.session.v1";
|
||||
@@ -263,12 +297,15 @@ type SessionRecord = {
|
||||
@@ -263,12 +299,15 @@ type SessionRecord = {
|
||||
lastAgentDisconnectReason?: string;
|
||||
protocolVersion?: number;
|
||||
agentCapabilities?: AgentCapabilities;
|
||||
@@ -1008,7 +1036,7 @@ index c3da16452..5ccc8dda1 100644
|
||||
acpx?: SessionAcpxState;
|
||||
importedFrom?: SessionImportedFrom;
|
||||
};
|
||||
@@ -295,4 +332,4 @@ type SessionAgentOptions = {
|
||||
@@ -295,4 +334,4 @@ type SessionAgentOptions = {
|
||||
};
|
||||
//#endregion
|
||||
export { SessionRecord as _, AcpElicitationHandler as a, AcpElicitationResponse as c, AuthPolicy as d, McpServer$1 as f, PermissionStats as g, PermissionPolicy as h, AcpElicitationContext as i, AcpPermissionDecision as l, PermissionMode as m, SystemPromptOption as n, AcpElicitationMode as o, NonInteractivePermissionPolicy as p, AcpClientOptions as r, AcpElicitationRequest as s, SessionAgentOptions as t, AcpPermissionRequest as u, PromptInput as v };
|
||||
|
||||
Reference in new issue
Block a user