mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 09:02:12 +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 |
|
| 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
|
Synthetics
|
||||||
|
|
||||||
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
+------------------------------+------------------------+--------------------------+--------------------------------------------------------------+
|
||||||
|
|
|
||||||
|
|
@ -1408,8 +1408,9 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"requested",
|
"requested",
|
||||||
"approved",
|
"accept",
|
||||||
"denied"
|
"accept_for_session",
|
||||||
|
"reject"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ProblemDetails": {
|
"ProblemDetails": {
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more)
|
||||||
| Type | Description | Data |
|
| Type | Description | Data |
|
||||||
|------|-------------|------|
|
|------|-------------|------|
|
||||||
| `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` |
|
| `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.requested` | Question pending user input | `{ question_id, prompt, options, status }` |
|
||||||
| `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` |
|
| `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 |
|
| `permission_id` | string | Identifier for the permission request |
|
||||||
| `action` | string | What the agent wants to do |
|
| `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 |
|
| `metadata` | any? | Additional context |
|
||||||
|
|
||||||
**QuestionEventData**
|
**QuestionEventData**
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,19 @@ fn run() -> Result<(), CliError> {
|
||||||
no_token: cli.no_token,
|
no_token: cli.no_token,
|
||||||
gigacode: true,
|
gigacode: true,
|
||||||
};
|
};
|
||||||
let command = cli
|
let yolo = cli.yolo;
|
||||||
.command
|
let command = match cli.command {
|
||||||
.unwrap_or_else(|| Command::Opencode(OpencodeArgs::default()));
|
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) {
|
if let Err(err) = init_logging(&command) {
|
||||||
eprintln!("failed to init logging: {err}");
|
eprintln!("failed to init logging: {err}");
|
||||||
return Err(err);
|
return Err(err);
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ export interface components {
|
||||||
reply: components["schemas"]["PermissionReply"];
|
reply: components["schemas"]["PermissionReply"];
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
PermissionStatus: "requested" | "approved" | "denied";
|
PermissionStatus: "requested" | "accept" | "accept_for_session" | "reject";
|
||||||
ProblemDetails: {
|
ProblemDetails: {
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
instance?: string | null;
|
instance?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ pub struct GigacodeCli {
|
||||||
|
|
||||||
#[arg(long, short = 'n', global = true)]
|
#[arg(long, short = 'n', global = true)]
|
||||||
pub no_token: bool,
|
pub no_token: bool,
|
||||||
|
|
||||||
|
/// Bypass all permission checks (auto-approve tool calls).
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub yolo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
|
|
@ -126,6 +130,10 @@ pub struct OpencodeArgs {
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
session_title: Option<String>,
|
session_title: Option<String>,
|
||||||
|
|
||||||
|
/// Bypass all permission checks (auto-approve tool calls).
|
||||||
|
#[arg(long)]
|
||||||
|
pub yolo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OpencodeArgs {
|
impl Default for OpencodeArgs {
|
||||||
|
|
@ -134,6 +142,7 @@ impl Default for OpencodeArgs {
|
||||||
host: DEFAULT_HOST.to_string(),
|
host: DEFAULT_HOST.to_string(),
|
||||||
port: DEFAULT_PORT,
|
port: DEFAULT_PORT,
|
||||||
session_title: None,
|
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"))?;
|
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 token = cli.token.clone();
|
||||||
|
|
||||||
let base_url = format!("http://{}:{}", args.host, args.port);
|
let base_url = format!("http://{}:{}", args.host, args.port);
|
||||||
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
|
crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?;
|
||||||
|
|
||||||
let session_id =
|
let session_id = create_opencode_session(
|
||||||
create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?;
|
&base_url,
|
||||||
|
token.as_deref(),
|
||||||
|
args.session_title.as_deref(),
|
||||||
|
yolo,
|
||||||
|
)?;
|
||||||
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
write_stdout_line(&format!("OpenCode session: {session_id}"))?;
|
||||||
|
|
||||||
let attach_url = format!("{base_url}/opencode");
|
let attach_url = format!("{base_url}/opencode");
|
||||||
|
|
@ -807,14 +821,18 @@ fn create_opencode_session(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
|
yolo: bool,
|
||||||
) -> Result<String, CliError> {
|
) -> Result<String, CliError> {
|
||||||
let client = HttpClient::builder().build()?;
|
let client = HttpClient::builder().build()?;
|
||||||
let url = format!("{base_url}/opencode/session");
|
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 })
|
json!({ "title": title })
|
||||||
} else {
|
} else {
|
||||||
json!({})
|
json!({})
|
||||||
};
|
};
|
||||||
|
if yolo {
|
||||||
|
body["permissionMode"] = json!("bypass");
|
||||||
|
}
|
||||||
let mut request = client.post(&url).json(&body);
|
let mut request = client.post(&url).json(&body);
|
||||||
if let Ok(directory) = std::env::current_dir() {
|
if let Ok(directory) = std::env::current_dir() {
|
||||||
request = request.header(
|
request = request.header(
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ struct OpenCodeSessionRecord {
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
updated_at: i64,
|
updated_at: i64,
|
||||||
share_url: Option<String>,
|
share_url: Option<String>,
|
||||||
|
permission_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCodeSessionRecord {
|
impl OpenCodeSessionRecord {
|
||||||
|
|
@ -370,6 +371,7 @@ impl OpenCodeState {
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
|
permission_mode: None,
|
||||||
};
|
};
|
||||||
let value = record.to_value();
|
let value = record.to_value();
|
||||||
sessions.insert(session_id.to_string(), record);
|
sessions.insert(session_id.to_string(), record);
|
||||||
|
|
@ -434,13 +436,14 @@ async fn ensure_backing_session(
|
||||||
agent: &str,
|
agent: &str,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
variant: Option<String>,
|
variant: Option<String>,
|
||||||
|
permission_mode: Option<String>,
|
||||||
) -> Result<(), SandboxError> {
|
) -> Result<(), SandboxError> {
|
||||||
let model = model.filter(|value| !value.trim().is_empty());
|
let model = model.filter(|value| !value.trim().is_empty());
|
||||||
let variant = variant.filter(|value| !value.trim().is_empty());
|
let variant = variant.filter(|value| !value.trim().is_empty());
|
||||||
let request = CreateSessionRequest {
|
let request = CreateSessionRequest {
|
||||||
agent: agent.to_string(),
|
agent: agent.to_string(),
|
||||||
agent_mode: None,
|
agent_mode: None,
|
||||||
permission_mode: None,
|
permission_mode,
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
variant: variant.clone(),
|
variant: variant.clone(),
|
||||||
agent_version: None,
|
agent_version: None,
|
||||||
|
|
@ -520,6 +523,8 @@ struct OpenCodeCreateSessionRequest {
|
||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
#[schema(value_type = String)]
|
#[schema(value_type = String)]
|
||||||
permission: Option<Value>,
|
permission: Option<Value>,
|
||||||
|
#[serde(alias = "permission_mode")]
|
||||||
|
permission_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|
@ -1936,10 +1941,13 @@ async fn apply_permission_event(
|
||||||
.opencode
|
.opencode
|
||||||
.emit_event(permission_event("permission.asked", &value));
|
.emit_event(permission_event("permission.asked", &value));
|
||||||
}
|
}
|
||||||
PermissionStatus::Approved | PermissionStatus::Denied => {
|
PermissionStatus::Accept
|
||||||
|
| PermissionStatus::AcceptForSession
|
||||||
|
| PermissionStatus::Reject => {
|
||||||
let reply = match permission.status {
|
let reply = match permission.status {
|
||||||
PermissionStatus::Approved => "once",
|
PermissionStatus::Accept => "once",
|
||||||
PermissionStatus::Denied => "reject",
|
PermissionStatus::AcceptForSession => "always",
|
||||||
|
PermissionStatus::Reject => "reject",
|
||||||
PermissionStatus::Requested => "once",
|
PermissionStatus::Requested => "once",
|
||||||
};
|
};
|
||||||
let event_value = json!({
|
let event_value = json!({
|
||||||
|
|
@ -2700,9 +2708,7 @@ async fn apply_item_delta(
|
||||||
runtime
|
runtime
|
||||||
.part_id_by_message
|
.part_id_by_message
|
||||||
.insert(message_id.clone(), part_id.clone());
|
.insert(message_id.clone(), part_id.clone());
|
||||||
runtime
|
runtime.messages_with_text_deltas.insert(message_id.clone());
|
||||||
.messages_with_text_deltas
|
|
||||||
.insert(message_id.clone());
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -3354,6 +3360,7 @@ async fn oc_session_create(
|
||||||
title: None,
|
title: None,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
permission: None,
|
permission: None,
|
||||||
|
permission_mode: None,
|
||||||
});
|
});
|
||||||
let directory = state
|
let directory = state
|
||||||
.opencode
|
.opencode
|
||||||
|
|
@ -3362,6 +3369,7 @@ async fn oc_session_create(
|
||||||
let id = next_id("ses_", &SESSION_COUNTER);
|
let id = next_id("ses_", &SESSION_COUNTER);
|
||||||
let slug = format!("session-{}", id);
|
let slug = format!("session-{}", id);
|
||||||
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
|
let title = body.title.unwrap_or_else(|| format!("Session {}", id));
|
||||||
|
let permission_mode = body.permission_mode;
|
||||||
let record = OpenCodeSessionRecord {
|
let record = OpenCodeSessionRecord {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -3373,6 +3381,7 @@ async fn oc_session_create(
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
|
permission_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_value = record.to_value();
|
let session_value = record.to_value();
|
||||||
|
|
@ -3541,6 +3550,12 @@ async fn oc_session_fork(
|
||||||
let id = next_id("ses_", &SESSION_COUNTER);
|
let id = next_id("ses_", &SESSION_COUNTER);
|
||||||
let slug = format!("session-{}", id);
|
let slug = format!("session-{}", id);
|
||||||
let title = format!("Fork of {}", 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 {
|
let record = OpenCodeSessionRecord {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
slug,
|
slug,
|
||||||
|
|
@ -3552,6 +3567,7 @@ async fn oc_session_fork(
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
|
permission_mode: parent_permission_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
let value = record.to_value();
|
let value = record.to_value();
|
||||||
|
|
@ -3722,12 +3738,20 @@ async fn oc_session_message_create(
|
||||||
})
|
})
|
||||||
.await;
|
.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(
|
if let Err(err) = ensure_backing_session(
|
||||||
&state,
|
&state,
|
||||||
&session_id,
|
&session_id,
|
||||||
&session_agent,
|
&session_agent,
|
||||||
backing_model,
|
backing_model,
|
||||||
backing_variant,
|
backing_variant,
|
||||||
|
session_permission_mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,7 @@ struct SessionState {
|
||||||
events: Vec<UniversalEvent>,
|
events: Vec<UniversalEvent>,
|
||||||
pending_questions: HashMap<String, PendingQuestion>,
|
pending_questions: HashMap<String, PendingQuestion>,
|
||||||
pending_permissions: HashMap<String, PendingPermission>,
|
pending_permissions: HashMap<String, PendingPermission>,
|
||||||
|
always_allow_actions: HashSet<String>,
|
||||||
item_started: HashSet<String>,
|
item_started: HashSet<String>,
|
||||||
item_delta_seen: HashSet<String>,
|
item_delta_seen: HashSet<String>,
|
||||||
item_map: HashMap<String, String>,
|
item_map: HashMap<String, String>,
|
||||||
|
|
@ -475,6 +476,7 @@ impl SessionState {
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
pending_questions: HashMap::new(),
|
pending_questions: HashMap::new(),
|
||||||
pending_permissions: HashMap::new(),
|
pending_permissions: HashMap::new(),
|
||||||
|
always_allow_actions: HashSet::new(),
|
||||||
item_started: HashSet::new(),
|
item_started: HashSet::new(),
|
||||||
item_delta_seen: HashSet::new(),
|
item_delta_seen: HashSet::new(),
|
||||||
item_map: HashMap::new(),
|
item_map: HashMap::new(),
|
||||||
|
|
@ -796,6 +798,18 @@ impl SessionState {
|
||||||
self.pending_permissions.remove(permission_id)
|
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
|
/// Find and remove a pending permission whose action matches a question tool
|
||||||
/// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
|
/// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission).
|
||||||
fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> {
|
fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> {
|
||||||
|
|
@ -2334,7 +2348,7 @@ impl SessionManager {
|
||||||
UniversalEventData::Permission(PermissionEventData {
|
UniversalEventData::Permission(PermissionEventData {
|
||||||
permission_id: perm_id,
|
permission_id: perm_id,
|
||||||
action: perm.action,
|
action: perm.action,
|
||||||
status: PermissionStatus::Approved,
|
status: PermissionStatus::Accept,
|
||||||
metadata: perm.metadata,
|
metadata: perm.metadata,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -2454,7 +2468,7 @@ impl SessionManager {
|
||||||
UniversalEventData::Permission(PermissionEventData {
|
UniversalEventData::Permission(PermissionEventData {
|
||||||
permission_id: perm_id,
|
permission_id: perm_id,
|
||||||
action: perm.action,
|
action: perm.action,
|
||||||
status: PermissionStatus::Denied,
|
status: PermissionStatus::Reject,
|
||||||
metadata: perm.metadata,
|
metadata: perm.metadata,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -2489,6 +2503,12 @@ impl SessionManager {
|
||||||
message: format!("unknown permission id: {permission_id}"),
|
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() {
|
if let Some(err) = session.ended_error() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
@ -2610,8 +2630,9 @@ impl SessionManager {
|
||||||
|
|
||||||
if let Some(pending) = pending_permission {
|
if let Some(pending) = pending_permission {
|
||||||
let status = match reply_for_status {
|
let status = match reply_for_status {
|
||||||
PermissionReply::Reject => PermissionStatus::Denied,
|
PermissionReply::Reject => PermissionStatus::Reject,
|
||||||
PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved,
|
PermissionReply::Once => PermissionStatus::Accept,
|
||||||
|
PermissionReply::Always => PermissionStatus::AcceptForSession,
|
||||||
};
|
};
|
||||||
let resolved = EventConversion::new(
|
let resolved = EventConversion::new(
|
||||||
UniversalEventType::PermissionResolved,
|
UniversalEventType::PermissionResolved,
|
||||||
|
|
@ -2910,13 +2931,127 @@ impl SessionManager {
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
conversions: Vec<EventConversion>,
|
conversions: Vec<EventConversion>,
|
||||||
) -> Result<Vec<UniversalEvent>, SandboxError> {
|
) -> Result<Vec<UniversalEvent>, SandboxError> {
|
||||||
let mut sessions = self.sessions.lock().await;
|
let (events, auto_approvals) = {
|
||||||
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
let mut sessions = self.sessions.lock().await;
|
||||||
SandboxError::SessionNotFound {
|
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
|
||||||
session_id: session_id.to_string(),
|
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,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})?;
|
(events, auto_approvals)
|
||||||
Ok(session.record_conversions(conversions))
|
};
|
||||||
|
|
||||||
|
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> {
|
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>) {
|
fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) {
|
||||||
let mut reader = BufReader::new(reader);
|
let mut reader = BufReader::new(reader);
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,37 @@ describe("OpenCode-compatible Permission API", () => {
|
||||||
throw new Error("Timed out waiting for permission request");
|
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)", () => {
|
describe("permission.reply (global)", () => {
|
||||||
it("should receive permission.asked and reply via global endpoint", async () => {
|
it("should receive permission.asked and reply via global endpoint", async () => {
|
||||||
await client.session.prompt({
|
await client.session.prompt({
|
||||||
|
|
@ -71,6 +102,108 @@ describe("OpenCode-compatible Permission API", () => {
|
||||||
});
|
});
|
||||||
expect(response.error).toBeUndefined();
|
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)", () => {
|
describe("postSessionIdPermissionsPermissionId (session)", () => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,29 @@ describe("OpenCode-compatible Session API", () => {
|
||||||
let handle: SandboxAgentHandle;
|
let handle: SandboxAgentHandle;
|
||||||
let client: OpencodeClient;
|
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 () => {
|
beforeAll(async () => {
|
||||||
// Build the binary if needed
|
// Build the binary if needed
|
||||||
await buildSandboxAgent();
|
await buildSandboxAgent();
|
||||||
|
|
@ -63,6 +86,42 @@ describe("OpenCode-compatible Session API", () => {
|
||||||
|
|
||||||
expect(session1.data?.id).not.toBe(session2.data?.id);
|
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", () => {
|
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
|
status: in_progress
|
||||||
seq: 5
|
seq: 5
|
||||||
type: item.started
|
type: item.started
|
||||||
- item:
|
- delta:
|
||||||
content_types: []
|
delta: "<redacted>"
|
||||||
kind: message
|
item_id: "<redacted>"
|
||||||
role: assistant
|
native_item_id: "<redacted>"
|
||||||
status: completed
|
|
||||||
seq: 6
|
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:
|
second:
|
||||||
- item:
|
- item:
|
||||||
content_types:
|
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>"
|
native_item_id: "<redacted>"
|
||||||
seq: 14
|
seq: 14
|
||||||
type: item.delta
|
type: item.delta
|
||||||
- question:
|
- delta:
|
||||||
id: "<redacted>"
|
delta: "<redacted>"
|
||||||
options: 4
|
item_id: "<redacted>"
|
||||||
status: requested
|
native_item_id: "<redacted>"
|
||||||
seq: 15
|
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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PermissionStatus {
|
pub enum PermissionStatus {
|
||||||
Requested,
|
Requested,
|
||||||
Approved,
|
Accept,
|
||||||
Denied,
|
AcceptForSession,
|
||||||
|
Reject,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
||||||
|
|
|
||||||
|
|
@ -370,8 +370,9 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"requested",
|
"requested",
|
||||||
"approved",
|
"accept",
|
||||||
"denied"
|
"accept_for_session",
|
||||||
|
"reject"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"QuestionEventData": {
|
"QuestionEventData": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue