mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
feat: default Claude to the native runner on Apple Silicon
Carry Claude effort through versioned native admission and recovery. Preserve existing connections and saved runner choices, and price Opus 5.5 API receipts. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
058af8f1b6
commit
6fd56c445d
77 files changed
+909
-296
No files matched your search
@@ -12,7 +12,7 @@ export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS = 300_000;
|
||||
export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS = 86_400_000;
|
||||
export const PAPERCLIP_RUNNER_DEFAULT_MODELS = {
|
||||
codex: "gpt-5.6-sol",
|
||||
acpx: "claude-sonnet-5",
|
||||
acpx: "claude-opus-5-5",
|
||||
opencode: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ describe("Claude model defaults", () => {
|
||||
expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => {
|
||||
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5");
|
||||
expect(resolveClaudeModel(model)).toBe("claude-opus-5");
|
||||
it.each([undefined, null, "", " "])("uses Opus 5.5 for an unset model (%j)", (model) => {
|
||||
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5-5");
|
||||
expect(resolveClaudeModel(model)).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("keeps explicit model IDs ahead of environment overrides", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5";
|
||||
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5-5";
|
||||
|
||||
/** Resolve Paperclip's default without replacing an explicit provider model. */
|
||||
export function resolveClaudeModel(
|
||||
|
||||
@@ -43,7 +43,8 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
ac.graceSec = 15;
|
||||
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
// Native creation omits the legacy form default; explicit limits remain visible to validation.
|
||||
if (v.runner === "legacy" || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
|
||||
@@ -76,6 +76,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const config = buildCodexLocalConfig(v);
|
||||
const schemaValues = normalizeLegacyRunnerProvider({ ...(v.adapterSchemaValues ?? {}) });
|
||||
const configuredEffort = v.thinkingEffort || schemaValues.modelReasoningEffort || schemaValues.reasoningEffort || schemaValues.effort;
|
||||
for (const unsupportedKey of [
|
||||
"engine",
|
||||
"agentCommand",
|
||||
@@ -253,6 +254,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent,
|
||||
...(acpxAgent === "claude" && configuredEffort ? { modelReasoningEffort: configuredEffort } : {}),
|
||||
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
|
||||
@@ -154,7 +154,16 @@ policy in the closed provider configuration: `approvalPolicy` for Codex and
|
||||
provider-session identity, so an incompatible idle or recovered session is
|
||||
replaced on the next execution. An active turn is never mutated in place.
|
||||
|
||||
`paperclip.native-execution-input.v5` is the current input format. It retains
|
||||
`paperclip.native-execution-input.v5` remains the ordinary input format.
|
||||
Claude inputs with explicitly configured effort use v7 with the optional
|
||||
`provider.claudeEffort` field. V7 retains the compact v3 model envelope; effort
|
||||
is host configuration, never model-visible task text. V6 remains the separate
|
||||
OpenAI Dot format. Readers retain persisted v1-v6 contracts. Claude effort is
|
||||
acknowledged on each ACP connection before prompting, bound into the session
|
||||
identity and recovery checkpoint, and cross-checked against the public provider
|
||||
descriptor. Missing effort retains the provider default; values are never clamped.
|
||||
|
||||
The v5 input format It retains
|
||||
the v4 permission pinning and adds optional completion-source references;
|
||||
`paperclip.native-model-envelope.v3` is the corresponding explicit model
|
||||
projection. Readers continue to accept persisted v1-v4 inputs, and v5 readers
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
{
|
||||
"path": "schemas/provider-descriptor.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519"
|
||||
"sha256": "ef37ebba071ac239e0b4ff680a469eca6948b8eace451c5c88089c81b7acdae1"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "command": { "const": "session.open" } } },
|
||||
"then": { "properties": { "params": { "$ref": "#/$defs/sessionOpenThinkingLevel" } } }
|
||||
"then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -89,6 +89,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeEffort": { "enum": ["low", "medium", "high", "xhigh", "max"] },
|
||||
"sessionOpenClaudeEffort": {
|
||||
"type": "object",
|
||||
"properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } },
|
||||
"if": { "properties": { "agent": { "const": "claude" } }, "required": ["agent"] },
|
||||
"then": { "properties": { "expectedIdentity": {
|
||||
"type": ["object", "null"], "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } }
|
||||
} } },
|
||||
"else": { "properties": {
|
||||
"claudeEffort": false,
|
||||
"expectedIdentity": { "type": ["object", "null"], "properties": { "claudeEffort": false } }
|
||||
} }
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"required": ["protocolVersion", "id", "ok"],
|
||||
|
||||
@@ -153,6 +153,15 @@
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"claudeEffort": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -176,6 +185,26 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": [
|
||||
"claudeEffort"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "acpx"
|
||||
},
|
||||
"agent": {
|
||||
"const": "claude"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -143,6 +143,8 @@ struct AcpxProviderDescriptor {
|
||||
mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
claude_effort: Option<String>,
|
||||
permission_mode_pinned: bool,
|
||||
#[serde(default)]
|
||||
provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
@@ -267,6 +269,10 @@ impl AcpxProviderDescriptor {
|
||||
|| self.model.len() > 240
|
||||
|| self.model.contains('\0')
|
||||
|| ((self.agent == "pi") != self.pi_thinking_level.is_some())
|
||||
|| self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
self.agent != "claude"
|
||||
|| !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
})
|
||||
|| self.mode.as_ref().is_some_and(|mode| {
|
||||
mode.trim().is_empty()
|
||||
|| mode.chars().count() > 240
|
||||
@@ -368,6 +374,7 @@ impl AcpxProviderDescriptor {
|
||||
permission_mode: self.permission_mode,
|
||||
mode: self.mode.clone(),
|
||||
pi_thinking_level: self.pi_thinking_level,
|
||||
claude_effort: self.claude_effort.clone(),
|
||||
permission_mode_pinned: self.permission_mode_pinned,
|
||||
provider_policy: self.provider_policy.clone(),
|
||||
system_instructions: self.instructions.clone(),
|
||||
@@ -472,6 +479,9 @@ impl AcpxProviderDescriptor {
|
||||
"acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()),
|
||||
"permissionMode": self.permission_mode,
|
||||
});
|
||||
if let Some(effort) = &self.claude_effort {
|
||||
descriptor["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = self.pi_thinking_level {
|
||||
descriptor["piThinkingLevel"] = json!(level);
|
||||
}
|
||||
@@ -611,6 +621,7 @@ impl AcpxDurableState {
|
||||
if identity.profile_digest != self.descriptor.command_digest
|
||||
|| identity.mode != self.descriptor.mode
|
||||
|| identity.pi_thinking_level != self.descriptor.pi_thinking_level
|
||||
|| identity.claude_effort != self.descriptor.claude_effort
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX durable identity no longer matches its qualified profile or provider mode",
|
||||
@@ -1295,6 +1306,9 @@ impl AcpxCommandExecutor {
|
||||
"sessionId": identity.agent_session_id,
|
||||
"processId": process_id,
|
||||
});
|
||||
if let Some(effort) = &identity.claude_effort {
|
||||
result["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = identity.pi_thinking_level {
|
||||
// This identity is admitted only after the sidecar's effective-mode ACK.
|
||||
result["piThinkingLevel"] = json!(level);
|
||||
@@ -2754,6 +2768,7 @@ mod tests {
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
pi_thinking_level: descriptor.pi_thinking_level,
|
||||
claude_effort: descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3030,6 +3045,7 @@ mod tests {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
|
||||
@@ -3507,6 +3523,7 @@ mod tests {
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
claude_effort: original_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3792,6 +3809,7 @@ mod tests {
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
claude_effort: original_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -4086,6 +4104,7 @@ mod tests {
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
pi_thinking_level: descriptor.pi_thinking_level,
|
||||
claude_effort: descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -4259,6 +4278,7 @@ mod tests {
|
||||
permission_mode: Some(provider_descriptor.permission_mode),
|
||||
mode: provider_descriptor.mode.clone(),
|
||||
pi_thinking_level: provider_descriptor.pi_thinking_level,
|
||||
claude_effort: provider_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
});
|
||||
state.provider_exit_unconfirmed = true;
|
||||
|
||||
@@ -53,6 +53,8 @@ struct PersistedAcpxProviderSessionIdentity {
|
||||
mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
claude_effort: Option<String>,
|
||||
provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
permission_mode,
|
||||
mode: identity.mode,
|
||||
pi_thinking_level: identity.pi_thinking_level,
|
||||
claude_effort: identity.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates,
|
||||
})
|
||||
}
|
||||
@@ -95,6 +98,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
permission_mode: Some(self.permission_mode),
|
||||
mode: self.mode.clone(),
|
||||
pi_thinking_level: self.pi_thinking_level,
|
||||
claude_effort: self.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +121,7 @@ impl AcpxSuspensionCheckpoint {
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| identity.pi_thinking_level != config.pi_thinking_level
|
||||
|| identity.claude_effort != config.claude_effort
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
|
||||
@@ -72,6 +72,8 @@ pub struct AcpxProviderSessionIdentity {
|
||||
pub mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_effort: Option<String>,
|
||||
pub provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ pub struct AcpxProviderSessionConfig {
|
||||
pub permission_mode: AcpxPermissionMode,
|
||||
pub mode: Option<String>,
|
||||
pub pi_thinking_level: Option<PiThinkingLevel>,
|
||||
pub claude_effort: Option<String>,
|
||||
pub permission_mode_pinned: bool,
|
||||
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
pub system_instructions: String,
|
||||
@@ -109,6 +112,14 @@ impl AcpxProviderSessionConfig {
|
||||
LocalRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
self.agent != "claude"
|
||||
|| !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
}) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Claude effort must be a supported Claude-only value",
|
||||
));
|
||||
}
|
||||
if (self.agent == "pi") != self.pi_thinking_level.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX Pi thinking level must be explicit for Pi and absent for other agents",
|
||||
@@ -188,6 +199,7 @@ impl AcpxProviderSessionConfig {
|
||||
|| expected_identity.permission_mode != Some(self.permission_mode)
|
||||
|| expected_identity.mode != self.mode
|
||||
|| expected_identity.pi_thinking_level != self.pi_thinking_level
|
||||
|| expected_identity.claude_effort != self.claude_effort
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX expected identity conflicts with the requested session",
|
||||
@@ -208,6 +220,13 @@ impl AcpxProviderSessionIdentity {
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?;
|
||||
}
|
||||
if self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
!matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
}) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX identity contains invalid Claude effort",
|
||||
));
|
||||
}
|
||||
for (value, label) in [
|
||||
(&self.normalized_session_id, "normalized session"),
|
||||
(&self.acpx_record_id, "record"),
|
||||
@@ -1313,6 +1332,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
});
|
||||
if let Some(effort) = &config.claude_effort {
|
||||
params["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = config.pi_thinking_level {
|
||||
params["piThinkingLevel"] = json!(level);
|
||||
}
|
||||
@@ -1430,6 +1452,7 @@ fn verify_open_response(
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| identity.pi_thinking_level != config.pi_thinking_level
|
||||
|| identity.claude_effort != config.claude_effort
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
@@ -1755,6 +1778,7 @@ mod mode_tests {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }),
|
||||
system_instructions: String::new(),
|
||||
@@ -1782,6 +1806,7 @@ mod mode_tests {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
@@ -1921,6 +1946,27 @@ mod mode_tests {
|
||||
identity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_effort_is_explicit_claude_only_and_forwarded_without_clamping() {
|
||||
let mut cfg = config();
|
||||
cfg.agent = "claude".to_owned();
|
||||
for effort in ["low", "medium", "high", "xhigh", "max"] {
|
||||
cfg.claude_effort = Some(effort.to_owned());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
session_open_params(&cfg, &[])["claudeEffort"],
|
||||
json!(effort)
|
||||
);
|
||||
}
|
||||
cfg.claude_effort = Some("ultra".to_owned());
|
||||
assert!(cfg.validate().is_err());
|
||||
cfg.claude_effort = Some("high".to_owned());
|
||||
cfg.agent = "codex".to_owned();
|
||||
assert!(cfg.validate().is_err());
|
||||
cfg.claude_effort = None;
|
||||
cfg.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_thinking_level_is_closed_explicit_and_pi_only() {
|
||||
for (name, level) in [
|
||||
|
||||
@@ -52,6 +52,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -80,6 +81,7 @@ fn identity() -> AcpxProviderSessionIdentity {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
|
||||
@@ -47,6 +47,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -70,6 +71,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
|
||||
@@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
claude_effort: None,
|
||||
pi_thinking_level: if mode.starts_with("controls") {
|
||||
Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low)
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
"model" | "permissionMode" | "mode" | "piThinkingLevel" | "claudeEffort" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
> {}
|
||||
|
||||
export type AcpxNativeSessionBackendOptions =
|
||||
@@ -76,6 +76,7 @@ export function createAcpxNativeSessionBackend(
|
||||
permissionMode: input.provider.permissionMode ?? "approve-reads",
|
||||
mode: input.provider.mode,
|
||||
piThinkingLevel: input.provider.piThinkingLevel,
|
||||
claudeEffort: "claudeEffort" in input.provider ? input.provider.claudeEffort : undefined,
|
||||
systemInstructions,
|
||||
runtimeContext: "runtimeContext" in input ? input.runtimeContext : null,
|
||||
providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { parseProviderMode } from "../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js";
|
||||
|
||||
@@ -297,6 +298,7 @@ async function dispatch(
|
||||
permissionMode: params.permissionMode,
|
||||
mode: params.mode,
|
||||
piThinkingLevel: params.piThinkingLevel,
|
||||
claudeEffort: params.claudeEffort,
|
||||
providerPolicy: params.providerPolicy,
|
||||
systemInstructions: params.systemInstructions,
|
||||
runtimeContext: params.runtimeContext,
|
||||
@@ -1209,12 +1211,13 @@ function safeOutput(value: unknown): Record<string, unknown> {
|
||||
function parseOpenParams(
|
||||
value: Record<string, unknown>,
|
||||
): AcpxSidecarOpenParams {
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field");
|
||||
const agent = requireQualifiedAgent(value.agent);
|
||||
const model = requiredText(value.model, "model");
|
||||
if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor");
|
||||
const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort(agent, value.claudeEffort);
|
||||
resolveQualifiedAcpxProfile(agent, model);
|
||||
if (
|
||||
value.providerSessionKey !== undefined &&
|
||||
@@ -1236,6 +1239,7 @@ function parseOpenParams(
|
||||
permissionMode: requiredPermissionMode(value.permissionMode),
|
||||
...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
permissionModePinned: value.permissionModePinned === true,
|
||||
...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }),
|
||||
systemInstructions: boundedText(
|
||||
@@ -1307,6 +1311,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity {
|
||||
: { permissionMode: requiredPermissionMode(input.permissionMode) }),
|
||||
...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }),
|
||||
...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }),
|
||||
...(input.claudeEffort === undefined ? {} : { claudeEffort: resolveClaudeEffort("claude", input.claudeEffort) }),
|
||||
providerLifetimeFenceCandidates: requiredFenceCandidates(
|
||||
input.providerLifetimeFenceCandidates,
|
||||
),
|
||||
|
||||
@@ -457,6 +457,7 @@ export interface AcpxSessionIdentity {
|
||||
/** Effective provider mode identifier, bound to the session identity. */
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, NATIVE_EXECUTION_INPUT_SCHEMA_V6, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
import {
|
||||
NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
PAPERCLIP_EXECUTION_PROMPT,
|
||||
@@ -680,3 +680,30 @@ describe("native task context ownership", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Claude effort input v7", () => {
|
||||
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
|
||||
const digest = "0".repeat(64);
|
||||
const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null } };
|
||||
const current = { ...input, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, executionMode: "default", planningContext: null,
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile } };
|
||||
it.each(["low", "medium", "high", "xhigh", "max"])("round trips explicit %s and retains the compact model envelope", claudeEffort => {
|
||||
const parsed = parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort } });
|
||||
expect(parsed).toMatchObject({ schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, provider: { claudeEffort } });
|
||||
expect(buildNativeModelEnvelope(parsed).schema).toBe("paperclip.native-model-envelope.v3");
|
||||
expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("claudeEffort");
|
||||
});
|
||||
it("keeps historical readers closed and rejects aliases and foreign effort", () => {
|
||||
for (const schema of ["paperclip.native-execution-input.v4", NATIVE_EXECUTION_INPUT_SCHEMA]) {
|
||||
expect(() => parseNativeExecutionInput({ ...current, schema, provider: { ...current.provider, claudeEffort: "high" } })).toThrow("claudeEffort");
|
||||
expect(parseNativeExecutionInput({ ...current, schema }).schema).toBe(schema);
|
||||
}
|
||||
expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort: "ultra" } })).toThrow("Claude effort");
|
||||
expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, agent: "codex", profile: QUALIFIED_ACPX_PROFILES.codex, claudeEffort: "high" } })).toThrow("Claude effort");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import type { DotBindingSnapshot } from "./external-provider.js";
|
||||
import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js";
|
||||
import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
|
||||
@@ -9,6 +10,7 @@ import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeCo
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V7 = "paperclip.native-execution-input.v7" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const;
|
||||
@@ -238,6 +240,13 @@ export interface NativeExecutionInputV5 extends Omit<NativeExecutionInputV4, "sc
|
||||
completionSources?: NativeCompletionSources;
|
||||
}
|
||||
|
||||
export type NativeProviderConfigV7 = Exclude<NativeProviderConfigV5, { kind: "acpx" }>
|
||||
| (Extract<NativeProviderConfigV5, { kind: "acpx" }> & { claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max" });
|
||||
export interface NativeExecutionInputV7 extends Omit<NativeExecutionInputV5, "schema" | "provider"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
provider: NativeProviderConfigV7;
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "schema" | "provider" | "workspace" | "session"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V6;
|
||||
provider: { kind: "openai_dot"; model: null; binding: DotBindingSnapshot };
|
||||
@@ -245,7 +254,7 @@ export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "sc
|
||||
session: Omit<NativeExecutionInputV5["session"], "driverKind"> & { driverKind: "openai_dot_mcp" };
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
@@ -372,7 +381,8 @@ function parseDotNativeExecutionInput(input: Record<string, unknown>): NativeExe
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseDotNativeExecutionInput(input);
|
||||
const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4;
|
||||
const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2;
|
||||
@@ -504,7 +514,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
: provider.kind === "aws_agentcore"
|
||||
? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"]
|
||||
: provider.kind === "acpx"
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])]
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : []), ...(isV7 ? ["claudeEffort"] : [])]
|
||||
: provider.kind === "codex" && isV4
|
||||
? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])]
|
||||
: provider.kind === "opencode" && isV4
|
||||
@@ -527,7 +537,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
|
||||
}
|
||||
let parsedProvider: NativeProviderConfig | NativeProviderConfigV5;
|
||||
let parsedProvider: NativeProviderConfigV7 | NativeProviderConfig | NativeProviderConfigV5;
|
||||
if (provider.kind === "claude_managed") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for claude_managed");
|
||||
@@ -643,6 +653,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
} else if (provider.permissionPolicy !== "interactive") {
|
||||
throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive");
|
||||
}
|
||||
const claudeEffort = resolveClaudeEffort(provider.agent, provider.claudeEffort);
|
||||
const profile = record(provider.profile, "input.provider.profile");
|
||||
exactKeys(profile, [
|
||||
"driverKind",
|
||||
@@ -681,6 +692,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode }
|
||||
: { permissionPolicy: "interactive" as const }),
|
||||
...(provider.mode === undefined ? {} : { mode: provider.mode as string }),
|
||||
...(claudeEffort === undefined ? {} : { claudeEffort }),
|
||||
...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }),
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
@@ -835,6 +847,12 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
provider: parsedProvider as NativeProviderConfigV4,
|
||||
};
|
||||
if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 };
|
||||
if (isV7) return {
|
||||
...withPermissions,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7,
|
||||
provider: parsedProvider as NativeProviderConfigV7,
|
||||
...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}),
|
||||
};
|
||||
return {
|
||||
...withPermissions,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
@@ -901,7 +919,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) {
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) {
|
||||
const sourceBinding = input.completionSources;
|
||||
const references = sourceBinding?.contractRevision === input.completionContract.contract.revision
|
||||
&& sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { admittedClaudeEffort, createClaudeEffortAdmission, resolveClaudeEffort } from "./claude-effort.js";
|
||||
|
||||
const response = (value: string, sessionId?: string) => ({ ...(sessionId ? { sessionId } : {}), configOptions: [{ id: "effort", currentValue: value }] });
|
||||
function loaded(method = "session/new") {
|
||||
const admission = createClaudeEffortAdmission("high");
|
||||
const guard = admission.createGuard();
|
||||
guard("outbound", { id: 1, method, params: method === "session/load" ? { sessionId: "s" } : {} });
|
||||
guard("inbound", { id: 1, result: response("medium", method === "session/new" ? "s" : undefined) });
|
||||
return { admission, guard };
|
||||
}
|
||||
function select(guard: ReturnType<ReturnType<typeof createClaudeEffortAdmission>["createGuard"]>, acknowledged = "high") {
|
||||
guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "effort", value: "high" } });
|
||||
guard("inbound", { id: 2, result: response(acknowledged) });
|
||||
}
|
||||
describe("Claude effort admission", () => {
|
||||
it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts exact %s without aliases", value => {
|
||||
expect(resolveClaudeEffort("claude", value)).toBe(value);
|
||||
expect(admittedClaudeEffort("claude", value, { claudeEffort: value }, { claudeEffort: value })).toBe(value);
|
||||
});
|
||||
it("rejects unsupported, foreign and conflicting effort", () => {
|
||||
for (const value of ["ultra", "off", null, 2]) expect(() => resolveClaudeEffort("claude", value)).toThrow();
|
||||
expect(() => resolveClaudeEffort("pi", "high")).toThrow("only supported");
|
||||
expect(() => admittedClaudeEffort("claude", "high", { claudeEffort: "high" }, { claudeEffort: "medium" })).toThrow("differs");
|
||||
});
|
||||
it.each(["session/new", "session/load"])("requires acknowledged effort before prompting after %s", method => {
|
||||
const { admission, guard } = loaded(method);
|
||||
expect(admission.isReady()).toBe(false);
|
||||
select(guard);
|
||||
expect(admission.isReady()).toBe(true);
|
||||
expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow();
|
||||
});
|
||||
it("blocks a prompt based only on a restored default", () => {
|
||||
const { guard } = loaded("session/load");
|
||||
expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge");
|
||||
});
|
||||
it("rejects a provider that ignores the requested effort", () => {
|
||||
const { guard } = loaded();
|
||||
expect(() => select(guard, "medium")).toThrow("did not apply");
|
||||
});
|
||||
it("rejects drift and wrong-session prompts after admission", () => {
|
||||
const { guard } = loaded(); select(guard);
|
||||
expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", ...response("low") } } })).toThrow("drifted");
|
||||
const fresh = loaded(); select(fresh.guard);
|
||||
expect(() => fresh.guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "foreign" } })).toThrow("different session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createAcpxConfigAdmission } from "./config-admission.js";
|
||||
|
||||
export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (agent !== "claude") throw new Error("Claude effort is only supported by Claude");
|
||||
if (value !== "low" && value !== "medium" && value !== "high" && value !== "xhigh" && value !== "max") throw new Error("Claude effort must be low, medium, high, xhigh, or max");
|
||||
return value;
|
||||
}
|
||||
export function createClaudeEffortAdmission(expected: ClaudeEffort) {
|
||||
// Every connection, including session/load, must select and acknowledge the
|
||||
// configured effort. A restored preference alone never admits a prompt.
|
||||
return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort",
|
||||
supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] });
|
||||
}
|
||||
export function admittedClaudeEffort(agent: string, requested: unknown, descriptor: Record<string, unknown>, identity: Record<string, unknown>): ClaudeEffort | undefined {
|
||||
const expected = resolveClaudeEffort(agent, requested);
|
||||
const effective = resolveClaudeEffort(agent, identity.claudeEffort);
|
||||
if (effective !== expected || descriptor.claudeEffort !== expected) throw new Error("Claude effort differs from the admitted configuration");
|
||||
return effective;
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export interface CodexAcpxDriverOptions {
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"];
|
||||
systemInstructions?: string;
|
||||
@@ -463,6 +464,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
permissionMode: this.#options.permissionMode ?? "approve-all",
|
||||
mode: this.#options.mode,
|
||||
piThinkingLevel: this.#options.piThinkingLevel,
|
||||
claudeEffort: this.#options.claudeEffort,
|
||||
providerPolicy: this.#options.providerPolicy,
|
||||
runtimeContext: this.#options.runtimeContext,
|
||||
systemInstructions: this.#options.systemInstructions,
|
||||
@@ -1290,6 +1292,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
permissionMode: identity.permissionMode,
|
||||
...(identity.mode === undefined ? {} : { mode: identity.mode }),
|
||||
...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }),
|
||||
...(identity.claudeEffort === undefined ? {} : { claudeEffort: identity.claudeEffort }),
|
||||
providerLifetimeFenceCandidates:
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
},
|
||||
@@ -2153,6 +2156,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void {
|
||||
identity.permissionMode,
|
||||
)) ||
|
||||
(identity.mode !== undefined && !isProviderMode(identity.mode)) ||
|
||||
(identity.claudeEffort !== undefined && !["low", "medium", "high", "xhigh", "max"].includes(identity.claudeEffort)) ||
|
||||
(identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) ||
|
||||
!validProviderLifetimeFenceCandidates(
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
|
||||
@@ -29,6 +29,31 @@ const HANDLE: AcpRuntimeHandle = {
|
||||
};
|
||||
|
||||
describe("Codex ACPX runtime adapter", () => {
|
||||
it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => {
|
||||
const runtime = fakeRuntime();
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent: "claude" };
|
||||
options.claudeEffort = "high";
|
||||
options.restoringSession = restoring;
|
||||
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
||||
let created!: AcpRuntimeOptions;
|
||||
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
||||
const guard = created.protocolGuardFactory!();
|
||||
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
||||
guard("inbound", { id: 0, result: { configOptions: [{ id: "effort", currentValue: "default" }] } });
|
||||
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
||||
guard("inbound", { id: 1, result: { configOptions: [{ id: "effort", currentValue: input.value }] } });
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
||||
});
|
||||
expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" });
|
||||
expect(await port.identity()).toMatchObject({ claudeEffort: "high" });
|
||||
expect(options.refreshConsumedCommand).toHaveBeenCalledOnce();
|
||||
expect(runtime.startTurn).not.toHaveBeenCalled();
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js";
|
||||
import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
@@ -305,6 +306,8 @@ export async function openQualifiedAcpxRuntime(
|
||||
const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode);
|
||||
const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel);
|
||||
const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined;
|
||||
const selectedClaudeEffort = resolveClaudeEffort(options.profile.agent, options.claudeEffort);
|
||||
const claudeEffort = selectedClaudeEffort ? createClaudeEffortAdmission(selectedClaudeEffort) : undefined;
|
||||
const cursorInstructions = options.profile.agent === "cursor"
|
||||
? createCursorInstructionAdmission(options.systemInstructions)
|
||||
: null;
|
||||
@@ -316,6 +319,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); };
|
||||
} } : {}),
|
||||
...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}),
|
||||
...(claudeEffort ? { protocolGuardFactory: () => claudeEffort.createGuard() } : {}),
|
||||
sessionStore,
|
||||
agentRegistry: createRegistry({
|
||||
// Preserve Claude's ACP capability identity. This is metadata only: the
|
||||
@@ -527,6 +531,18 @@ export async function openQualifiedAcpxRuntime(
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : claudeEffort ? ensuredSession.then(async (ensuredHandle) => {
|
||||
handle = ensuredHandle;
|
||||
options.signal?.throwIfAborted();
|
||||
if (!claudeEffort.isReady()) {
|
||||
if (!runtime.setConfigOption) throw new Error("Claude effort admission requires native configuration");
|
||||
await runtime.setConfigOption({ handle: ensuredHandle, key: "effort", value: selectedClaudeEffort! });
|
||||
claudeEffort.assertReady();
|
||||
await children.verifyLifetimeOwnership();
|
||||
options.signal?.throwIfAborted();
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : ensuredSession)
|
||||
.catch((error: unknown) => {
|
||||
throw classifySessionEnsureFailure(error);
|
||||
@@ -543,6 +559,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
cursorInstructions?.assertReady();
|
||||
modeBinding?.assertReady();
|
||||
piThinking?.assertReady();
|
||||
claudeEffort?.assertReady();
|
||||
// A provider can answer only after the verified sentinel is armed, but do
|
||||
// not admit the session until the owner has observed that exact handoff.
|
||||
await children.verifyLifetimeOwnership();
|
||||
@@ -597,7 +614,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
return runtimePort(
|
||||
runtime,
|
||||
handle,
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) },
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}), ...(selectedClaudeEffort ? { claudeEffort: selectedClaudeEffort } : {}) },
|
||||
baseStore,
|
||||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Native mode is independent of permissions. Every connection must prove its
|
||||
* current mode before a prompt; a persisted ACPX preference is not proof. */
|
||||
export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) {
|
||||
type State = { sessionId: string | null; mode: string | null; error: Error | null };
|
||||
let current: State | null = null;
|
||||
let selectedOnce = options.restoring === true;
|
||||
const failure = (detail: string) => new Error(`${options.label} admission failed: ${detail}`);
|
||||
const assertReady = () => {
|
||||
if (current?.error) throw current.error;
|
||||
if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode");
|
||||
};
|
||||
const readMode = (result: Record<string, unknown>, session: boolean): string => {
|
||||
const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : [];
|
||||
if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous");
|
||||
const mode = object(configs[0]).currentValue;
|
||||
// Before exact model selection, Pi can legitimately report a different
|
||||
// native model's level. Observation alone never admits the first prompt.
|
||||
if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported");
|
||||
if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict");
|
||||
return mode;
|
||||
};
|
||||
return {
|
||||
assertReady,
|
||||
isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; },
|
||||
createGuard() {
|
||||
const state: State = { sessionId: null, mode: null, error: null };
|
||||
current = state;
|
||||
const pending = new Map<string | number, { method: string; sessionId: unknown; selectsMode: boolean }>();
|
||||
return (direction: "inbound" | "outbound", value: unknown): void => {
|
||||
if (current !== state) throw failure("connection authority was replaced");
|
||||
assertNoError();
|
||||
const message = object(value);
|
||||
const params = object(message.params);
|
||||
try {
|
||||
if (direction === "outbound") {
|
||||
const method = message.method;
|
||||
if (method === "session/prompt") {
|
||||
assertReady();
|
||||
if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session");
|
||||
} else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") {
|
||||
if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request");
|
||||
if (pending.has(message.id)) throw failure("duplicate mode request identity");
|
||||
if (pending.size) throw failure("overlapping native admission controls");
|
||||
if (method === "session/new" || method === "session/load") {
|
||||
if (pending.size) throw failure("overlapping session admission");
|
||||
state.sessionId = null; state.mode = null;
|
||||
} else {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session");
|
||||
if (params.configId === options.configId && params.value !== expected) throw failure("configuration changes the selected mode");
|
||||
state.mode = null; // No prompt may race unacknowledged native configuration.
|
||||
}
|
||||
pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId });
|
||||
} else if (options.sessionMode && method === "session/set_mode") {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode");
|
||||
// This method only returns {}; require a later config/session
|
||||
// acknowledgement rather than treating that empty result as proof.
|
||||
state.mode = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") {
|
||||
const update = object(params.update);
|
||||
// Config updates may contain only model options. A mode entry is
|
||||
// authoritative observation, but never an admission acknowledgement.
|
||||
if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
const observed = readMode(update, false);
|
||||
if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.sessionMode && message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode");
|
||||
return; // Notifications alone never admit a prompt.
|
||||
}
|
||||
if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error !== undefined) { state.mode = null; return; }
|
||||
const result = object(message.result);
|
||||
const session = request.method !== "session/set_config_option";
|
||||
const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity");
|
||||
const mode = readMode(result, session);
|
||||
if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode");
|
||||
if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode");
|
||||
if (!session && request.selectsMode) selectedOnce = true;
|
||||
state.sessionId = sessionId; state.mode = mode;
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error : failure("invalid mode acknowledgement");
|
||||
throw state.error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
function assertNoError() { if (current?.error) throw current.error; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createAcpxConfigAdmission } from "./config-admission.js";
|
||||
|
||||
export type PiThinkingLevel = "off" | "low" | "high" | "max";
|
||||
|
||||
export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinkingLevel | undefined {
|
||||
@@ -10,108 +12,13 @@ export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinking
|
||||
return mode;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) {
|
||||
return createAcpxConfigAdmission(expected, { ...options, label: "Pi thinking mode", configId: "thought_level",
|
||||
supportedValues: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], sessionMode: true });
|
||||
}
|
||||
|
||||
/** Native mode is independent of permissions. Every connection must prove its
|
||||
* current mode before a prompt; a persisted ACPX preference is not proof. */
|
||||
export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) {
|
||||
type State = { sessionId: string | null; mode: string | null; error: Error | null };
|
||||
let current: State | null = null;
|
||||
let selectedOnce = options.restoring === true;
|
||||
const failure = (detail: string) => new Error(`Pi thinking mode admission failed: ${detail}`);
|
||||
const assertReady = () => {
|
||||
if (current?.error) throw current.error;
|
||||
if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode");
|
||||
};
|
||||
const readMode = (result: Record<string, unknown>, session: boolean): string => {
|
||||
const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "thought_level") : [];
|
||||
if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous");
|
||||
const mode = object(configs[0]).currentValue;
|
||||
// Before exact model selection, Pi can legitimately report a different
|
||||
// native model's level. Observation alone never admits the first prompt.
|
||||
if (typeof mode !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(mode)) throw failure("native mode is unsupported");
|
||||
if (session && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict");
|
||||
return mode;
|
||||
};
|
||||
return {
|
||||
assertReady,
|
||||
isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; },
|
||||
createGuard() {
|
||||
const state: State = { sessionId: null, mode: null, error: null };
|
||||
current = state;
|
||||
const pending = new Map<string | number, { method: string; sessionId: unknown; selectsMode: boolean }>();
|
||||
return (direction: "inbound" | "outbound", value: unknown): void => {
|
||||
if (current !== state) throw failure("connection authority was replaced");
|
||||
assertNoError();
|
||||
const message = object(value);
|
||||
const params = object(message.params);
|
||||
try {
|
||||
if (direction === "outbound") {
|
||||
const method = message.method;
|
||||
if (method === "session/prompt") {
|
||||
assertReady();
|
||||
if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session");
|
||||
} else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") {
|
||||
if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request");
|
||||
if (pending.has(message.id)) throw failure("duplicate mode request identity");
|
||||
if (pending.size) throw failure("overlapping native admission controls");
|
||||
if (method === "session/new" || method === "session/load") {
|
||||
if (pending.size) throw failure("overlapping session admission");
|
||||
state.sessionId = null; state.mode = null;
|
||||
} else {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session");
|
||||
if (params.configId === "thought_level" && params.value !== expected) throw failure("configuration changes the selected mode");
|
||||
state.mode = null; // No prompt may race unacknowledged native configuration.
|
||||
}
|
||||
pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "thought_level" });
|
||||
} else if (method === "session/set_mode") {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode");
|
||||
// This method only returns {}; require a later config/session
|
||||
// acknowledgement rather than treating that empty result as proof.
|
||||
state.mode = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") {
|
||||
const update = object(params.update);
|
||||
// Config updates may contain only model options. A mode entry is
|
||||
// authoritative observation, but never an admission acknowledgement.
|
||||
if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === "thought_level")) {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
const observed = readMode(update, false);
|
||||
if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode");
|
||||
return; // Notifications alone never admit a prompt.
|
||||
}
|
||||
if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error !== undefined) { state.mode = null; return; }
|
||||
const result = object(message.result);
|
||||
const session = request.method !== "session/set_config_option";
|
||||
const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity");
|
||||
const mode = readMode(result, session);
|
||||
if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode");
|
||||
if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode");
|
||||
if (!session && request.selectsMode) selectedOnce = true;
|
||||
state.sessionId = sessionId; state.mode = mode;
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error : failure("invalid mode acknowledgement");
|
||||
throw state.error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
function assertNoError() { if (current?.error) throw current.error; }
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Cross-check independently returned durable identity and public descriptor. */
|
||||
|
||||
@@ -75,6 +75,21 @@ describe("ACPX recovery identity", () => {
|
||||
expect(customMode.mode).toBe("provider-custom-mode");
|
||||
});
|
||||
|
||||
it("binds Claude effort to recovery and rejects changed or omitted effort", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const profile = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5");
|
||||
const input = { ...fixture.input, profile, requestedModel: profile.qualificationModel, claudeEffort: "high" as const };
|
||||
const high = await createAcpxRecoveryBinding(input);
|
||||
const low = await createAcpxRecoveryBinding({ ...input, claudeEffort: "low" });
|
||||
expect(low.profileSessionKey).not.toBe(high.profileSessionKey);
|
||||
const expected = { ...fixture.expected, profileDigest: high.commandDigest, requestedModel: high.requestedModel, effectiveModel: high.effectiveModel, claudeEffort: "high" as const };
|
||||
const saved = createAcpxIdentityRecord(expected, high);
|
||||
expect(acpxProviderSessionIdentity(saved, high).claudeEffort).toBe("high");
|
||||
expect(() => verifyExpectedAcpxIdentity({ ...expected, claudeEffort: undefined }, high, saved)).toThrow("immutable");
|
||||
expect(() => verifyExpectedAcpxIdentity(expected, high, { ...saved, claudeEffort: undefined })).toThrow("persisted");
|
||||
expect(() => verifyExpectedAcpxIdentity(expected, low, saved)).toThrow("immutable");
|
||||
});
|
||||
|
||||
it("binds Pi thinking mode and rejects old or mismatched warm identities", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const profile = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "./claude-effort.js";
|
||||
import { parseProviderMode } from "../../contracts/provider-mode.js";
|
||||
import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -23,6 +24,7 @@ export interface AcpxRecoveryBinding {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
profileSessionKey: string;
|
||||
}
|
||||
|
||||
@@ -39,6 +41,7 @@ export interface AcpxIdentityRecord {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
@@ -51,12 +54,14 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerConfigurationDigest?: string;
|
||||
providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] };
|
||||
}): Promise<AcpxRecoveryBinding> {
|
||||
validateIdentity(input.normalizedSessionId, "normalized session");
|
||||
const mode = parseProviderMode(input.mode);
|
||||
const piThinkingLevel = resolvePiThinkingLevel(input.profile.agent, input.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort(input.profile.agent, input.claudeEffort);
|
||||
if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy");
|
||||
if (input.requestedModel !== input.profile.qualificationModel) {
|
||||
throw new Error("ACPX recovery requested model does not match its admitted profile");
|
||||
@@ -104,6 +109,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: input.permissionMode,
|
||||
...(mode ? { mode } : {}),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
}),
|
||||
).replace("sha256:", "paperclip-");
|
||||
return {
|
||||
@@ -118,6 +124,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: input.permissionMode,
|
||||
...(mode ? { mode } : {}),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
profileSessionKey,
|
||||
};
|
||||
}
|
||||
@@ -140,6 +147,7 @@ export function createAcpxIdentityRecord(
|
||||
permissionMode: binding.permissionMode,
|
||||
...(binding.mode ? { mode: binding.mode } : {}),
|
||||
...(binding.piThinkingLevel ? { piThinkingLevel: binding.piThinkingLevel } : {}),
|
||||
...(binding.claudeEffort ? { claudeEffort: binding.claudeEffort } : {}),
|
||||
providerLifetimeFenceCandidates: Object.freeze([
|
||||
...expected.providerLifetimeFenceCandidates,
|
||||
]) as readonly [number, number, number],
|
||||
@@ -167,6 +175,7 @@ export function acpxProviderSessionIdentity(
|
||||
permissionMode: record.permissionMode,
|
||||
...(record.mode ? { mode: record.mode } : {}),
|
||||
...(record.piThinkingLevel ? { piThinkingLevel: record.piThinkingLevel } : {}),
|
||||
...(record.claudeEffort ? { claudeEffort: record.claudeEffort } : {}),
|
||||
providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates,
|
||||
};
|
||||
verifyExpectedAcpxIdentity(identity, binding, record);
|
||||
@@ -193,7 +202,8 @@ export function verifyExpectedAcpxIdentity(
|
||||
expected.effectiveModel !== binding.effectiveModel ||
|
||||
expected.permissionMode !== binding.permissionMode ||
|
||||
expected.mode !== binding.mode ||
|
||||
expected.piThinkingLevel !== binding.piThinkingLevel
|
||||
expected.piThinkingLevel !== binding.piThinkingLevel ||
|
||||
expected.claudeEffort !== binding.claudeEffort
|
||||
) {
|
||||
throw new Error(
|
||||
"ACPX recovery identity conflicts with the immutable session configuration",
|
||||
@@ -214,6 +224,7 @@ export function verifyExpectedAcpxIdentity(
|
||||
record.permissionMode !== binding.permissionMode ||
|
||||
record.mode !== binding.mode ||
|
||||
record.piThinkingLevel !== binding.piThinkingLevel ||
|
||||
record.claudeEffort !== binding.claudeEffort ||
|
||||
!sameFenceCandidates(
|
||||
record.providerLifetimeFenceCandidates,
|
||||
expected.providerLifetimeFenceCandidates,
|
||||
@@ -240,6 +251,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord {
|
||||
"permissionMode",
|
||||
"mode",
|
||||
"piThinkingLevel",
|
||||
"claudeEffort",
|
||||
"providerLifetimeFenceCandidates",
|
||||
]);
|
||||
return validatedRecord(record);
|
||||
@@ -267,6 +279,7 @@ function validatedRecord(value: Record<string, unknown>): AcpxIdentityRecord {
|
||||
throw new Error("ACPX identity permission mode is invalid");
|
||||
}
|
||||
if (value.mode !== undefined) parseProviderMode(value.mode);
|
||||
resolveClaudeEffort("claude", value.claudeEffort);
|
||||
if (value.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", value.piThinkingLevel);
|
||||
validateFenceCandidates(value.providerLifetimeFenceCandidates);
|
||||
return value as unknown as AcpxIdentityRecord;
|
||||
@@ -297,6 +310,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void {
|
||||
throw new Error("Expected ACPX permission mode is invalid");
|
||||
}
|
||||
if (expected.mode !== undefined) parseProviderMode(expected.mode);
|
||||
resolveClaudeEffort("claude", expected.claudeEffort);
|
||||
if (expected.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", expected.piThinkingLevel);
|
||||
validateFenceCandidates(expected.providerLifetimeFenceCandidates);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "./claude-effort.js";
|
||||
import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { withAcpxTurnCancellation } from "./turn-cancellation.js";
|
||||
import { resolveAcpxProviderMode } from "./provider-mode.js";
|
||||
@@ -83,6 +84,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000;
|
||||
export interface AcpxRuntimePortIdentity {
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
acpxRecordId: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
@@ -163,6 +165,7 @@ export interface AcpxRuntimePortOpenOptions {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
permissionPolicy: ReturnType<typeof acpxRuntimePermissionPolicy>;
|
||||
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
/** Kernel credential-home quorum inherited by the provider sentinel. */
|
||||
@@ -238,6 +241,7 @@ export interface OpenAcpxRuntimeHostOptions {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
systemInstructions?: string;
|
||||
runtimeContext?: NativeRuntimeContextSnapshot | null;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
@@ -350,6 +354,7 @@ export class AcpxRuntimeHost {
|
||||
permissionMode: options.permissionMode,
|
||||
mode: resolveAcpxProviderMode(options.agent, options.mode),
|
||||
piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel),
|
||||
claudeEffort: resolveClaudeEffort(options.agent, options.claudeEffort),
|
||||
...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined
|
||||
? { providerPolicy: options.providerPolicy } : {}),
|
||||
}),
|
||||
@@ -581,6 +586,7 @@ export class AcpxRuntimeHost {
|
||||
permissionMode: binding.permissionMode,
|
||||
mode: binding.mode,
|
||||
piThinkingLevel: binding.piThinkingLevel,
|
||||
claudeEffort: binding.claudeEffort,
|
||||
permissionPolicy: acpxRuntimePermissionPolicy(
|
||||
binding.permissionMode,
|
||||
),
|
||||
@@ -644,6 +650,7 @@ export class AcpxRuntimeHost {
|
||||
if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) {
|
||||
throw new Error("ACPX runtime Pi thinking level conflicts with session binding");
|
||||
}
|
||||
if (runtimeIdentity.claudeEffort !== binding.claudeEffort) throw new Error("ACPX runtime Claude effort conflicts with session binding");
|
||||
const observedIdentity: AcpxExpectedSessionIdentity = {
|
||||
kind: "acpx",
|
||||
normalizedSessionId: binding.normalizedSessionId,
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AcpxSidecarOpenParams {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
permissionModePinned: boolean;
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
systemInstructions: string;
|
||||
@@ -69,6 +70,7 @@ export interface AcpxExpectedSessionIdentity {
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "../acpx/claude-effort.js";
|
||||
import { resolvePiThinkingLevel } from "../acpx/pi-thinking.js";
|
||||
import { isProviderMode } from "../../contracts/provider-mode.js";
|
||||
import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js";
|
||||
@@ -81,6 +82,7 @@ export function parseProviderIdentity(
|
||||
);
|
||||
}
|
||||
const piThinkingLevel = identity.piThinkingLevel === undefined ? undefined : resolvePiThinkingLevel("pi", identity.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort("claude", identity.claudeEffort);
|
||||
const mode = identity.mode;
|
||||
if (mode !== undefined && !isProviderMode(mode)) {
|
||||
throw new Error("ACPX provider identity contains an invalid provider mode");
|
||||
@@ -112,6 +114,7 @@ export function parseProviderIdentity(
|
||||
...(permissionMode === undefined ? {} : { permissionMode }),
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
...(claudeEffort === undefined ? {} : { claudeEffort }),
|
||||
providerLifetimeFenceCandidates: fenceCandidates as [
|
||||
number,
|
||||
number,
|
||||
|
||||
@@ -56,6 +56,7 @@ export {
|
||||
type QualifiedAcpxEnvironmentProbe,
|
||||
} from "./drivers/acpx/codex-acpx-driver.js";
|
||||
export * from "./drivers/acpx/sidecar-protocol.js";
|
||||
export { resolveClaudeEffort } from "./drivers/acpx/claude-effort.js";
|
||||
export * from "./drivers/runner-tool-bridge.js";
|
||||
export {
|
||||
createRunnerdCodexTransport,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveClaudeEffort, admittedClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import { configuredEnvironment } from "../configured-environment.js";
|
||||
import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js";
|
||||
@@ -1191,6 +1192,7 @@ export interface CapabilityRunnerdProcessEvidence {
|
||||
providerDriver: string | null;
|
||||
/** Effective native mode, populated only from the admitted Pi identity. */
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerVersion: string | null;
|
||||
acpxAgent: QualifiedAcpxAgent | null;
|
||||
agentServerVersion: string | null;
|
||||
@@ -1215,6 +1217,7 @@ export interface CapabilityRunnerdCodexTransportOptions {
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxMode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
acpxPermissionModePinned?: boolean;
|
||||
acpxSidecarPath?: string;
|
||||
/** SHA-256 verified by the provider-pack authority before runner startup. */
|
||||
@@ -3617,6 +3620,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string; permission?: boolean }>();
|
||||
|
||||
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
|
||||
resolveClaudeEffort(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.claudeEffort);
|
||||
resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel);
|
||||
if (options.acpxMode !== undefined && options.provider !== "acpx") {
|
||||
throw new Error("acpxMode requires the ACPX provider");
|
||||
@@ -4912,6 +4916,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" },
|
||||
...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }),
|
||||
...(acpxProfile!.agent === "pi" ? { piThinkingLevel: this.options.piThinkingLevel } : {}),
|
||||
...(acpxProfile!.agent === "claude" && this.options.claudeEffort ? { claudeEffort: this.options.claudeEffort } : {}),
|
||||
permissionMode: resolveRunnerdAcpxPermissionMode(
|
||||
this.options.acpxPermissionMode,
|
||||
),
|
||||
@@ -6597,6 +6602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) {
|
||||
throw new Error("ACPX capability identity differs from the admitted profile");
|
||||
}
|
||||
const claudeEffort = admittedClaudeEffort(this.options.acpxAgent ?? "codex", this.options.claudeEffort, descriptor, providerIdentity);
|
||||
if (claudeEffort !== undefined) this.#evidence.claudeEffort = claudeEffort;
|
||||
const piThinkingLevel = admittedPiThinkingLevel(this.options.acpxAgent ?? "codex", this.options.piThinkingLevel, descriptor, providerIdentity);
|
||||
if (piThinkingLevel !== undefined) this.#evidence.piThinkingLevel = piThinkingLevel;
|
||||
this.#turnControls = parseAcpxTurnControlCapabilities(descriptor.turnControls, descriptor.agent);
|
||||
|
||||
@@ -1178,6 +1178,15 @@ export const providerDescriptorSchema = {
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"claudeEffort": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -1201,6 +1210,26 @@ export const providerDescriptorSchema = {
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": [
|
||||
"claudeEffort"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "acpx"
|
||||
},
|
||||
"agent": {
|
||||
"const": "claude"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -7,13 +7,17 @@ export interface AgentRunnerAvailability {
|
||||
defaultRunner: AgentRunner;
|
||||
}
|
||||
|
||||
/** Codex is the only harness whose creation default graduates in this release. */
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined {
|
||||
return harness === "codex_local" ? { provider: "codex" } : undefined;
|
||||
/** Only qualified harnesses graduate their creation default. */
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
|
||||
if (harness === "codex_local") return { provider: "codex" };
|
||||
if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Creation defaults follow the shipped Linux x64 and macOS release artifacts. */
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean {
|
||||
// Claude graduates on Apple Silicon first; other targets await package and live qualification.
|
||||
if (harness === "claude_local") return platform === "darwin" && architecture === "arm64";
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& ((platform === "linux" && architecture === "x64")
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
|
||||
Reference in new issue
Block a user