mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
fix: wire gigacode --yolo through opencode session permissionMode
This commit is contained in:
parent
4bdd2416d1
commit
63625ee48f
16 changed files with 734 additions and 124 deletions
|
|
@ -41,6 +41,10 @@ Events / Message Flow
|
|||
| error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error |
|
||||
+------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+
|
||||
|
||||
Permission status normalization:
|
||||
- `permission.requested` uses `status=requested`.
|
||||
- `permission.resolved` uses `status=accept`, `accept_for_session`, or `reject`.
|
||||
|
||||
Synthetics
|
||||
|
||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||
|
|
|
|||
|
|
@ -1408,8 +1408,9 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"requested",
|
||||
"approved",
|
||||
"denied"
|
||||
"accept",
|
||||
"accept_for_session",
|
||||
"reject"
|
||||
]
|
||||
},
|
||||
"ProblemDetails": {
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more)
|
|||
| Type | Description | Data |
|
||||
|------|-------------|------|
|
||||
| `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` |
|
||||
| `permission.resolved` | Permission granted or denied | `{ permission_id, action, status, metadata? }` |
|
||||
| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` |
|
||||
| `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` |
|
||||
| `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` |
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more)
|
|||
|-------|------|-------------|
|
||||
| `permission_id` | string | Identifier for the permission request |
|
||||
| `action` | string | What the agent wants to do |
|
||||
| `status` | string | `requested`, `approved`, `denied` |
|
||||
| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` |
|
||||
| `metadata` | any? | Additional context |
|
||||
|
||||
**QuestionEventData**
|
||||
|
|
|
|||
|
|
@ -17,9 +17,19 @@ fn run() -> Result<(), CliError> {
|
|||
no_token: cli.no_token,
|
||||
gigacode: true,
|
||||
};
|
||||
let command = cli
|
||||
.command
|
||||
.unwrap_or_else(|| Command::Opencode(OpencodeArgs::default()));
|
||||
let yolo = cli.yolo;
|
||||
let command = match cli.command {
|
||||
Some(Command::Opencode(mut args)) => {
|
||||
args.yolo = args.yolo || yolo;
|
||||
Command::Opencode(args)
|
||||
}
|
||||
Some(other) => other,
|
||||
None => {
|
||||
let mut args = OpencodeArgs::default();
|
||||
args.yolo = yolo;
|
||||
Command::Opencode(args)
|
||||
}
|
||||
};
|
||||
if let Err(err) = init_logging(&command) {
|
||||
eprintln!("failed to init logging: {err}");
|
||||
return Err(err);
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ export interface components {
|
|||
reply: components["schemas"]["PermissionReply"];
|
||||
};
|
||||
/** @enum {string} */
|
||||
PermissionStatus: "requested" | "approved" | "denied";
|
||||
PermissionStatus: "requested" | "accept" | "accept_for_session" | "reject";
|
||||
ProblemDetails: {
|
||||
detail?: string | null;
|
||||
instance?: string | null;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ pub struct GigacodeCli {
|
|||
|
||||
#[arg(long, short = 'n', global = true)]
|
||||
pub no_token: bool,
|
||||
|
||||
/// Bypass all permission checks (auto-approve tool calls).
|
||||
#[arg(long, global = true)]
|
||||
pub yolo: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -126,6 +130,10 @@ pub struct OpencodeArgs {
|
|||
|
||||
#[arg(long)]
|
||||
session_title: Option<String>,
|
||||
|
||||
/// Bypass all permission checks (auto-approve tool calls).
|
||||
#[arg(long)]
|
||||
pub yolo: bool,
|
||||
}
|
||||
|
||||
impl Default for OpencodeArgs {
|
||||
|
|
@ -134,6 +142,7 @@ impl Default for OpencodeArgs {
|
|||
host: DEFAULT_HOST.to_string(),
|
||||
port: DEFAULT_PORT,
|
||||
session_title: None,
|
||||
yolo: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -592,13 +601,18 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> {
|
|||
};
|
||||
write_stderr_line(&format!("\nEXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:\n- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?;
|
||||
|
||||
let yolo = args.yolo;
|
||||
let token = cli.token.clone();
|
||||
|
||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
|
||||
|
||||
let session_id =
|
||||
create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
|
||||
let session_id = create_opencode_session(
|
||||
&base_url,
|
||||
token.as_deref(),
|
||||
args.session_title.as_deref(),
|
||||
yolo,
|
||||
)?;
|
||||
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
||||
|
||||
let attach_url = format!("{base_url}/opencode");
|
||||
|
|
@ -807,14 +821,18 @@ fn create_opencode_session(
|
|||
base_url: &str,
|
||||
token: Option<&str>,
|
||||
title: Option<&str>,
|
||||
yolo: bool,
|
||||
) -> Result<String, CliError> {
|
||||
let client = HttpClient::builder().build()?;
|
||||
let url = format!("{base_url}/opencode/session");
|
||||
let body = if let Some(title) = title {
|
||||
let mut body = if let Some(title) = title {
|
||||
json!({ "title": title })
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
if yolo {
|
||||
body["permissionMode"] = json!("bypass");
|
||||
}
|
||||
let mut request = client.post(&url).json(&body);
|
||||
if let Ok(directory) = std::env::current_dir() {
|
||||
request = request.header(
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ struct OpenCodeSessionRecord {
|
|||
created_at: i64,
|
||||
updated_at: i64,
|
||||
share_url: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
impl OpenCodeSessionRecord {
|
||||
|
|
@ -370,6 +371,7 @@ impl OpenCodeState {
|
|||
created_at: now,
|
||||
updated_at: now,
|
||||
share_url: None,
|
||||
permission_mode: None,
|
||||
};
|
||||
let value = record.to_value();
|
||||
sessions.insert(session_id.to_string(), record);
|
||||
|
|
@ -434,13 +436,14 @@ async fn ensure_backing_session(
|
|||
agent: &str,
|
||||
model: Option<String>,
|
||||
variant: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
) -> Result<(), SandboxError> {
|
||||
let model = model.filter(|value| !value.trim().is_empty());
|
||||
let variant = variant.filter(|value| !value.trim().is_empty());
|
||||
let request = CreateSessionRequest {
|
||||
agent: agent.to_string(),
|
||||
agent_mode: None,
|
||||
permission_mode: None,
|
||||
permission_mode,
|
||||
model: model.clone(),
|
||||
variant: variant.clone(),
|
||||
agent_version: None,
|
||||
|
|
@ -520,6 +523,8 @@ struct OpenCodeCreateSessionRequest {
|
|||
parent_id: Option<String>,
|
||||
#[schema(value_type = String)]
|
||||
permission: Option<Value>,
|
||||
#[serde(alias = "permission_mode")]
|
||||
permission_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
|
@ -1936,10 +1941,13 @@ async fn apply_permission_event(
|
|||
.opencode
|
||||
.emit_event(permission_event("permission.asked", &value));
|
||||
}
|
||||
PermissionStatus::Approved | PermissionStatus::Denied => {
|
||||
PermissionStatus::Accept
|
||||
| PermissionStatus::AcceptForSession
|
||||
| PermissionStatus::Reject => {
|
||||
let reply = match permission.status {
|
||||
PermissionStatus::Approved => "once",
|
||||
PermissionStatus::Denied => "reject",
|
||||
PermissionStatus::Accept => "once",
|
||||
PermissionStatus::AcceptForSession => "always",
|
||||
PermissionStatus::Reject => "reject",
|
||||
PermissionStatus::Requested => "once",
|
||||
};
|
||||
let event_value = json!({
|
||||
|
|
@ -2700,9 +2708,7 @@ async fn apply_item_delta(
|
|||
runtime
|
||||
.part_id_by_message
|
||||
.insert(message_id.clone(), part_id.clone());
|
||||
runtime
|
||||
.messages_with_text_deltas
|
||||
.insert(message_id.clone());
|
||||
runtime.messages_with_text_deltas.insert(message_id.clone());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
|
@ -3354,6 +3360,7 @@ async fn oc_session_create(
|
|||
title: None,
|
||||
parent_id: None,
|
||||
permission: None,
|
||||
permission_mode: None,
|
||||
});
|
||||
let directory = state
|
||||
.opencode
|
||||
|
|
@ -3362,6 +3369,7 @@ async fn oc_session_create(
|
|||
let id = next_id("ses_", &SESSION_COUNTER);
|
||||
let slug = format!("session-{}", id);
|
||||
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
|
||||
let permission_mode = body.permission_mode;
|
||||
let record = OpenCodeSessionRecord {
|
||||
id: id.clone(),
|
||||
slug,
|
||||
|
|
@ -3373,6 +3381,7 @@ async fn oc_session_create(
|
|||
created_at: now,
|
||||
updated_at: now,
|
||||
share_url: None,
|
||||
permission_mode,
|
||||
};
|
||||
|
||||
let session_value = record.to_value();
|
||||
|
|
@ -3541,6 +3550,12 @@ async fn oc_session_fork(
|
|||
let id = next_id("ses_", &SESSION_COUNTER);
|
||||
let slug = format!("session-{}", id);
|
||||
let title = format!("Fork of {}", session_id);
|
||||
let parent_permission_mode = {
|
||||
let sessions = state.opencode.sessions.lock().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.and_then(|s| s.permission_mode.clone())
|
||||
};
|
||||
let record = OpenCodeSessionRecord {
|
||||
id: id.clone(),
|
||||
slug,
|
||||
|
|
@ -3552,6 +3567,7 @@ async fn oc_session_fork(
|
|||
created_at: now,
|
||||
updated_at: now,
|
||||
share_url: None,
|
||||
permission_mode: parent_permission_mode,
|
||||
};
|
||||
|
||||
let value = record.to_value();
|
||||
|
|
@ -3722,12 +3738,20 @@ async fn oc_session_message_create(
|
|||
})
|
||||
.await;
|
||||
|
||||
let session_permission_mode = {
|
||||
let sessions = state.opencode.sessions.lock().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.and_then(|s| s.permission_mode.clone())
|
||||
};
|
||||
|
||||
if let Err(err) = ensure_backing_session(
|
||||
&state,
|
||||
&session_id,
|
||||
&session_agent,
|
||||
backing_model,
|
||||
backing_variant,
|
||||
session_permission_mode,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -417,6 +417,7 @@ struct SessionState {
|
|||
events: Vec<UniversalEvent>,
|
||||
pending_questions: HashMap<String, PendingQuestion>,
|
||||
pending_permissions: HashMap<String, PendingPermission>,
|
||||
always_allow_actions: HashSet<String>,
|
||||
item_started: HashSet<String>,
|
||||
item_delta_seen: HashSet<String>,
|
||||
item_map: HashMap<String, String>,
|
||||
|
|
@ -475,6 +476,7 @@ impl SessionState {
|
|||
events: Vec::new(),
|
||||
pending_questions: HashMap::new(),
|
||||
pending_permissions: HashMap::new(),
|
||||
always_allow_actions: HashSet::new(),
|
||||
item_started: HashSet::new(),
|
||||
item_delta_seen: HashSet::new(),
|
||||
item_map: HashMap::new(),
|
||||
|
|
@ -796,6 +798,18 @@ impl SessionState {
|
|||
self.pending_permissions.remove(permission_id)
|
||||
}
|
||||
|
||||
fn remember_permission_allow_for_session(&mut self, action: &str, metadata: &Option<Value>) {
|
||||
for key in permission_cache_keys(action, metadata) {
|
||||
self.always_allow_actions.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_auto_approve_permission(&self, action: &str, metadata: &Option<Value>) -> bool {
|
||||
permission_cache_keys(action, metadata)
|
||||
.iter()
|
||||
.any(|key| self.always_allow_actions.contains(key))
|
||||
}
|
||||
|
||||
/// Find and remove a pending permission whose action matches a question tool
|
||||
/// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
|
||||
fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> {
|
||||
|
|
@ -2334,7 +2348,7 @@ impl SessionManager {
|
|||
UniversalEventData::Permission(PermissionEventData {
|
||||
permission_id: perm_id,
|
||||
action: perm.action,
|
||||
status: PermissionStatus::Approved,
|
||||
status: PermissionStatus::Accept,
|
||||
metadata: perm.metadata,
|
||||
}),
|
||||
)
|
||||
|
|
@ -2454,7 +2468,7 @@ impl SessionManager {
|
|||
UniversalEventData::Permission(PermissionEventData {
|
||||
permission_id: perm_id,
|
||||
action: perm.action,
|
||||
status: PermissionStatus::Denied,
|
||||
status: PermissionStatus::Reject,
|
||||
metadata: perm.metadata,
|
||||
}),
|
||||
)
|
||||
|
|
@ -2489,6 +2503,12 @@ impl SessionManager {
|
|||
message: format!("unknown permission id: {permission_id}"),
|
||||
});
|
||||
}
|
||||
if matches!(reply_for_status, PermissionReply::Always) {
|
||||
if let Some(pending) = pending.as_ref() {
|
||||
session
|
||||
.remember_permission_allow_for_session(&pending.action, &pending.metadata);
|
||||
}
|
||||
}
|
||||
if let Some(err) = session.ended_error() {
|
||||
return Err(err);
|
||||
}
|
||||
|
|
@ -2610,8 +2630,9 @@ impl SessionManager {
|
|||
|
||||
if let Some(pending) = pending_permission {
|
||||
let status = match reply_for_status {
|
||||
PermissionReply::Reject => PermissionStatus::Denied,
|
||||
PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved,
|
||||
PermissionReply::Reject => PermissionStatus::Reject,
|
||||
PermissionReply::Once => PermissionStatus::Accept,
|
||||
PermissionReply::Always => PermissionStatus::AcceptForSession,
|
||||
};
|
||||
let resolved = EventConversion::new(
|
||||
UniversalEventType::PermissionResolved,
|
||||
|
|
@ -2910,13 +2931,127 @@ impl SessionManager {
|
|||
session_id: &str,
|
||||
conversions: Vec<EventConversion>,
|
||||
) -> Result<Vec<UniversalEvent>, SandboxError> {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
SandboxError::SessionNotFound {
|
||||
session_id: session_id.to_string(),
|
||||
let (events, auto_approvals) = {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||
SandboxError::SessionNotFound {
|
||||
session_id: session_id.to_string(),
|
||||
}
|
||||
})?;
|
||||
let events = session.record_conversions(conversions);
|
||||
let mut auto_approvals = Vec::new();
|
||||
for event in &events {
|
||||
if event.event_type != UniversalEventType::PermissionRequested {
|
||||
continue;
|
||||
}
|
||||
let UniversalEventData::Permission(data) = &event.data else {
|
||||
continue;
|
||||
};
|
||||
let cached = session.should_auto_approve_permission(&data.action, &data.metadata);
|
||||
if session.agent == AgentId::Codex
|
||||
|| is_question_tool_action(&data.action)
|
||||
|| !cached
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(pending) = session.take_permission(&data.permission_id) {
|
||||
auto_approvals.push((
|
||||
session.agent,
|
||||
session.native_session_id.clone(),
|
||||
session.claude_sender(),
|
||||
data.permission_id.clone(),
|
||||
pending,
|
||||
));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(session.record_conversions(conversions))
|
||||
(events, auto_approvals)
|
||||
};
|
||||
|
||||
for (agent, native_session_id, claude_sender, permission_id, pending) in auto_approvals {
|
||||
let reply_result = match agent {
|
||||
AgentId::Opencode => {
|
||||
let agent_session_id =
|
||||
native_session_id
|
||||
.clone()
|
||||
.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "missing OpenCode session id".to_string(),
|
||||
});
|
||||
match agent_session_id {
|
||||
Ok(agent_session_id) => {
|
||||
self.opencode_permission_reply(
|
||||
&agent_session_id,
|
||||
&permission_id,
|
||||
PermissionReply::Always,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
AgentId::Claude => {
|
||||
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
});
|
||||
match sender {
|
||||
Ok(sender) => {
|
||||
let metadata = pending.metadata.as_ref().and_then(Value::as_object);
|
||||
let updated_input = metadata
|
||||
.and_then(|map| map.get("input"))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
let mut response_map = serde_json::Map::new();
|
||||
if !updated_input.is_null() {
|
||||
response_map.insert("updatedInput".to_string(), updated_input);
|
||||
}
|
||||
let line = claude_control_response_line(
|
||||
&permission_id,
|
||||
"allow",
|
||||
Value::Object(response_map),
|
||||
);
|
||||
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
|
||||
message: "Claude session is not active".to_string(),
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
|
||||
if let Err(err) = reply_result {
|
||||
tracing::warn!(
|
||||
session_id,
|
||||
permission_id,
|
||||
?err,
|
||||
"failed to auto-approve cached permission"
|
||||
);
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = Self::session_mut(&mut sessions, session_id) {
|
||||
session
|
||||
.pending_permissions
|
||||
.insert(permission_id.clone(), pending.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = EventConversion::new(
|
||||
UniversalEventType::PermissionResolved,
|
||||
UniversalEventData::Permission(PermissionEventData {
|
||||
permission_id: permission_id.clone(),
|
||||
action: pending.action,
|
||||
status: PermissionStatus::AcceptForSession,
|
||||
metadata: pending.metadata,
|
||||
}),
|
||||
)
|
||||
.synthetic()
|
||||
.with_native_session(native_session_id);
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = Self::session_mut(&mut sessions, session_id) {
|
||||
session.record_conversions(vec![resolved]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> {
|
||||
|
|
@ -5412,6 +5547,60 @@ pub(crate) fn is_question_tool_action(action: &str) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
push_permission_cache_key(&mut keys, action);
|
||||
if let Some(metadata) = metadata.as_ref().and_then(Value::as_object) {
|
||||
if let Some(permission) = metadata.get("permission").and_then(Value::as_str) {
|
||||
push_permission_cache_key(&mut keys, permission);
|
||||
}
|
||||
if let Some(kind) = metadata.get("codexRequestKind").and_then(Value::as_str) {
|
||||
push_permission_cache_key(&mut keys, kind);
|
||||
}
|
||||
if let Some(tool_name) = metadata
|
||||
.get("toolName")
|
||||
.or_else(|| metadata.get("tool_name"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
push_permission_cache_key(&mut keys, tool_name);
|
||||
}
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
keys
|
||||
}
|
||||
|
||||
fn push_permission_cache_key(keys: &mut Vec<String>, raw: &str) {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return;
|
||||
}
|
||||
keys.push(raw.to_string());
|
||||
if let Some(category) = permission_action_category(raw) {
|
||||
keys.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_action_category(action: &str) -> Option<String> {
|
||||
let first = action.split_whitespace().next().unwrap_or(action);
|
||||
let stripped = first
|
||||
.split_once(':')
|
||||
.map(|(prefix, _)| prefix)
|
||||
.unwrap_or(first)
|
||||
.trim();
|
||||
if stripped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if stripped
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.')
|
||||
{
|
||||
Some(stripped.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
|
|
|||
|
|
@ -53,6 +53,37 @@ describe("OpenCode-compatible Permission API", () => {
|
|||
throw new Error("Timed out waiting for permission request");
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
check: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 10_000,
|
||||
intervalMs = 100,
|
||||
) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await check()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
async function waitForValue<T>(
|
||||
getValue: () => T | undefined | Promise<T | undefined>,
|
||||
timeoutMs = 10_000,
|
||||
intervalMs = 100,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const value = await getValue();
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error("Timed out waiting for value");
|
||||
}
|
||||
|
||||
describe("permission.reply (global)", () => {
|
||||
it("should receive permission.asked and reply via global endpoint", async () => {
|
||||
await client.session.prompt({
|
||||
|
|
@ -71,6 +102,108 @@ describe("OpenCode-compatible Permission API", () => {
|
|||
});
|
||||
expect(response.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should emit permission.replied with always when reply is always", async () => {
|
||||
const eventStream = await client.event.subscribe();
|
||||
const repliedEventPromise = new Promise<any>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("Timed out waiting for permission.replied")), 15_000);
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
if (event.type === "permission.replied") {
|
||||
clearTimeout(timeout);
|
||||
resolve(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const asked = await waitForPermissionRequest();
|
||||
const requestId = asked?.id;
|
||||
expect(requestId).toBeDefined();
|
||||
|
||||
const response = await client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply: "always",
|
||||
});
|
||||
expect(response.error).toBeUndefined();
|
||||
|
||||
const replied = await repliedEventPromise;
|
||||
expect(replied?.properties?.requestID).toBe(requestId);
|
||||
expect(replied?.properties?.reply).toBe("always");
|
||||
});
|
||||
|
||||
it("should auto-reply subsequent matching permissions after always", async () => {
|
||||
const eventStream = await client.event.subscribe();
|
||||
const repliedEvents: any[] = [];
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of (eventStream as any).stream) {
|
||||
if (event.type === "permission.replied") {
|
||||
repliedEvents.push(event);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream can end during test teardown.
|
||||
}
|
||||
})();
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const firstAsked = await waitForPermissionRequest();
|
||||
const firstRequestId = firstAsked?.id;
|
||||
expect(firstRequestId).toBeDefined();
|
||||
|
||||
const firstReply = await client.permission.reply({
|
||||
requestID: firstRequestId,
|
||||
reply: "always",
|
||||
});
|
||||
expect(firstReply.error).toBeUndefined();
|
||||
|
||||
await waitForCondition(() =>
|
||||
repliedEvents.some(
|
||||
(event) =>
|
||||
event?.properties?.requestID === firstRequestId &&
|
||||
event?.properties?.reply === "always",
|
||||
),
|
||||
);
|
||||
|
||||
await client.session.prompt({
|
||||
sessionID: sessionId,
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: permissionPrompt }],
|
||||
});
|
||||
|
||||
const autoReplyEvent = await waitForValue(() =>
|
||||
repliedEvents.find(
|
||||
(event) =>
|
||||
event?.properties?.requestID !== firstRequestId &&
|
||||
event?.properties?.reply === "always",
|
||||
),
|
||||
);
|
||||
const autoRequestId = autoReplyEvent?.properties?.requestID;
|
||||
expect(autoRequestId).toBeDefined();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const list = await client.permission.list();
|
||||
return !(list.data ?? []).some((item) => item?.id === autoRequestId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("postSessionIdPermissionsPermissionId (session)", () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,29 @@ describe("OpenCode-compatible Session API", () => {
|
|||
let handle: SandboxAgentHandle;
|
||||
let client: OpencodeClient;
|
||||
|
||||
async function createSessionViaHttp(body: Record<string, unknown>) {
|
||||
const response = await fetch(`${handle.baseUrl}/opencode/session`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${handle.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getBackingSessionPermissionMode(sessionId: string) {
|
||||
const response = await fetch(`${handle.baseUrl}/v1/sessions`, {
|
||||
headers: { Authorization: `Bearer ${handle.token}` },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
const session = (data.sessions ?? []).find((item: any) => item.sessionId === sessionId);
|
||||
return session?.permissionMode;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build the binary if needed
|
||||
await buildSandboxAgent();
|
||||
|
|
@ -63,6 +86,42 @@ describe("OpenCode-compatible Session API", () => {
|
|||
|
||||
expect(session1.data?.id).not.toBe(session2.data?.id);
|
||||
});
|
||||
|
||||
it("should pass permissionMode bypass to backing session", async () => {
|
||||
const session = await createSessionViaHttp({ permissionMode: "bypass" });
|
||||
const sessionId = session.id as string;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const prompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
});
|
||||
expect(prompt.error).toBeUndefined();
|
||||
|
||||
const permissionMode = await getBackingSessionPermissionMode(sessionId);
|
||||
expect(permissionMode).toBe("bypass");
|
||||
});
|
||||
|
||||
it("should accept permission_mode alias and pass bypass to backing session", async () => {
|
||||
const session = await createSessionViaHttp({ permission_mode: "bypass" });
|
||||
const sessionId = session.id as string;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
const prompt = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: "mock", modelID: "mock" },
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
});
|
||||
expect(prompt.error).toBeUndefined();
|
||||
|
||||
const permissionMode = await getBackingSessionPermissionMode(sessionId);
|
||||
expect(permissionMode).toBe("bypass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.list", () => {
|
||||
|
|
|
|||
|
|
@ -80,3 +80,193 @@ async fn permission_flow_snapshots() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permission_reply_always_sets_accept_for_session_status() {
|
||||
let app = TestApp::new();
|
||||
install_agent(&app.app, AgentId::Mock).await;
|
||||
|
||||
let session_id = "perm-always-mock";
|
||||
create_session(&app.app, AgentId::Mock, session_id, "plan").await;
|
||||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/messages"),
|
||||
Some(json!({ "message": PERMISSION_PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "send permission prompt");
|
||||
|
||||
let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
|
||||
find_permission_id(events).is_some() || should_stop(events)
|
||||
})
|
||||
.await;
|
||||
let permission_id = find_permission_id(&events).expect("permission.requested missing");
|
||||
|
||||
let status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
|
||||
Some(json!({ "reply": "always" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "reply permission always");
|
||||
|
||||
let resolved_events =
|
||||
poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(permission_id.as_str())
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let resolved = resolved_events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| {
|
||||
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(permission_id.as_str())
|
||||
})
|
||||
.expect("permission.resolved missing");
|
||||
let status = resolved
|
||||
.get("data")
|
||||
.and_then(|data| data.get("status"))
|
||||
.and_then(Value::as_str);
|
||||
assert_eq!(status, Some("accept_for_session"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn permission_reply_always_auto_approves_subsequent_permissions() {
|
||||
let app = TestApp::new();
|
||||
install_agent(&app.app, AgentId::Mock).await;
|
||||
|
||||
let session_id = "perm-always-auto-mock";
|
||||
create_session(&app.app, AgentId::Mock, session_id, "plan").await;
|
||||
|
||||
let first_status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/messages"),
|
||||
Some(json!({ "message": PERMISSION_PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
first_status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"send first permission prompt"
|
||||
);
|
||||
|
||||
let first_events =
|
||||
poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
|
||||
find_permission_id(events).is_some() || should_stop(events)
|
||||
})
|
||||
.await;
|
||||
let first_permission_id =
|
||||
find_permission_id(&first_events).expect("first permission.requested missing");
|
||||
|
||||
let reply_status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/permissions/{first_permission_id}/reply"),
|
||||
Some(json!({ "reply": "always" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
reply_status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"reply first permission always"
|
||||
);
|
||||
|
||||
let second_status = send_status(
|
||||
&app.app,
|
||||
Method::POST,
|
||||
&format!("/v1/sessions/{session_id}/messages"),
|
||||
Some(json!({ "message": PERMISSION_PROMPT })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
second_status,
|
||||
StatusCode::NO_CONTENT,
|
||||
"send second permission prompt"
|
||||
);
|
||||
|
||||
let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| {
|
||||
let requested_ids = events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
if event.get("type").and_then(Value::as_str) != Some("permission.requested") {
|
||||
return None;
|
||||
}
|
||||
event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if requested_ids.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let second_permission_id = &requested_ids[1];
|
||||
events.iter().any(|event| {
|
||||
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(second_permission_id.as_str())
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("accept_for_session")
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let requested_ids = events
|
||||
.iter()
|
||||
.filter_map(|event| {
|
||||
if event.get("type").and_then(Value::as_str) != Some("permission.requested") {
|
||||
return None;
|
||||
}
|
||||
event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
requested_ids.len() >= 2,
|
||||
"expected at least two permission.requested events"
|
||||
);
|
||||
let second_permission_id = &requested_ids[1];
|
||||
|
||||
let second_resolved = events.iter().any(|event| {
|
||||
event.get("type").and_then(Value::as_str) == Some("permission.resolved")
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("permission_id"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(second_permission_id.as_str())
|
||||
&& event
|
||||
.get("data")
|
||||
.and_then(|data| data.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("accept_for_session")
|
||||
});
|
||||
assert!(
|
||||
second_resolved,
|
||||
"second permission should auto-resolve as accept_for_session"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,36 @@ first:
|
|||
status: in_progress
|
||||
seq: 5
|
||||
type: item.started
|
||||
- item:
|
||||
content_types: []
|
||||
kind: message
|
||||
role: assistant
|
||||
status: completed
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 6
|
||||
type: item.completed
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 7
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 8
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 9
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 10
|
||||
type: item.delta
|
||||
second:
|
||||
- item:
|
||||
content_types:
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
---
|
||||
source: server/packages/sandbox-agent/tests/sessions/permissions.rs
|
||||
assertion_line: 12
|
||||
expression: value
|
||||
---
|
||||
- metadata: true
|
||||
seq: 1
|
||||
session: started
|
||||
type: session.started
|
||||
- item:
|
||||
content_types:
|
||||
- text
|
||||
kind: message
|
||||
role: user
|
||||
status: in_progress
|
||||
seq: 2
|
||||
type: item.started
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 3
|
||||
type: item.delta
|
||||
- item:
|
||||
content_types:
|
||||
- text
|
||||
kind: message
|
||||
role: user
|
||||
status: completed
|
||||
seq: 4
|
||||
type: item.completed
|
||||
- item:
|
||||
content_types:
|
||||
- text
|
||||
kind: message
|
||||
role: assistant
|
||||
status: in_progress
|
||||
seq: 5
|
||||
type: item.started
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 6
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 7
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 8
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 9
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 10
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 11
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 12
|
||||
type: item.delta
|
||||
|
|
@ -91,9 +91,47 @@ expression: value
|
|||
native_item_id: "<redacted>"
|
||||
seq: 14
|
||||
type: item.delta
|
||||
- question:
|
||||
id: "<redacted>"
|
||||
options: 4
|
||||
status: requested
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 15
|
||||
type: question.requested
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 16
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 17
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 18
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 19
|
||||
type: item.delta
|
||||
- delta:
|
||||
delta: "<redacted>"
|
||||
item_id: "<redacted>"
|
||||
native_item_id: "<redacted>"
|
||||
seq: 20
|
||||
type: item.delta
|
||||
- item:
|
||||
content_types:
|
||||
- text
|
||||
kind: message
|
||||
role: assistant
|
||||
status: completed
|
||||
seq: 21
|
||||
type: item.completed
|
||||
|
|
|
|||
|
|
@ -161,8 +161,9 @@ pub struct PermissionEventData {
|
|||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionStatus {
|
||||
Requested,
|
||||
Approved,
|
||||
Denied,
|
||||
Accept,
|
||||
AcceptForSession,
|
||||
Reject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||
|
|
|
|||
|
|
@ -370,8 +370,9 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"requested",
|
||||
"approved",
|
||||
"denied"
|
||||
"accept",
|
||||
"accept_for_session",
|
||||
"reject"
|
||||
]
|
||||
},
|
||||
"QuestionEventData": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue