Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants
# Conflicts: # crates/worker-runtime/src/worker_backend.rs # openapi/server-api.json # web/workspace/src/lib/generated/companion-api.ts # web/workspace/src/lib/generated/repository-api.ts # web/workspace/src/lib/generated/runtime-api.ts # web/workspace/src/lib/generated/ticket-api.ts # web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
Generated
+3
@@ -5266,6 +5266,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
"workdir",
|
"workdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6705,6 +6706,7 @@ dependencies = [
|
|||||||
name = "yoi-e2e"
|
name = "yoi-e2e"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"libc",
|
"libc",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6714,6 +6716,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"webauthn-authenticator-rs",
|
"webauthn-authenticator-rs",
|
||||||
"webauthn-rs-proto",
|
"webauthn-rs-proto",
|
||||||
|
"yoi-workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -4108,6 +4108,17 @@ impl RequestActor {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WhoamiResponse {
|
pub struct WhoamiResponse {
|
||||||
pub actor: Option<RequestActor>,
|
pub actor: Option<RequestActor>,
|
||||||
|
pub session: BrowserSessionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser navigation evidence, not an authorization grant or a generic 401 classification.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum BrowserSessionState {
|
||||||
|
Anonymous {},
|
||||||
|
Authenticated { expires_at: Option<String> },
|
||||||
|
Expired {},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -11620,6 +11631,7 @@ pub fn auth_api_typescript() -> String {
|
|||||||
ActorAuthMethod::decl(&config),
|
ActorAuthMethod::decl(&config),
|
||||||
AuthenticatedUser::decl(&config),
|
AuthenticatedUser::decl(&config),
|
||||||
RequestActor::decl(&config),
|
RequestActor::decl(&config),
|
||||||
|
BrowserSessionState::decl(&config),
|
||||||
WhoamiResponse::decl(&config),
|
WhoamiResponse::decl(&config),
|
||||||
AuthBootstrapUserRequest::decl(&config),
|
AuthBootstrapUserRequest::decl(&config),
|
||||||
AuthUserResponse::decl(&config),
|
AuthUserResponse::decl(&config),
|
||||||
@@ -14050,7 +14062,8 @@ mod tests {
|
|||||||
"handle": "hare",
|
"handle": "hare",
|
||||||
"display_name": "Hare",
|
"display_name": "Hare",
|
||||||
"auth_method": "browser_session"
|
"auth_method": "browser_session"
|
||||||
}
|
},
|
||||||
|
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
|
||||||
});
|
});
|
||||||
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
|
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
|
||||||
.expect("server whoami fixture should match shared DTO");
|
.expect("server whoami fixture should match shared DTO");
|
||||||
@@ -14232,11 +14245,36 @@ mod tests {
|
|||||||
|
|
||||||
let unexpected_field = serde_json::json!({
|
let unexpected_field = serde_json::json!({
|
||||||
"actor": null,
|
"actor": null,
|
||||||
|
"session": { "kind": "anonymous" },
|
||||||
"access_token": "must-not-be-accepted"
|
"access_token": "must-not-be-accepted"
|
||||||
});
|
});
|
||||||
assert!(serde_json::from_value::<WhoamiResponse>(unexpected_field).is_err());
|
assert!(serde_json::from_value::<WhoamiResponse>(unexpected_field).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whoami_session_json_distinguishes_anonymous_expired_and_token_auth() {
|
||||||
|
for session in [
|
||||||
|
serde_json::json!({"kind":"anonymous"}),
|
||||||
|
serde_json::json!({"kind":"expired"}),
|
||||||
|
serde_json::json!({"kind":"authenticated", "expires_at":null}),
|
||||||
|
] {
|
||||||
|
let value = serde_json::json!({"actor":null, "session":session});
|
||||||
|
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||||
|
}
|
||||||
|
for session in [
|
||||||
|
serde_json::json!({"kind":"unknown"}),
|
||||||
|
serde_json::json!({"kind":"expired", "expires_at":null}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<WhoamiResponse>(
|
||||||
|
serde_json::json!({"actor":null, "session":session})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn device_login_debug_output_redacts_secret_material() {
|
fn device_login_debug_output_redacts_secret_material() {
|
||||||
let start = DeviceLoginStartResponse {
|
let start = DeviceLoginStartResponse {
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ use session_store::{
|
|||||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use worker::bootstrap::{
|
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
||||||
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
|
||||||
};
|
|
||||||
use worker::controller::WorkerControllerTransport;
|
use worker::controller::WorkerControllerTransport;
|
||||||
use worker::ipc::protocol_session::{
|
use worker::ipc::protocol_session::{
|
||||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||||
@@ -216,7 +214,6 @@ impl StandaloneHost {
|
|||||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
bootstrap_manifest,
|
bootstrap_manifest,
|
||||||
@@ -224,10 +221,7 @@ impl StandaloneHost {
|
|||||||
launch.prompt_catalog,
|
launch.prompt_catalog,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct { runtime_base },
|
||||||
runtime_base,
|
|
||||||
bash_output_dir,
|
|
||||||
},
|
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(subject) = &subject {
|
if let Some(subject) = &subject {
|
||||||
@@ -428,7 +422,6 @@ impl StandaloneHost {
|
|||||||
);
|
);
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
@@ -436,10 +429,7 @@ impl StandaloneHost {
|
|||||||
worker::PromptCatalogSource::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct { runtime_base },
|
||||||
runtime_base,
|
|
||||||
bash_output_dir,
|
|
||||||
},
|
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(subject) = &subject {
|
if let Some(subject) = &subject {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab
|
|||||||
worker::PromptCatalogSource::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
worker::WorkerWorkspaceContext::no_workspace(),
|
worker::WorkerWorkspaceContext::no_workspace(),
|
||||||
worker::WorkerFilesystemAuthority::None,
|
worker::WorkerFilesystemAuthority::None,
|
||||||
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") },
|
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") },
|
||||||
worker::WorkerControllerTransport::InProcess,
|
worker::WorkerControllerTransport::InProcess,
|
||||||
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap();
|
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap();
|
||||||
assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
|
assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ thiserror = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
workdir = { workspace = true }
|
workdir = { workspace = true }
|
||||||
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
filetime = "0.2.27"
|
filetime = "0.2.27"
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||||
|
|||||||
+273
-212
@@ -1,19 +1,19 @@
|
|||||||
//! Bash Tool using WorkdirSession command capability.
|
//! Bash Tool using WorkdirSession command capability.
|
||||||
//!
|
//!
|
||||||
//! The selected session owns cwd, process launch, bounded output, timeout, and cancellation.
|
//! Workdir owns launch and byte transport; Bash owns bounded preview and retained evidence.
|
||||||
//! Bash is not a sandbox; the Runtime process and OS user define the execution boundary.
|
//! Bash is not a sandbox; the Runtime process and OS user define the execution boundary.
|
||||||
//! Per-execution handles and nonces prevent cancellation of another command. Dropping a future
|
//! Per-execution handles and nonces prevent cancellation of another command. Dropping a future
|
||||||
//! does not confirm termination; active registrations remain until cleanup requests cancellation
|
//! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup;
|
||||||
//! and observes terminal output.
|
//! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter};
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle,
|
CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle,
|
||||||
WorkdirSessionRouter,
|
WorkdirSessionRouter,
|
||||||
@@ -35,9 +35,9 @@ struct BashParams {
|
|||||||
timeout: Option<u64>,
|
timeout: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct BashTool {
|
pub struct BashTool {
|
||||||
router: Arc<WorkdirSessionRouter>,
|
router: Arc<WorkdirSessionRouter>,
|
||||||
output_dir: PathBuf,
|
output: BashOutputView,
|
||||||
state: Arc<Mutex<BashExecutionState>>,
|
state: Arc<Mutex<BashExecutionState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +63,8 @@ struct CommandGuard {
|
|||||||
execution_id: String,
|
execution_id: String,
|
||||||
execution_nonce: u64,
|
execution_nonce: u64,
|
||||||
handle: Option<CommandHandle>,
|
handle: Option<CommandHandle>,
|
||||||
|
cursor: u64,
|
||||||
|
retained: Option<BashOutputWriter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for CommandGuard {
|
impl Drop for CommandGuard {
|
||||||
@@ -74,20 +76,66 @@ impl Drop for CommandGuard {
|
|||||||
let state = Arc::clone(&self.state);
|
let state = Arc::clone(&self.state);
|
||||||
let execution_id = self.execution_id.clone();
|
let execution_id = self.execution_id.clone();
|
||||||
let execution_nonce = self.execution_nonce;
|
let execution_nonce = self.execution_nonce;
|
||||||
|
let mut retained = self.retained.take();
|
||||||
|
let cursor = self.cursor;
|
||||||
// A dropped provider future is not terminal confirmation. Keep the live
|
// A dropped provider future is not terminal confirmation. Keep the live
|
||||||
// execution registered until cleanup has both requested cancellation and
|
// execution registered until cleanup has both requested cancellation and
|
||||||
// observed terminal command output, so cancellation/session teardown
|
// observed terminal command output, so cancellation/session teardown
|
||||||
// cannot race with an apparently empty registry.
|
// cannot race with an apparently empty registry.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = workdir.cancel_command(handle.clone()).await;
|
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||||
let _ = workdir
|
let _ = workdir.cancel_command(handle.clone()).await;
|
||||||
.command_output(CommandOutputRequest {
|
let mut cursor = cursor;
|
||||||
handle,
|
loop {
|
||||||
cursor: 0,
|
let page = command_page(
|
||||||
limit: INLINE_BYTE_BUDGET,
|
&workdir,
|
||||||
wait: true,
|
CommandOutputRequest {
|
||||||
})
|
handle: handle.clone(),
|
||||||
.await;
|
cursor,
|
||||||
|
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(writer) = &mut retained {
|
||||||
|
let mut next = cursor;
|
||||||
|
for chunk in &page.chunks {
|
||||||
|
if chunk.sequence != next {
|
||||||
|
return Err(workdir::WorkdirError::Unavailable(
|
||||||
|
"command cleanup sequence gap".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
writer
|
||||||
|
.append_chunk(chunk)
|
||||||
|
.map_err(workdir::WorkdirError::Unavailable)?;
|
||||||
|
next = next.checked_add(chunk.bytes.len() as u64).ok_or_else(|| {
|
||||||
|
workdir::WorkdirError::Unavailable(
|
||||||
|
"command cleanup cursor overflow".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if page.next_cursor != next {
|
||||||
|
return Err(workdir::WorkdirError::Unavailable(
|
||||||
|
"command cleanup cursor gap".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
if let Some(writer) = &mut retained {
|
||||||
|
writer
|
||||||
|
.finish_transport(&terminal)
|
||||||
|
.map_err(workdir::WorkdirError::Unavailable)?;
|
||||||
|
}
|
||||||
|
return Ok::<(), workdir::WorkdirError>(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Bounded cleanup can end with an unknown provider outcome. The writer then
|
||||||
|
// terminalizes partial evidence on drop; retaining a dead collector in `active`
|
||||||
|
// would leak registrations and falsely suggest it could still collect output.
|
||||||
|
let _ = result;
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
if state
|
if state
|
||||||
.active
|
.active
|
||||||
@@ -101,15 +149,49 @@ impl Drop for CommandGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
// Output pages are replayable only with the exact same command/cursor/limit.
|
||||||
impl Tool for BashTool {
|
// Retry read transport failures, never command start, and cap both attempts and
|
||||||
async fn execute(
|
// backoff. The caller's deadline bounds a disconnected or unresponsive provider.
|
||||||
|
async fn command_page(
|
||||||
|
session: &workdir::WorkdirSessionHandle,
|
||||||
|
request: CommandOutputRequest,
|
||||||
|
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||||
|
for attempt in 0..3 {
|
||||||
|
match session.command_output(request.clone()).await {
|
||||||
|
Err(
|
||||||
|
workdir::WorkdirError::Transport(_)
|
||||||
|
| workdir::WorkdirError::Unavailable(_)
|
||||||
|
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||||
|
) if attempt < 2 => {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100 << attempt)).await;
|
||||||
|
}
|
||||||
|
result => return result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!("bounded retry loop returns its last result")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashTool {
|
||||||
|
pub fn input_schema() -> serde_json::Value {
|
||||||
|
serde_json::to_value(schemars::schema_for!(BashParams)).expect("Bash schema serialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(router: Arc<WorkdirSessionRouter>, output: BashOutputView) -> Self {
|
||||||
|
Self {
|
||||||
|
router,
|
||||||
|
output,
|
||||||
|
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_result(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: agen::tool::ToolExecutionContext,
|
ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<BashResult, ToolError> {
|
||||||
let params: BashParams = serde_json::from_str(input_json)
|
let params: BashParams = serde_json::from_str(input_json)
|
||||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
||||||
|
let retained = self.output.begin().map_err(ToolError::ExecutionFailed)?;
|
||||||
let selected = crate::routing::resolve_session(
|
let selected = crate::routing::resolve_session(
|
||||||
&self.router,
|
&self.router,
|
||||||
params.target_workdir.as_deref(),
|
params.target_workdir.as_deref(),
|
||||||
@@ -134,14 +216,15 @@ impl Tool for BashTool {
|
|||||||
execution_id: execution_id.clone(),
|
execution_id: execution_id.clone(),
|
||||||
execution_nonce,
|
execution_nonce,
|
||||||
handle: None,
|
handle: None,
|
||||||
|
cursor: 0,
|
||||||
|
retained: Some(retained),
|
||||||
};
|
};
|
||||||
let handle = session
|
let handle = session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
transport_byte_budget: self.output.byte_budget(),
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
spill_dir: Some(self.output_dir.clone()),
|
|
||||||
tool_call_id: Some(call_id.clone()),
|
tool_call_id: Some(call_id.clone()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -167,15 +250,101 @@ impl Tool for BashTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(crate::ToolsError::from)?;
|
.map_err(crate::ToolsError::from)?;
|
||||||
}
|
}
|
||||||
let output = session
|
let mut cursor = 0;
|
||||||
.command_output(CommandOutputRequest {
|
let mut stdout_preview = Vec::new();
|
||||||
handle,
|
let mut stderr_preview = Vec::new();
|
||||||
cursor: 0,
|
let mut seen = [0_u64; 2];
|
||||||
limit: INLINE_BYTE_BUDGET,
|
let mut empty_polls = 0;
|
||||||
wait: true,
|
let deadline =
|
||||||
})
|
tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs + 30);
|
||||||
|
let terminal = loop {
|
||||||
|
let page = tokio::time::timeout_at(
|
||||||
|
deadline,
|
||||||
|
command_page(
|
||||||
|
&session,
|
||||||
|
CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
ToolError::ExecutionFailed(
|
||||||
|
"command byte transport deadline exceeded; terminal outcome unknown".into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
.map_err(crate::ToolsError::from)?;
|
.map_err(crate::ToolsError::from)?;
|
||||||
|
let mut next = cursor;
|
||||||
|
for chunk in &page.chunks {
|
||||||
|
if chunk.sequence != next || chunk.bytes.is_empty() {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"command byte transport sequence gap".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
next = next
|
||||||
|
.checked_add(chunk.bytes.len() as u64)
|
||||||
|
.filter(|n| *n <= self.output.byte_budget())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(
|
||||||
|
"command byte transport exceeds delivery budget".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let index = if chunk.stream == workdir::CommandStream::Stdout {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
if chunk.offset != seen[index] {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"command byte transport offset gap".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
seen[index] += chunk.bytes.len() as u64;
|
||||||
|
guard
|
||||||
|
.retained
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.append_chunk(chunk)
|
||||||
|
.map_err(ToolError::ExecutionFailed)?;
|
||||||
|
let available =
|
||||||
|
INLINE_BYTE_BUDGET.saturating_sub(stdout_preview.len() + stderr_preview.len());
|
||||||
|
let preview = if index == 0 {
|
||||||
|
&mut stdout_preview
|
||||||
|
} else {
|
||||||
|
&mut stderr_preview
|
||||||
|
};
|
||||||
|
preview.extend_from_slice(&chunk.bytes[..available.min(chunk.bytes.len())]);
|
||||||
|
}
|
||||||
|
if page.next_cursor != next {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"command byte transport cursor does not fence the page".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if page.chunks.is_empty() && page.terminal.is_none() {
|
||||||
|
empty_polls += 1;
|
||||||
|
if empty_polls > 32 {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"command byte transport made no progress after bounded polls".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
empty_polls = 0;
|
||||||
|
}
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
guard.cursor = cursor;
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
break terminal;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let metadata = guard
|
||||||
|
.retained
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.finish_transport(&terminal)
|
||||||
|
.map_err(ToolError::ExecutionFailed)?;
|
||||||
let cancellation_requested = {
|
let cancellation_requested = {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
let owns_registration = state
|
let owns_registration = state
|
||||||
@@ -193,41 +362,78 @@ impl Tool for BashTool {
|
|||||||
};
|
};
|
||||||
guard.handle = None;
|
guard.handle = None;
|
||||||
|
|
||||||
let timed_out = output.timed_out;
|
let timed_out = terminal.status == workdir::CommandStatus::TimedOut;
|
||||||
let summary = if cancellation_requested {
|
let summary =
|
||||||
format!("$ {cmd_summary} (cancelled)")
|
if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled {
|
||||||
} else if output.timed_out {
|
format!("$ {cmd_summary} (cancelled)")
|
||||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
} else if timed_out {
|
||||||
} else {
|
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||||
match output.exit_code {
|
} else {
|
||||||
Some(0) => format!("$ {cmd_summary}"),
|
match terminal.exit_code {
|
||||||
Some(code) => format!("$ {cmd_summary} (exit {code})"),
|
Some(0) => format!("$ {cmd_summary}"),
|
||||||
None => format!("$ {cmd_summary} (terminated)"),
|
Some(code) => format!("$ {cmd_summary} (exit {code})"),
|
||||||
}
|
None => format!("$ {cmd_summary} (terminated)"),
|
||||||
};
|
}
|
||||||
let content = if output.content.is_empty() {
|
|
||||||
None
|
|
||||||
} else if output.truncated {
|
|
||||||
let notice = match output.output_path {
|
|
||||||
Some(path) => format!(
|
|
||||||
"[showing bounded WorkdirSession command output; full output saved to {}]",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
None => "[showing bounded WorkdirSession command output; additional output was truncated]"
|
|
||||||
.to_owned(),
|
|
||||||
};
|
};
|
||||||
Some(format!("{notice}\n{}", output.content))
|
Ok(BashResult {
|
||||||
} else {
|
|
||||||
Some(output.content)
|
|
||||||
};
|
|
||||||
let output = ToolOutput {
|
|
||||||
summary,
|
summary,
|
||||||
content,
|
stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(),
|
||||||
attachments: Vec::new(),
|
stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(),
|
||||||
};
|
preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes)
|
||||||
if cancellation_requested {
|
> (stdout_preview.len() + stderr_preview.len()) as u64,
|
||||||
|
metadata,
|
||||||
|
cancelled: cancellation_requested
|
||||||
|
|| terminal.status == workdir::CommandStatus::Cancelled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct BashResult {
|
||||||
|
pub summary: String,
|
||||||
|
pub stdout_preview: String,
|
||||||
|
pub stderr_preview: String,
|
||||||
|
pub preview_omitted: bool,
|
||||||
|
pub metadata: BashOutputMetadata,
|
||||||
|
pub cancelled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashResult {
|
||||||
|
pub fn stdout_entry(&self) -> String {
|
||||||
|
format!("/bash-output/{}/stdout", self.metadata.id)
|
||||||
|
}
|
||||||
|
pub fn stderr_entry(&self) -> String {
|
||||||
|
format!("/bash-output/{}/stderr", self.metadata.id)
|
||||||
|
}
|
||||||
|
pub fn order_entry(&self) -> String {
|
||||||
|
format!("/bash-output/{}/order", self.metadata.id)
|
||||||
|
}
|
||||||
|
pub fn tool_output(&self) -> ToolOutput {
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"stdout_preview": self.stdout_preview, "stderr_preview": self.stderr_preview,
|
||||||
|
"preview_omitted": self.preview_omitted, "output": self.metadata,
|
||||||
|
"stdout_entry": self.stdout_entry(), "stderr_entry": self.stderr_entry(), "order_entry": self.order_entry(),
|
||||||
|
});
|
||||||
|
ToolOutput {
|
||||||
|
summary: self.summary.clone(),
|
||||||
|
content: Some(content.to_string()),
|
||||||
|
attachments: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BashTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
ctx: agen::tool::ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let result = self.execute_result(input, ctx).await?;
|
||||||
|
let output = result.tool_output();
|
||||||
|
if result.cancelled {
|
||||||
Err(ToolError::Cancelled(output))
|
Err(ToolError::Cancelled(output))
|
||||||
} else if timed_out {
|
} else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
|
||||||
Err(ToolError::Interrupted(output))
|
Err(ToolError::Interrupted(output))
|
||||||
} else {
|
} else {
|
||||||
Ok(output)
|
Ok(output)
|
||||||
@@ -288,164 +494,19 @@ fn truncate_for_summary(command: &str) -> String {
|
|||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
|
pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition {
|
||||||
routed_bash_tool(crate::routing::singleton_router(session), output_dir)
|
routed_bash_tool(crate::routing::singleton_router(session), output)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn routed_bash_tool(
|
pub(crate) fn routed_bash_tool(
|
||||||
router: Arc<WorkdirSessionRouter>,
|
router: Arc<WorkdirSessionRouter>,
|
||||||
output_dir: PathBuf,
|
output: BashOutputView,
|
||||||
) -> ToolDefinition {
|
) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(BashParams);
|
|
||||||
let meta = ToolMeta::new("Bash")
|
let meta = ToolMeta::new("Bash")
|
||||||
.description("Execute a shell command in one selected Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by that WorkdirSession provider. This is not a sandbox.")
|
.description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
.input_schema(BashTool::input_schema());
|
||||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
let tool: Arc<dyn Tool> = Arc::new(BashTool::new(router.clone(), output.clone()));
|
||||||
router: router.clone(),
|
|
||||||
output_dir: output_dir.clone(),
|
|
||||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
|
||||||
});
|
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
|
||||||
use tempfile::TempDir;
|
|
||||||
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
|
||||||
|
|
||||||
use super::bash_tool;
|
|
||||||
use crate::{grep::grep_tool, read::read_tool, tracker::Tracker};
|
|
||||||
|
|
||||||
fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle {
|
|
||||||
let scope = Scope::from_config(&ScopeConfig {
|
|
||||||
allow: vec![
|
|
||||||
ScopeRule {
|
|
||||||
target: root.path().to_path_buf(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
},
|
|
||||||
ScopeRule {
|
|
||||||
target: output.path().to_path_buf(),
|
|
||||||
permission: Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
deny: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn long_output_is_spilled_and_available_to_read_and_grep() {
|
|
||||||
let root = TempDir::new().unwrap();
|
|
||||||
let output = TempDir::new().unwrap();
|
|
||||||
let session = session_with_output_scope(&root, &output);
|
|
||||||
let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())();
|
|
||||||
let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'";
|
|
||||||
let result = bash
|
|
||||||
.execute(
|
|
||||||
&serde_json::json!({ "command": command, "cwd": "." }).to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let rendered = result.content.expect("bounded Bash output");
|
|
||||||
let artifact = std::fs::read_dir(output.path())
|
|
||||||
.unwrap()
|
|
||||||
.next()
|
|
||||||
.expect("artifact entry")
|
|
||||||
.unwrap()
|
|
||||||
.path();
|
|
||||||
|
|
||||||
assert!(rendered.contains("full output saved to"));
|
|
||||||
assert!(rendered.contains(&artifact.display().to_string()));
|
|
||||||
let retained = std::fs::read_to_string(&artifact).unwrap();
|
|
||||||
assert!(retained.starts_with("line-0000\n"));
|
|
||||||
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
|
||||||
assert_eq!(retained.lines().count(), 2001);
|
|
||||||
|
|
||||||
let (_, read) = read_tool(session.clone(), Tracker::new())();
|
|
||||||
let read_result = read
|
|
||||||
.execute(
|
|
||||||
&serde_json::json!({
|
|
||||||
"file_path": artifact,
|
|
||||||
"offset": 2000,
|
|
||||||
"limit": 1,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
read_result
|
|
||||||
.content
|
|
||||||
.expect("Read content")
|
|
||||||
.contains("FINAL-NEEDLE")
|
|
||||||
);
|
|
||||||
|
|
||||||
let (_, grep) = grep_tool(session)();
|
|
||||||
let grep_result = grep
|
|
||||||
.execute(
|
|
||||||
&serde_json::json!({
|
|
||||||
"pattern": "FINAL-NEEDLE",
|
|
||||||
"path": artifact,
|
|
||||||
"output_mode": "content",
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let grep_content = grep_result.content.expect("Grep content");
|
|
||||||
assert!(
|
|
||||||
grep_content.contains("FINAL-NEEDLE"),
|
|
||||||
"unexpected Grep content: {grep_content:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_cwd_is_rejected_before_command_start() {
|
|
||||||
let root = TempDir::new().unwrap();
|
|
||||||
let output = TempDir::new().unwrap();
|
|
||||||
let session = session_with_output_scope(&root, &output);
|
|
||||||
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
|
|
||||||
|
|
||||||
let error = bash
|
|
||||||
.execute(
|
|
||||||
&serde_json::json!({ "command": "printf hidden" }).to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("missing field `cwd`"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn short_output_does_not_leave_a_spill_artifact() {
|
|
||||||
let root = TempDir::new().unwrap();
|
|
||||||
let output = TempDir::new().unwrap();
|
|
||||||
let session = session_with_output_scope(&root, &output);
|
|
||||||
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
|
|
||||||
|
|
||||||
let result = bash
|
|
||||||
.execute(
|
|
||||||
&serde_json::json!({ "command": "printf short", "cwd": "." }).to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(result.content.as_deref(), Some("short"));
|
|
||||||
assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
//! Bash-owned bounded output, independent of Workdir transport and Console subscribers.
|
||||||
|
//!
|
||||||
|
//! Output is ephemeral evidence, not a filesystem grant. Every lookup checks the live view;
|
||||||
|
//! children can see their own subtree and ancestors can retain evidence after child revocation.
|
||||||
|
//! Root teardown clears all bytes. A new root (including restore) has a new incarnation and
|
||||||
|
//! never reconstructs grants from history. Expiry is measured from admission, enforced on
|
||||||
|
//! lookup and by a weak reaper once commands run in Tokio. Parent and all descendants share
|
||||||
|
//! one pool: admission reserves a whole command budget, evicts the oldest terminal evidence
|
||||||
|
//! first, and refuses rather than evicting a live collector. Revoking a child denies access
|
||||||
|
//! but leaves ancestor evidence until pool eviction/expiry; root teardown clears the pool.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{
|
||||||
|
Arc, Mutex, Weak,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use workdir::{CommandStatus, CommandStream};
|
||||||
|
|
||||||
|
const COMMAND_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
const STORE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
const STORE_COUNT: usize = 64;
|
||||||
|
const ORDER_COUNT: usize = 4096;
|
||||||
|
const RETENTION: Duration = Duration::from_secs(3600);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BashOutputView {
|
||||||
|
store: Arc<Mutex<Store>>,
|
||||||
|
lineage: Vec<String>,
|
||||||
|
active: Arc<AtomicBool>,
|
||||||
|
authority: Arc<dyn Fn() -> bool + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Store {
|
||||||
|
records: VecDeque<Arc<Mutex<Record>>>,
|
||||||
|
reserved: usize,
|
||||||
|
retention: Duration,
|
||||||
|
reaper_started: bool,
|
||||||
|
_shutdown: tokio::sync::watch::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Record {
|
||||||
|
id: String,
|
||||||
|
owner: Vec<String>,
|
||||||
|
created: Instant,
|
||||||
|
stdout: Vec<u8>,
|
||||||
|
stderr: Vec<u8>,
|
||||||
|
order: Vec<OutputRange>,
|
||||||
|
stdout_seen: u64,
|
||||||
|
stderr_seen: u64,
|
||||||
|
truncated: bool,
|
||||||
|
limit: Option<RetentionLimit>,
|
||||||
|
transport_counts: Option<(u64, u64)>,
|
||||||
|
transport_truncation: Option<workdir::CommandOutputTruncation>,
|
||||||
|
terminal: Option<OutputTerminal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct OutputRange {
|
||||||
|
pub stream: CommandStream,
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct OutputTerminal {
|
||||||
|
pub status: Option<CommandStatus>,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
/// False on a forcibly dropped collector or failed transport: received bytes are retained,
|
||||||
|
/// but they are not a claim about the unobserved process tail.
|
||||||
|
pub transport_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RetentionLimit {
|
||||||
|
ByteQuota,
|
||||||
|
OrderQuota,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct BashOutputMetadata {
|
||||||
|
pub id: String,
|
||||||
|
pub stdout_saved: usize,
|
||||||
|
pub stderr_saved: usize,
|
||||||
|
pub stdout_seen: u64,
|
||||||
|
pub stderr_seen: u64,
|
||||||
|
pub stdout_delivered: u64,
|
||||||
|
pub stderr_delivered: u64,
|
||||||
|
pub retention_limit: Option<RetentionLimit>,
|
||||||
|
pub transport_truncation: Option<workdir::CommandOutputTruncation>,
|
||||||
|
pub storage_truncated: bool,
|
||||||
|
pub terminal: OutputTerminal,
|
||||||
|
pub text_encoding: &'static str,
|
||||||
|
pub ordering: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded reservation acquired before command start. Drop terminalizes partial evidence.
|
||||||
|
pub struct BashOutputWriter {
|
||||||
|
record: Arc<Mutex<Record>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BashOutputView {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashOutputView {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (shutdown, _) = tokio::sync::watch::channel(());
|
||||||
|
let store = Arc::new(Mutex::new(Store {
|
||||||
|
records: VecDeque::new(),
|
||||||
|
reserved: 0,
|
||||||
|
retention: RETENTION,
|
||||||
|
reaper_started: false,
|
||||||
|
_shutdown: shutdown,
|
||||||
|
}));
|
||||||
|
let view = Self {
|
||||||
|
store,
|
||||||
|
lineage: vec![uuid::Uuid::now_v7().to_string()],
|
||||||
|
active: Arc::new(AtomicBool::new(true)),
|
||||||
|
authority: Arc::new(|| true),
|
||||||
|
};
|
||||||
|
view.start_reaper();
|
||||||
|
view
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_reaper(&self) {
|
||||||
|
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut store = self.store.lock().unwrap();
|
||||||
|
if store.reaper_started {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let receiver = store._shutdown.subscribe();
|
||||||
|
store.reaper_started = true;
|
||||||
|
let weak = Arc::downgrade(&self.store);
|
||||||
|
runtime.spawn(reap(weak, receiver));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn byte_budget(&self) -> u64 {
|
||||||
|
COMMAND_BYTES as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn child(&self, authority: Arc<dyn Fn() -> bool + Send + Sync>) -> Self {
|
||||||
|
let parent = self.clone();
|
||||||
|
let mut lineage = self.lineage.clone();
|
||||||
|
lineage.push(uuid::Uuid::now_v7().to_string());
|
||||||
|
Self {
|
||||||
|
store: self.store.clone(),
|
||||||
|
lineage,
|
||||||
|
active: Arc::new(AtomicBool::new(true)),
|
||||||
|
authority: Arc::new(move || parent.is_active() && authority()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.active.load(Ordering::Acquire) && (self.authority)()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_root(&self) -> bool {
|
||||||
|
self.lineage.len() == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke(&self) {
|
||||||
|
self.active.store(false, Ordering::Release);
|
||||||
|
if self.lineage.len() == 1 {
|
||||||
|
let mut store = self.store.lock().unwrap();
|
||||||
|
store.records.clear();
|
||||||
|
store.reserved = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin(&self) -> Result<BashOutputWriter, String> {
|
||||||
|
if !self.is_active() {
|
||||||
|
return Err("Bash output authority revoked".into());
|
||||||
|
}
|
||||||
|
// A host may construct the Feature before entering Tokio. Command admission
|
||||||
|
// happens inside the runtime, so idle expiry must start here as well.
|
||||||
|
self.start_reaper();
|
||||||
|
let mut store = self.store.lock().unwrap();
|
||||||
|
if !self.is_active() {
|
||||||
|
return Err("Bash output authority revoked".into());
|
||||||
|
}
|
||||||
|
store.expire(Instant::now());
|
||||||
|
while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES {
|
||||||
|
let Some(index) = store
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.lock().unwrap().terminal.is_some())
|
||||||
|
else {
|
||||||
|
return Err("Bash output capacity is occupied by live commands".into());
|
||||||
|
};
|
||||||
|
store.records.remove(index);
|
||||||
|
store.reserved -= COMMAND_BYTES;
|
||||||
|
}
|
||||||
|
let record = Arc::new(Mutex::new(Record {
|
||||||
|
id: uuid::Uuid::now_v7().to_string(),
|
||||||
|
owner: self.lineage.clone(),
|
||||||
|
created: Instant::now(),
|
||||||
|
stdout: Vec::new(),
|
||||||
|
stderr: Vec::new(),
|
||||||
|
order: Vec::new(),
|
||||||
|
stdout_seen: 0,
|
||||||
|
stderr_seen: 0,
|
||||||
|
truncated: false,
|
||||||
|
limit: None,
|
||||||
|
transport_counts: None,
|
||||||
|
transport_truncation: None,
|
||||||
|
terminal: None,
|
||||||
|
}));
|
||||||
|
store.records.push_back(record.clone());
|
||||||
|
store.reserved += COMMAND_BYTES;
|
||||||
|
Ok(BashOutputWriter { record })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, id: &str) -> Option<Arc<Mutex<Record>>> {
|
||||||
|
if !self.is_active() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut store = self.store.lock().unwrap();
|
||||||
|
if !self.is_active() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
store.expire(Instant::now());
|
||||||
|
store
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.find(|r| {
|
||||||
|
let r = r.lock().unwrap();
|
||||||
|
r.id == id && r.owner.starts_with(&self.lineage) && r.terminal.is_some()
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata(&self, id: &str) -> Option<BashOutputMetadata> {
|
||||||
|
let record = self.record(id)?;
|
||||||
|
let r = record.lock().unwrap();
|
||||||
|
Some(r.metadata())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies at most the caller's bounded range; never publishes output bodies in descriptors.
|
||||||
|
pub fn read(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
stream: CommandStream,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
let record = self.record(id)?;
|
||||||
|
let r = record.lock().unwrap();
|
||||||
|
let bytes = match stream {
|
||||||
|
CommandStream::Stdout => &r.stdout,
|
||||||
|
CommandStream::Stderr => &r.stderr,
|
||||||
|
};
|
||||||
|
let start = offset.min(bytes.len());
|
||||||
|
Some(bytes[start..start.saturating_add(limit.min(64 * 1024)).min(bytes.len())].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the bounded raw source without cloning it, decoding only the requested line window.
|
||||||
|
/// A long single line uses the common byte reader instead of allocating an oversized text page.
|
||||||
|
pub fn line_window(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
stream: CommandStream,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Option<Result<(String, bool), String>> {
|
||||||
|
let record = self.record(id)?;
|
||||||
|
let r = record.lock().unwrap();
|
||||||
|
let bytes = match stream {
|
||||||
|
CommandStream::Stdout => &r.stdout,
|
||||||
|
CommandStream::Stderr => &r.stderr,
|
||||||
|
};
|
||||||
|
let mut lines = bytes.split_inclusive(|b| *b == b'\n').skip(offset);
|
||||||
|
let mut window = Vec::new();
|
||||||
|
for line in lines.by_ref().take(limit.min(2000)) {
|
||||||
|
if window.len() + line.len() > 64 * 1024 {
|
||||||
|
return Some(Err(
|
||||||
|
"text source window exceeds 64 KiB; use the common byte reader".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
window.extend_from_slice(line);
|
||||||
|
}
|
||||||
|
let more = lines.next().is_some();
|
||||||
|
Some(Ok((String::from_utf8_lossy(&window).into_owned(), more)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn order(&self, id: &str) -> Option<Vec<OutputRange>> {
|
||||||
|
let record = self.record(id)?;
|
||||||
|
let r = record.lock().unwrap();
|
||||||
|
Some(r.order.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
fn metadata(&self) -> BashOutputMetadata {
|
||||||
|
BashOutputMetadata {
|
||||||
|
id: self.id.clone(),
|
||||||
|
stdout_saved: self.stdout.len(),
|
||||||
|
stderr_saved: self.stderr.len(),
|
||||||
|
stdout_seen: self.transport_counts.map_or(self.stdout_seen, |c| c.0),
|
||||||
|
stderr_seen: self.transport_counts.map_or(self.stderr_seen, |c| c.1),
|
||||||
|
stdout_delivered: self.stdout_seen,
|
||||||
|
stderr_delivered: self.stderr_seen,
|
||||||
|
retention_limit: self.limit,
|
||||||
|
transport_truncation: self.transport_truncation.clone(),
|
||||||
|
storage_truncated: self.truncated || self.transport_truncation.is_some(),
|
||||||
|
terminal: self.terminal.clone().expect("finalized record"),
|
||||||
|
text_encoding: "utf8_lossy",
|
||||||
|
ordering: "provider_read_observation",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
fn expire(&mut self, now: Instant) {
|
||||||
|
let before = self.records.len();
|
||||||
|
self.records
|
||||||
|
.retain(|r| now.duration_since(r.lock().unwrap().created) < self.retention);
|
||||||
|
self.reserved -= (before - self.records.len()) * COMMAND_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reap(weak: Weak<Mutex<Store>>, mut shutdown: tokio::sync::watch::Receiver<()>) {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
||||||
|
_ = shutdown.changed() => return,
|
||||||
|
}
|
||||||
|
let Some(store) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
store.lock().unwrap().expire(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashOutputWriter {
|
||||||
|
pub fn append_chunk(&mut self, chunk: &workdir::CommandByteChunk) -> Result<(), String> {
|
||||||
|
let expected = {
|
||||||
|
let r = self.record.lock().unwrap();
|
||||||
|
match chunk.stream {
|
||||||
|
CommandStream::Stdout => r.stdout_seen,
|
||||||
|
CommandStream::Stderr => r.stderr_seen,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if chunk.offset != expected || chunk.bytes.is_empty() {
|
||||||
|
return Err("command byte transport stream gap or empty chunk".into());
|
||||||
|
}
|
||||||
|
self.append(chunk.stream, &chunk.bytes);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append(&mut self, stream: CommandStream, bytes: &[u8]) {
|
||||||
|
let mut r = self.record.lock().unwrap();
|
||||||
|
let seen = match stream {
|
||||||
|
CommandStream::Stdout => &mut r.stdout_seen,
|
||||||
|
CommandStream::Stderr => &mut r.stderr_seen,
|
||||||
|
};
|
||||||
|
let start = *seen;
|
||||||
|
*seen = seen.saturating_add(bytes.len() as u64);
|
||||||
|
let available = COMMAND_BYTES.saturating_sub(r.stdout.len() + r.stderr.len());
|
||||||
|
let n = if r.truncated || r.order.len() >= ORDER_COUNT {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
available.min(bytes.len())
|
||||||
|
};
|
||||||
|
if n < bytes.len() {
|
||||||
|
r.truncated = true;
|
||||||
|
if r.limit.is_none() {
|
||||||
|
r.limit = Some(if r.order.len() >= ORDER_COUNT {
|
||||||
|
RetentionLimit::OrderQuota
|
||||||
|
} else {
|
||||||
|
RetentionLimit::ByteQuota
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
match stream {
|
||||||
|
CommandStream::Stdout => r.stdout.extend_from_slice(&bytes[..n]),
|
||||||
|
CommandStream::Stderr => r.stderr.extend_from_slice(&bytes[..n]),
|
||||||
|
}
|
||||||
|
if let Some(last) = r
|
||||||
|
.order
|
||||||
|
.last_mut()
|
||||||
|
.filter(|last| last.stream == stream && last.end == start)
|
||||||
|
{
|
||||||
|
last.end += n as u64;
|
||||||
|
} else {
|
||||||
|
r.order.push(OutputRange {
|
||||||
|
stream,
|
||||||
|
start,
|
||||||
|
end: start + n as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_transport(
|
||||||
|
&mut self,
|
||||||
|
terminal: &workdir::CommandTerminal,
|
||||||
|
) -> Result<BashOutputMetadata, String> {
|
||||||
|
let mut r = self.record.lock().unwrap();
|
||||||
|
if terminal.stdout_delivered_bytes != r.stdout_seen
|
||||||
|
|| terminal.stderr_delivered_bytes != r.stderr_seen
|
||||||
|
|| terminal.stdout_bytes < r.stdout_seen
|
||||||
|
|| terminal.stderr_bytes < r.stderr_seen
|
||||||
|
|| ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen)
|
||||||
|
!= terminal.truncation_reason.is_some())
|
||||||
|
{
|
||||||
|
return Err("command terminal counts do not fence delivered bytes".into());
|
||||||
|
}
|
||||||
|
r.transport_counts = Some((terminal.stdout_bytes, terminal.stderr_bytes));
|
||||||
|
r.transport_truncation = terminal.truncation_reason.clone();
|
||||||
|
r.terminal = Some(OutputTerminal {
|
||||||
|
status: Some(terminal.status),
|
||||||
|
exit_code: terminal.exit_code,
|
||||||
|
transport_complete: true,
|
||||||
|
});
|
||||||
|
Ok(r.metadata())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(&mut self, terminal: OutputTerminal) -> BashOutputMetadata {
|
||||||
|
let mut r = self.record.lock().unwrap();
|
||||||
|
r.terminal = Some(terminal);
|
||||||
|
r.metadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BashOutputWriter {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut r = self.record.lock().unwrap();
|
||||||
|
if r.terminal.is_none() {
|
||||||
|
r.terminal = Some(OutputTerminal {
|
||||||
|
status: None,
|
||||||
|
exit_code: None,
|
||||||
|
transport_complete: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn finish(view: &BashOutputView, text: &[u8]) -> String {
|
||||||
|
let mut writer = view.begin().unwrap();
|
||||||
|
writer.append(CommandStream::Stdout, text);
|
||||||
|
writer
|
||||||
|
.finish(OutputTerminal {
|
||||||
|
status: Some(CommandStatus::Completed),
|
||||||
|
exit_code: Some(0),
|
||||||
|
transport_complete: true,
|
||||||
|
})
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_child_is_denied_while_parent_keeps_evidence_and_sibling_is_denied() {
|
||||||
|
let root = BashOutputView::new();
|
||||||
|
let lease = Arc::new(AtomicBool::new(true));
|
||||||
|
let child = root.child({
|
||||||
|
let lease = lease.clone();
|
||||||
|
Arc::new(move || lease.load(Ordering::Acquire))
|
||||||
|
});
|
||||||
|
let sibling = root.child(Arc::new(|| true));
|
||||||
|
let id = finish(&child, b"child");
|
||||||
|
assert_eq!(
|
||||||
|
child.read(&id, CommandStream::Stdout, 0, 20).unwrap(),
|
||||||
|
b"child"
|
||||||
|
);
|
||||||
|
assert!(sibling.metadata(&id).is_none());
|
||||||
|
assert!(BashOutputView::new().metadata(&id).is_none());
|
||||||
|
lease.store(false, Ordering::Release);
|
||||||
|
assert!(child.metadata(&id).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
root.read(&id, CommandStream::Stdout, 0, 20).unwrap(),
|
||||||
|
b"child"
|
||||||
|
);
|
||||||
|
root.revoke();
|
||||||
|
let restored = BashOutputView::new();
|
||||||
|
assert!(root.metadata(&id).is_none());
|
||||||
|
assert!(restored.metadata(&id).is_none());
|
||||||
|
let new_id = finish(&restored, b"new incarnation");
|
||||||
|
assert_ne!(new_id, id);
|
||||||
|
assert!(child.metadata(&new_id).is_none());
|
||||||
|
assert!(root.begin().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_and_descendants_share_admission_without_evicting_live_evidence() {
|
||||||
|
let root = BashOutputView::new();
|
||||||
|
let child = root.child(Arc::new(|| true));
|
||||||
|
let sibling = root.child(Arc::new(|| true));
|
||||||
|
let mut writers = (0..STORE_BYTES / COMMAND_BYTES)
|
||||||
|
.map(|index| {
|
||||||
|
if index % 2 == 0 {
|
||||||
|
root.begin()
|
||||||
|
} else {
|
||||||
|
child.begin()
|
||||||
|
}
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(sibling.begin().is_err());
|
||||||
|
let mut first = writers.remove(0);
|
||||||
|
first.append(CommandStream::Stdout, b"finished");
|
||||||
|
let metadata = first.finish(OutputTerminal {
|
||||||
|
status: Some(CommandStatus::Completed),
|
||||||
|
exit_code: Some(0),
|
||||||
|
transport_complete: true,
|
||||||
|
});
|
||||||
|
assert!(root.metadata(&metadata.id).is_some());
|
||||||
|
let _replacement = sibling.begin().unwrap();
|
||||||
|
assert!(root.metadata(&metadata.id).is_none());
|
||||||
|
assert!(root.begin().is_err());
|
||||||
|
assert_eq!(writers.len(), STORE_BYTES / COMMAND_BYTES - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quota_and_forced_drop_are_distinct_from_preview_omission() {
|
||||||
|
let root = BashOutputView::new();
|
||||||
|
let mut writer = root.begin().unwrap();
|
||||||
|
writer.append(CommandStream::Stdout, &vec![b'x'; COMMAND_BYTES + 1]);
|
||||||
|
writer.append(CommandStream::Stderr, b"tail");
|
||||||
|
let id = writer.record.lock().unwrap().id.clone();
|
||||||
|
drop(writer);
|
||||||
|
let m = root.metadata(&id).unwrap();
|
||||||
|
assert_eq!(m.stdout_saved, COMMAND_BYTES);
|
||||||
|
assert_eq!(m.stdout_seen, COMMAND_BYTES as u64 + 1);
|
||||||
|
assert_eq!(m.stderr_seen, 4);
|
||||||
|
assert!(m.storage_truncated);
|
||||||
|
assert!(!m.terminal.transport_complete);
|
||||||
|
assert_eq!(
|
||||||
|
root.read(&id, CommandStream::Stdout, 0, usize::MAX)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
64 * 1024
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expiry_and_count_eviction_never_reuse_old_references() {
|
||||||
|
let root = BashOutputView::new();
|
||||||
|
let id = finish(&root, b"old");
|
||||||
|
root.store.lock().unwrap().retention = Duration::ZERO;
|
||||||
|
assert!(root.metadata(&id).is_none());
|
||||||
|
root.store.lock().unwrap().retention = RETENTION;
|
||||||
|
let first = finish(&root, b"first");
|
||||||
|
for _ in 0..STORE_COUNT {
|
||||||
|
finish(&root, b"later");
|
||||||
|
}
|
||||||
|
assert!(root.metadata(&first).is_none());
|
||||||
|
assert!(root.store.lock().unwrap().reserved <= STORE_BYTES);
|
||||||
|
}
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn expiry_reclaims_bytes_without_another_read_and_reaper_ends_with_owner() {
|
||||||
|
// Exercise host construction outside a runtime, followed by real async admission.
|
||||||
|
let root = std::thread::spawn(BashOutputView::new).join().unwrap();
|
||||||
|
finish(&root, b"expires without lookup");
|
||||||
|
let mut closed = root.store.lock().unwrap()._shutdown.subscribe();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
// The store uses monotonic wall time for publication and Tokio time only for scheduling.
|
||||||
|
// Age the record deterministically rather than delaying this test in real time.
|
||||||
|
root.store.lock().unwrap().records[0]
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.created = Instant::now() - RETENTION;
|
||||||
|
tokio::time::advance(Duration::from_secs(31)).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert_eq!(root.store.lock().unwrap().reserved, 0);
|
||||||
|
drop(root);
|
||||||
|
assert!(closed.changed().await.is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn terminal_counts_must_fence_the_received_prefix_before_marking_evidence_complete() {
|
||||||
|
let root = BashOutputView::new();
|
||||||
|
let mut writer = root.begin().unwrap();
|
||||||
|
writer.append(CommandStream::Stdout, b"abc");
|
||||||
|
let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"});
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.finish_transport(&serde_json::from_value(terminal.clone()).unwrap())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
terminal["stdout_delivered_bytes"] = serde_json::json!(3);
|
||||||
|
let metadata = writer
|
||||||
|
.finish_transport(&serde_json::from_value(terminal).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(metadata.stdout_saved, 3);
|
||||||
|
assert_eq!(metadata.stdout_delivered, 3);
|
||||||
|
assert_eq!(metadata.stdout_seen, 9);
|
||||||
|
assert!(metadata.storage_truncated);
|
||||||
|
assert!(metadata.retention_limit.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
metadata.transport_truncation,
|
||||||
|
Some(workdir::CommandOutputTruncation::ByteBudget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-9
@@ -5,6 +5,7 @@ pub mod error;
|
|||||||
pub mod tracker;
|
pub mod tracker;
|
||||||
|
|
||||||
mod bash;
|
mod bash;
|
||||||
|
pub mod bash_output;
|
||||||
mod edit;
|
mod edit;
|
||||||
mod file_target;
|
mod file_target;
|
||||||
mod glob;
|
mod glob;
|
||||||
@@ -16,7 +17,7 @@ pub mod view_image;
|
|||||||
mod web;
|
mod web;
|
||||||
mod write;
|
mod write;
|
||||||
|
|
||||||
pub use bash::bash_tool;
|
pub use bash::{BashResult, BashTool, bash_tool};
|
||||||
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
||||||
pub use edit::edit_tool;
|
pub use edit::edit_tool;
|
||||||
pub use error::ToolsError;
|
pub use error::ToolsError;
|
||||||
@@ -35,7 +36,7 @@ pub use write::write_tool;
|
|||||||
pub fn core_builtin_tools(
|
pub fn core_builtin_tools(
|
||||||
session: workdir::WorkdirSessionHandle,
|
session: workdir::WorkdirSessionHandle,
|
||||||
tracker: Tracker,
|
tracker: Tracker,
|
||||||
bash_output_dir: std::path::PathBuf,
|
bash_output: bash_output::BashOutputView,
|
||||||
) -> Vec<agen::tool::ToolDefinition> {
|
) -> Vec<agen::tool::ToolDefinition> {
|
||||||
use workdir::WorkdirSessionCapability;
|
use workdir::WorkdirSessionCapability;
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ pub fn core_builtin_tools(
|
|||||||
tools.push(grep::routed_grep_tool(router.clone()));
|
tools.push(grep::routed_grep_tool(router.clone()));
|
||||||
}
|
}
|
||||||
if capabilities.supports(WorkdirSessionCapability::Command) {
|
if capabilities.supports(WorkdirSessionCapability::Command) {
|
||||||
tools.push(bash::routed_bash_tool(router, bash_output_dir));
|
tools.push(bash::routed_bash_tool(router, bash_output));
|
||||||
}
|
}
|
||||||
tools
|
tools
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ pub fn core_builtin_tools(
|
|||||||
pub fn routed_builtin_tools(
|
pub fn routed_builtin_tools(
|
||||||
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
||||||
tracker: Tracker,
|
tracker: Tracker,
|
||||||
bash_output_dir: std::path::PathBuf,
|
bash_output: bash_output::BashOutputView,
|
||||||
) -> Vec<agen::tool::ToolDefinition> {
|
) -> Vec<agen::tool::ToolDefinition> {
|
||||||
vec![
|
vec![
|
||||||
read::routed_read_tool(router.clone(), tracker.clone()),
|
read::routed_read_tool(router.clone(), tracker.clone()),
|
||||||
@@ -79,14 +80,14 @@ pub fn routed_builtin_tools(
|
|||||||
edit::routed_edit_tool(router.clone(), tracker),
|
edit::routed_edit_tool(router.clone(), tracker),
|
||||||
glob::routed_glob_tool(router.clone()),
|
glob::routed_glob_tool(router.clone()),
|
||||||
grep::routed_grep_tool(router.clone()),
|
grep::routed_grep_tool(router.clone()),
|
||||||
bash::routed_bash_tool(router, bash_output_dir),
|
bash::routed_bash_tool(router, bash_output),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routed_builtin_tools_for_current_capabilities(
|
pub fn routed_builtin_tools_for_current_capabilities(
|
||||||
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
||||||
tracker: Tracker,
|
tracker: Tracker,
|
||||||
bash_output_dir: std::path::PathBuf,
|
bash_output: bash_output::BashOutputView,
|
||||||
) -> Vec<agen::tool::ToolDefinition> {
|
) -> Vec<agen::tool::ToolDefinition> {
|
||||||
use workdir::WorkdirSessionCapability;
|
use workdir::WorkdirSessionCapability;
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities(
|
|||||||
tools.push(grep::routed_grep_tool(router.clone()));
|
tools.push(grep::routed_grep_tool(router.clone()));
|
||||||
}
|
}
|
||||||
if capabilities.supports(WorkdirSessionCapability::Command) {
|
if capabilities.supports(WorkdirSessionCapability::Command) {
|
||||||
tools.push(bash::routed_bash_tool(router, bash_output_dir));
|
tools.push(bash::routed_bash_tool(router, bash_output));
|
||||||
}
|
}
|
||||||
tools
|
tools
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,7 @@ pub fn read_only_builtin_tools(
|
|||||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
"read-only tool projection requires a read-only Workdir session"
|
"read-only tool projection requires a read-only Workdir session"
|
||||||
);
|
);
|
||||||
core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new())
|
core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_only_routed_builtin_tools(
|
pub fn read_only_routed_builtin_tools(
|
||||||
@@ -173,7 +174,7 @@ mod workdir_tool_tests {
|
|||||||
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||||
WorkdirSessionCapabilities::READ_ONLY,
|
WorkdirSessionCapabilities::READ_ONLY,
|
||||||
));
|
));
|
||||||
let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output"))
|
let names = core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|definition| definition().0.name)
|
.map(|definition| definition().0.name)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition};
|
use agen::tool::{Tool, ToolDefinition};
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
use manifest::Scope;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tools::{Tracker, core_builtin_tools};
|
use tools::{Tracker, core_builtin_tools};
|
||||||
@@ -28,31 +28,23 @@ impl Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (TempDir, TempDir, Registry) {
|
fn setup() -> (TempDir, Registry) {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let base = Scope::writable(dir.path()).unwrap();
|
|
||||||
let mut config = ScopeConfig {
|
|
||||||
allow: base.allow_rules(),
|
|
||||||
deny: base.deny_rules(),
|
|
||||||
};
|
|
||||||
config.allow.push(ScopeRule {
|
|
||||||
target: spill.path().to_path_buf(),
|
|
||||||
permission: Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
});
|
|
||||||
let scope = Scope::from_config(&config).unwrap();
|
|
||||||
let fs: WorkdirSessionHandle =
|
let fs: WorkdirSessionHandle =
|
||||||
std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let tracker = Tracker::new();
|
let tracker = Tracker::new();
|
||||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
let reg = Registry::new(core_builtin_tools(
|
||||||
(dir, spill, reg)
|
fs,
|
||||||
|
tracker,
|
||||||
|
tools::bash_output::BashOutputView::new(),
|
||||||
|
));
|
||||||
|
(dir, reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unicode_path_and_content() {
|
async fn unicode_path_and_content() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("日本語ファイル.txt");
|
let file = dir.path().join("日本語ファイル.txt");
|
||||||
let content = "こんにちは 🦀 世界\nabc\n";
|
let content = "こんにちは 🦀 世界\nabc\n";
|
||||||
|
|
||||||
@@ -87,7 +79,7 @@ async fn unicode_path_and_content() {
|
|||||||
async fn symlink_to_outside_scope_is_rejected_for_write() {
|
async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let outside = TempDir::new().unwrap();
|
let outside = TempDir::new().unwrap();
|
||||||
let outside_target = outside.path().join("secret.txt");
|
let outside_target = outside.path().join("secret.txt");
|
||||||
std::fs::write(&outside_target, "secret").unwrap();
|
std::fs::write(&outside_target, "secret").unwrap();
|
||||||
@@ -151,7 +143,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
|||||||
async fn broken_symlink_reports_target_and_repair_hint() {
|
async fn broken_symlink_reports_target_and_repair_hint() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let link = dir.path().join("external-project");
|
let link = dir.path().join("external-project");
|
||||||
let target = dir.path().join("missing-target");
|
let target = dir.path().join("missing-target");
|
||||||
symlink(&target, &link).unwrap();
|
symlink(&target, &link).unwrap();
|
||||||
@@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_file_read_and_edit() {
|
async fn empty_file_read_and_edit() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("empty.txt");
|
let file = dir.path().join("empty.txt");
|
||||||
std::fs::write(&file, "").unwrap();
|
std::fs::write(&file, "").unwrap();
|
||||||
|
|
||||||
@@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn very_long_single_line() {
|
async fn very_long_single_line() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("long.txt");
|
let file = dir.path().join("long.txt");
|
||||||
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
|
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
|
||||||
std::fs::write(&file, &big).unwrap();
|
std::fs::write(&file, &big).unwrap();
|
||||||
@@ -226,7 +218,7 @@ async fn very_long_single_line() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn absolute_path_requires_matching_read_scope() {
|
async fn absolute_path_requires_matching_read_scope() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
let outside = tempfile::tempdir().unwrap();
|
let outside = tempfile::tempdir().unwrap();
|
||||||
let outside_file = outside.path().join("outside.txt");
|
let outside_file = outside.path().join("outside.txt");
|
||||||
std::fs::write(&outside_file, "secret").unwrap();
|
std::fs::write(&outside_file, "secret").unwrap();
|
||||||
@@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn directory_target_is_rejected_for_read() {
|
async fn directory_target_is_rejected_for_read() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
let read = reg.get("Read");
|
let read = reg.get("Read");
|
||||||
let err = read
|
let err = read
|
||||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||||
@@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deeply_nested_new_file_is_created() {
|
async fn deeply_nested_new_file_is_created() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let deep = dir.path().join("a/b/c/d/e/deep.txt");
|
let deep = dir.path().join("a/b/c/d/e/deep.txt");
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
write
|
write
|
||||||
@@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn replace_preserves_unicode() {
|
async fn replace_preserves_unicode() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("u.txt");
|
let file = dir.path().join("u.txt");
|
||||||
std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
|
std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
|
||||||
|
|
||||||
@@ -306,7 +298,7 @@ async fn replace_preserves_unicode() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grep_handles_unicode_pattern() {
|
async fn grep_handles_unicode_pattern() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("u.txt");
|
let file = dir.path().join("u.txt");
|
||||||
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
|
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
|
||||||
|
|
||||||
|
|||||||
+349
-153
@@ -11,7 +11,7 @@ use agen::tool::{
|
|||||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
||||||
ToolExecutionTerminal, ToolMeta,
|
ToolExecutionTerminal, ToolMeta,
|
||||||
};
|
};
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
use manifest::Scope;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tools::{
|
use tools::{
|
||||||
@@ -23,21 +23,6 @@ use workdir::{
|
|||||||
WorkdirToolScopeRule,
|
WorkdirToolScopeRule,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
|
||||||
let base = Scope::writable(workspace).unwrap();
|
|
||||||
let mut config = ScopeConfig {
|
|
||||||
allow: base.allow_rules(),
|
|
||||||
deny: base.deny_rules(),
|
|
||||||
};
|
|
||||||
config.allow.push(ScopeRule {
|
|
||||||
target: spill.to_path_buf(),
|
|
||||||
permission: Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
});
|
|
||||||
Scope::from_config(&config).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Registry {
|
struct Registry {
|
||||||
entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
|
entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
|
||||||
}
|
}
|
||||||
@@ -61,39 +46,38 @@ impl Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup() -> (TempDir, TempDir, Registry) {
|
fn setup() -> (TempDir, Registry) {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let fs: WorkdirSessionHandle =
|
let fs: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let tracker = Tracker::new();
|
let tracker = Tracker::new();
|
||||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
let reg = Registry::new(core_builtin_tools(
|
||||||
(dir, spill, reg)
|
fs,
|
||||||
|
tracker,
|
||||||
|
tools::bash_output::BashOutputView::new(),
|
||||||
|
));
|
||||||
|
(dir, reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_routed() -> (
|
fn setup_routed() -> (TempDir, TempDir, Arc<WorkdirSessionRouter>, Registry) {
|
||||||
TempDir,
|
|
||||||
TempDir,
|
|
||||||
TempDir,
|
|
||||||
Arc<WorkdirSessionRouter>,
|
|
||||||
Registry,
|
|
||||||
) {
|
|
||||||
let left = TempDir::new().unwrap();
|
let left = TempDir::new().unwrap();
|
||||||
let right = TempDir::new().unwrap();
|
let right = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
for (alias, dir) in [("left", &left), ("right", &right)] {
|
for (alias, dir) in [("left", &left), ("right", &right)] {
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let session: WorkdirSessionHandle =
|
let session: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
router
|
router
|
||||||
.attach(WorkdirAttachmentAlias::new(alias).unwrap(), session)
|
.attach(WorkdirAttachmentAlias::new(alias).unwrap(), session)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let definitions =
|
let definitions = routed_builtin_tools(
|
||||||
routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf());
|
router.clone(),
|
||||||
(left, right, spill, router, Registry::new(definitions))
|
Tracker::new(),
|
||||||
|
tools::bash_output::BashOutputView::new(),
|
||||||
|
);
|
||||||
|
(left, right, router, Registry::new(definitions))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
||||||
@@ -137,7 +121,7 @@ async fn call_err(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn core_builtin_tools_registers_full_set() {
|
fn core_builtin_tools_registers_full_set() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
let mut names = reg.names();
|
let mut names = reg.names();
|
||||||
names.sort();
|
names.sort();
|
||||||
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
||||||
@@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_has_description_and_schema() {
|
fn meta_has_description_and_schema() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
for (meta, _) in ®.entries {
|
for (meta, _) in ®.entries {
|
||||||
assert!(
|
assert!(
|
||||||
!meta.description.is_empty(),
|
!meta.description.is_empty(),
|
||||||
@@ -163,7 +147,7 @@ fn meta_has_description_and_schema() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn routed_tool_schemas_share_optional_target_workdir() {
|
fn routed_tool_schemas_share_optional_target_workdir() {
|
||||||
let (_left, _right, _spill, router, mut reg) = setup_routed();
|
let (_left, _right, router, mut reg) = setup_routed();
|
||||||
reg.entries.push(routed_view_image_tool(router)());
|
reg.entries.push(routed_view_image_tool(router)());
|
||||||
for (meta, _) in ®.entries {
|
for (meta, _) in ®.entries {
|
||||||
let target = &meta.input_schema["properties"]["target_workdir"];
|
let target = &meta.input_schema["properties"]["target_workdir"];
|
||||||
@@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
|
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
|
||||||
let (_left, _right, _spill, router, reg) = setup_routed();
|
let (_left, _right, router, reg) = setup_routed();
|
||||||
let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await;
|
let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await;
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
assert!(message.contains("target_workdir_required"), "{message}");
|
assert!(message.contains("target_workdir_required"), "{message}");
|
||||||
@@ -199,7 +183,7 @@ async fn empty_routed_surface_returns_no_attachment_error() {
|
|||||||
let reg = Registry::new(routed_builtin_tools(
|
let reg = Registry::new(routed_builtin_tools(
|
||||||
router,
|
router,
|
||||||
Tracker::new(),
|
Tracker::new(),
|
||||||
Path::new("unused").to_path_buf(),
|
tools::bash_output::BashOutputView::new(),
|
||||||
));
|
));
|
||||||
let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" }))
|
let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" }))
|
||||||
.await
|
.await
|
||||||
@@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
||||||
let (left, right, _spill, router, mut reg) = setup_routed();
|
let (left, right, router, mut reg) = setup_routed();
|
||||||
std::fs::write(left.path().join("same.txt"), "left value\n").unwrap();
|
std::fs::write(left.path().join("same.txt"), "left value\n").unwrap();
|
||||||
std::fs::write(right.path().join("same.txt"), "right value\n").unwrap();
|
std::fs::write(right.path().join("same.txt"), "right value\n").unwrap();
|
||||||
std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap();
|
std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap();
|
||||||
@@ -282,7 +266,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(),
|
std::fs::canonicalize(serde_json::from_str::<serde_json::Value>(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(),
|
||||||
std::fs::canonicalize(right.path()).unwrap()
|
std::fs::canonicalize(right.path()).unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -304,13 +288,12 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
|||||||
async fn selected_attachment_capability_is_checked_without_fallback() {
|
async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||||
let left = TempDir::new().unwrap();
|
let left = TempDir::new().unwrap();
|
||||||
let right = TempDir::new().unwrap();
|
let right = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
router
|
router
|
||||||
.attach(
|
.attach(
|
||||||
WorkdirAttachmentAlias::new("writable").unwrap(),
|
WorkdirAttachmentAlias::new("writable").unwrap(),
|
||||||
Arc::new(LocalWorkdirSession::new(
|
Arc::new(LocalWorkdirSession::new(
|
||||||
scope_with_spill(left.path(), spill.path()),
|
Scope::writable(left.path()).unwrap(),
|
||||||
left.path().to_path_buf(),
|
left.path().to_path_buf(),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
|||||||
workdir::Workdir::new("readonly"),
|
workdir::Workdir::new("readonly"),
|
||||||
right.path().to_path_buf(),
|
right.path().to_path_buf(),
|
||||||
right.path().to_path_buf(),
|
right.path().to_path_buf(),
|
||||||
manifest::SharedScope::new(scope_with_spill(right.path(), spill.path())),
|
manifest::SharedScope::new(Scope::writable(right.path()).unwrap()),
|
||||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
|||||||
let reg = Registry::new(routed_builtin_tools(
|
let reg = Registry::new(routed_builtin_tools(
|
||||||
router.clone(),
|
router.clone(),
|
||||||
Tracker::new(),
|
Tracker::new(),
|
||||||
spill.path().to_path_buf(),
|
tools::bash_output::BashOutputView::new(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let message = call_err(
|
let message = call_err(
|
||||||
@@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn routed_tools_observe_detach_after_registration() {
|
async fn routed_tools_observe_detach_after_registration() {
|
||||||
let (_left, _right, _spill, router, reg) = setup_routed();
|
let (_left, _right, router, reg) = setup_routed();
|
||||||
let alias = WorkdirAttachmentAlias::new("left").unwrap();
|
let alias = WorkdirAttachmentAlias::new("left").unwrap();
|
||||||
router.detach(&alias).await.unwrap();
|
router.detach(&alias).await.unwrap();
|
||||||
|
|
||||||
@@ -372,7 +355,6 @@ async fn routed_tools_observe_detach_after_registration() {
|
|||||||
async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||||
let first = TempDir::new().unwrap();
|
let first = TempDir::new().unwrap();
|
||||||
let second = TempDir::new().unwrap();
|
let second = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
|
||||||
std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap();
|
std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap();
|
||||||
std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap();
|
std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap();
|
||||||
|
|
||||||
@@ -382,7 +364,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
|||||||
.attach(
|
.attach(
|
||||||
alias.clone(),
|
alias.clone(),
|
||||||
Arc::new(LocalWorkdirSession::new(
|
Arc::new(LocalWorkdirSession::new(
|
||||||
scope_with_spill(first.path(), spill.path()),
|
Scope::writable(first.path()).unwrap(),
|
||||||
first.path().to_path_buf(),
|
first.path().to_path_buf(),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -390,7 +372,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
|||||||
let reg = Registry::new(routed_builtin_tools(
|
let reg = Registry::new(routed_builtin_tools(
|
||||||
router.clone(),
|
router.clone(),
|
||||||
Tracker::new(),
|
Tracker::new(),
|
||||||
spill.path().to_path_buf(),
|
tools::bash_output::BashOutputView::new(),
|
||||||
));
|
));
|
||||||
call(
|
call(
|
||||||
®.get("Read"),
|
®.get("Read"),
|
||||||
@@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
|||||||
.attach(
|
.attach(
|
||||||
alias.clone(),
|
alias.clone(),
|
||||||
Arc::new(LocalWorkdirSession::new(
|
Arc::new(LocalWorkdirSession::new(
|
||||||
scope_with_spill(second.path(), spill.path()),
|
Scope::writable(second.path()).unwrap(),
|
||||||
second.path().to_path_buf(),
|
second.path().to_path_buf(),
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
@@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
|||||||
struct ExternalValidatingSession {
|
struct ExternalValidatingSession {
|
||||||
inner: LocalWorkdirSession,
|
inner: LocalWorkdirSession,
|
||||||
truncate_first_binary_response: bool,
|
truncate_first_binary_response: bool,
|
||||||
|
command_response_losses: std::sync::atomic::AtomicUsize,
|
||||||
|
lose_terminal_only: bool,
|
||||||
mutate_after_first_binary_response: Option<std::path::PathBuf>,
|
mutate_after_first_binary_response: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
|
|||||||
&self,
|
&self,
|
||||||
request: workdir::CommandOutputRequest,
|
request: workdir::CommandOutputRequest,
|
||||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||||
self.inner.command_output(request).await
|
let output = self.inner.command_output(request).await?;
|
||||||
|
if (!self.lose_terminal_only || output.terminal.is_some())
|
||||||
|
&& self
|
||||||
|
.command_response_losses
|
||||||
|
.fetch_update(
|
||||||
|
std::sync::atomic::Ordering::AcqRel,
|
||||||
|
std::sync::atomic::Ordering::Acquire,
|
||||||
|
|remaining| remaining.checked_sub(1),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
// The production provider has committed a page, but its response is lost.
|
||||||
|
return Err(workdir::WorkdirError::Transport(
|
||||||
|
"injected lost response".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_command(
|
async fn cancel_command(
|
||||||
@@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_view_image_uses_a_protocol_bounded_request() {
|
async fn external_view_image_uses_a_protocol_bounded_request() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||||
truncate_first_binary_response: false,
|
truncate_first_binary_response: false,
|
||||||
|
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
lose_terminal_only: false,
|
||||||
mutate_after_first_binary_response: None,
|
mutate_after_first_binary_response: None,
|
||||||
});
|
});
|
||||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||||
@@ -590,13 +591,14 @@ async fn external_view_image_uses_a_protocol_bounded_request() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
|
||||||
let image_path = dir.path().join("image.png");
|
let image_path = dir.path().join("image.png");
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
|
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
|
||||||
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||||
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
|
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
|
||||||
truncate_first_binary_response: true,
|
truncate_first_binary_response: true,
|
||||||
|
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
lose_terminal_only: false,
|
||||||
mutate_after_first_binary_response: None,
|
mutate_after_first_binary_response: None,
|
||||||
});
|
});
|
||||||
let (_meta, tool) = view_image_tool(truncated)();
|
let (_meta, tool) = view_image_tool(truncated)();
|
||||||
@@ -613,6 +615,8 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
|||||||
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||||
truncate_first_binary_response: false,
|
truncate_first_binary_response: false,
|
||||||
|
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||||
|
lose_terminal_only: false,
|
||||||
mutate_after_first_binary_response: Some(image_path),
|
mutate_after_first_binary_response: Some(image_path),
|
||||||
});
|
});
|
||||||
let (_meta, tool) = view_image_tool(changing)();
|
let (_meta, tool) = view_image_tool(changing)();
|
||||||
@@ -626,8 +630,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
|||||||
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
|
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
|
||||||
const LIMIT: usize = 10 * 1024 * 1024;
|
const LIMIT: usize = 10 * 1024 * 1024;
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let session: WorkdirSessionHandle =
|
let session: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let (_meta, tool) = view_image_tool(session)();
|
let (_meta, tool) = view_image_tool(session)();
|
||||||
@@ -650,8 +653,7 @@ async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments()
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn view_image_attaches_valid_supported_image_fixtures() {
|
async fn view_image_attaches_valid_supported_image_fixtures() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let session: WorkdirSessionHandle =
|
let session: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let (_meta, tool) = view_image_tool(session)();
|
let (_meta, tool) = view_image_tool(session)();
|
||||||
@@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let session: WorkdirSessionHandle =
|
let session: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||||
@@ -752,7 +753,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_then_edit_then_read_roundtrip() {
|
async fn read_then_edit_then_read_roundtrip() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("a.txt");
|
let file = dir.path().join("a.txt");
|
||||||
std::fs::write(&file, "hello world\n").unwrap();
|
std::fs::write(&file, "hello world\n").unwrap();
|
||||||
let p = "a.txt";
|
let p = "a.txt";
|
||||||
@@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_then_grep_finds_content() {
|
async fn write_then_grep_finds_content() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
let grep = reg.get("Grep");
|
let grep = reg.get("Grep");
|
||||||
|
|
||||||
@@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn glob_finds_written_files() {
|
async fn glob_finds_written_files() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
let glob = reg.get("Glob");
|
let glob = reg.get("Glob");
|
||||||
|
|
||||||
@@ -837,7 +838,7 @@ async fn glob_finds_written_files() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn absolute_path_is_rejected() {
|
async fn absolute_path_is_rejected() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
let outside = TempDir::new().unwrap();
|
let outside = TempDir::new().unwrap();
|
||||||
let write = reg.get("Write");
|
let write = reg.get("Write");
|
||||||
|
|
||||||
@@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_to_existing_without_read_fails() {
|
async fn write_to_existing_without_read_fails() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("exists.txt");
|
let file = dir.path().join("exists.txt");
|
||||||
std::fs::write(&file, "preexisting").unwrap();
|
std::fs::write(&file, "preexisting").unwrap();
|
||||||
|
|
||||||
@@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() {
|
|||||||
async fn shared_workdir_across_tools() {
|
async fn shared_workdir_across_tools() {
|
||||||
// The key invariant: all builtin tools share the same WorkdirSession instance,
|
// The key invariant: all builtin tools share the same WorkdirSession instance,
|
||||||
// so read-history set by Read is visible to Edit and Write.
|
// so read-history set by Read is visible to Edit and Write.
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("shared.txt");
|
let file = dir.path().join("shared.txt");
|
||||||
std::fs::write(&file, "one\n").unwrap();
|
std::fs::write(&file, "one\n").unwrap();
|
||||||
|
|
||||||
@@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn edit_requires_read_across_tools() {
|
async fn edit_requires_read_across_tools() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let file = dir.path().join("a.txt");
|
let file = dir.path().join("a.txt");
|
||||||
std::fs::write(&file, "foo\n").unwrap();
|
std::fs::write(&file, "foo\n").unwrap();
|
||||||
|
|
||||||
@@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deterministic_tool_order_is_registration_order() {
|
async fn deterministic_tool_order_is_registration_order() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
// Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash
|
// Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash
|
||||||
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() {
|
|||||||
// Regression: tool name capitalization matches Claude Code reference
|
// Regression: tool name capitalization matches Claude Code reference
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_names_match_reference_spec() {
|
fn tool_names_match_reference_spec() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, reg) = setup();
|
||||||
for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||||
assert!(
|
assert!(
|
||||||
reg.entries.iter().any(|(m, _)| m.name == expected),
|
reg.entries.iter().any(|(m, _)| m.name == expected),
|
||||||
@@ -953,15 +954,14 @@ fn tool_names_match_reference_spec() {
|
|||||||
async fn tracker_recent_files_tracks_read_write_edit() {
|
async fn tracker_recent_files_tracks_read_write_edit() {
|
||||||
// Build a fresh registry that shares a tracker we can query afterwards.
|
// Build a fresh registry that shares a tracker we can query afterwards.
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let spill = TempDir::new().unwrap();
|
let scope = Scope::writable(dir.path()).unwrap();
|
||||||
let scope = scope_with_spill(dir.path(), spill.path());
|
|
||||||
let fs: WorkdirSessionHandle =
|
let fs: WorkdirSessionHandle =
|
||||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||||
let tracker = Tracker::new();
|
let tracker = Tracker::new();
|
||||||
let reg = Registry::new(core_builtin_tools(
|
let reg = Registry::new(core_builtin_tools(
|
||||||
fs,
|
fs,
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
spill.path().to_path_buf(),
|
tools::bash_output::BashOutputView::new(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let a = dir.path().join("a.txt");
|
let a = dir.path().join("a.txt");
|
||||||
@@ -1003,118 +1003,314 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
|||||||
async fn bash_inherits_workdir_cwd() {
|
async fn bash_inherits_workdir_cwd() {
|
||||||
// The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its
|
// The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its
|
||||||
// `pwd` should canonicalize to the workspace root we set up.
|
// `pwd` should canonicalize to the workspace root we set up.
|
||||||
let (dir, _spill, reg) = setup();
|
let (dir, reg) = setup();
|
||||||
let bash = reg.get("Bash");
|
let bash = reg.get("Bash");
|
||||||
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
|
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
|
||||||
let body = out.content.unwrap();
|
let body = out.content.unwrap();
|
||||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||||
|
let actual = std::fs::canonicalize(parsed["stdout_preview"].as_str().unwrap().trim()).unwrap();
|
||||||
let expected = std::fs::canonicalize(dir.path()).unwrap();
|
let expected = std::fs::canonicalize(dir.path()).unwrap();
|
||||||
assert_eq!(actual, expected);
|
assert_eq!(actual, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn output_fixture() -> (
|
||||||
|
TempDir,
|
||||||
|
WorkdirSessionHandle,
|
||||||
|
tools::bash_output::BashOutputView,
|
||||||
|
Arc<dyn Tool>,
|
||||||
|
) {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().into(),
|
||||||
|
));
|
||||||
|
let view = tools::bash_output::BashOutputView::new();
|
||||||
|
let (_, bash) = tools::bash_tool(session.clone(), view.clone())();
|
||||||
|
(root, session, view, bash)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_provider_output_exposes_readable_retained_path() {
|
async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() {
|
||||||
let (_dir, spill, reg) = setup();
|
let (root, _session, view, bash) = output_fixture();
|
||||||
let bash = reg.get("Bash");
|
|
||||||
let out = call(
|
let out = call(
|
||||||
&bash,
|
&bash,
|
||||||
json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }),
|
json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let body = out.content.unwrap();
|
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||||
assert!(body.contains("bounded WorkdirSession command output"));
|
assert_eq!(body["output"]["terminal"]["exit_code"], 7);
|
||||||
assert!(body.contains("full output saved to"));
|
assert_eq!(body["preview_omitted"], true);
|
||||||
assert!(body.contains(spill.path().to_str().unwrap()));
|
assert_eq!(body["output"]["storage_truncated"], false);
|
||||||
let artifact = std::fs::read_dir(spill.path())
|
let id = body["output"]["id"].as_str().unwrap();
|
||||||
.unwrap()
|
assert_eq!(
|
||||||
.next()
|
view.read(id, workdir::CommandStream::Stdout, 0, 65536)
|
||||||
.expect("retained output")
|
.unwrap(),
|
||||||
.unwrap()
|
vec![b'x'; 20480]
|
||||||
.path();
|
);
|
||||||
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
|
assert_eq!(
|
||||||
|
view.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||||
|
.unwrap(),
|
||||||
|
[vec![255], "€".as_bytes().to_vec()].concat()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body["stdout_entry"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("/bash-output/")
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains(root.path().to_str().unwrap()));
|
||||||
|
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_command_output(
|
||||||
|
events: &mut tokio::sync::broadcast::Receiver<workdir::CommandEvent>,
|
||||||
|
) {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if matches!(
|
||||||
|
events.recv().await.unwrap(),
|
||||||
|
workdir::CommandEvent::Output { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("command output readiness");
|
||||||
|
}
|
||||||
|
async fn await_command_terminal(
|
||||||
|
events: &mut tokio::sync::broadcast::Receiver<workdir::CommandEvent>,
|
||||||
|
) {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if let workdir::CommandEvent::Terminal { status, .. } = events.recv().await.unwrap() {
|
||||||
|
assert_eq!(status, workdir::CommandStatus::Cancelled);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("command terminal fence");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_root, session, _view, bash) = output_fixture();
|
||||||
let marker = dir.path().join("must-not-run-after-cancel");
|
let mut events = session.subscribe_command_events().unwrap();
|
||||||
let command = format!(
|
|
||||||
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
|
||||||
marker.display()
|
|
||||||
);
|
|
||||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
|
||||||
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||||
let bash = reg.get("Bash");
|
|
||||||
let executing = bash.clone();
|
let executing = bash.clone();
|
||||||
let execution_context = context.clone();
|
let execution_context = context.clone();
|
||||||
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
|
let execution = tokio::spawn(async move {
|
||||||
|
executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
});
|
||||||
bash.cancel_execution(&context)
|
await_command_output(&mut events).await;
|
||||||
.await
|
bash.cancel_execution(&context).await.unwrap();
|
||||||
.expect("signal exact execution cancellation");
|
let ToolError::Cancelled(output) =
|
||||||
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
|
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||||
.await
|
.await
|
||||||
.expect("cancelled Bash should terminate inside the Engine grace budget")
|
.unwrap()
|
||||||
.expect("Bash task join");
|
.unwrap()
|
||||||
|
.unwrap_err()
|
||||||
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
|
else {
|
||||||
panic!("expected typed cancellation result");
|
panic!("expected cancellation");
|
||||||
};
|
};
|
||||||
let content = output.content.expect("bounded progress output");
|
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
assert!(
|
assert_eq!(body["stdout_preview"], "before");
|
||||||
content.contains("before"),
|
assert_eq!(body["stderr_preview"], "err-before");
|
||||||
"missing pre-cancel stdout: {content}"
|
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
|
||||||
);
|
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||||
assert!(
|
|
||||||
content.contains("err-before"),
|
|
||||||
"missing pre-cancel stderr: {content}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!content.contains("after"),
|
|
||||||
"post-cancel output leaked: {content}"
|
|
||||||
);
|
|
||||||
assert!(content.len() <= 16 * 1024, "output must remain bounded");
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
|
||||||
assert!(
|
|
||||||
!marker.exists(),
|
|
||||||
"the cancelled command continued executing after terminal confirmation"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_root, session, _view, bash) = output_fixture();
|
||||||
let marker = dir.path().join("must-not-survive-force-close");
|
let mut events = session.subscribe_command_events().unwrap();
|
||||||
let command = format!("sleep 1; touch {}", marker.display());
|
|
||||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
|
||||||
let bash = reg.get("Bash");
|
|
||||||
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||||
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
let (handle, terminal) = ToolExecutionHandle::start(
|
||||||
|
bash.clone(),
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(),
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
await_command_output(&mut events).await;
|
||||||
handle.force_close();
|
handle.force_close();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
terminal.await,
|
terminal.await,
|
||||||
ToolExecutionTerminal::OutcomeUnknown
|
ToolExecutionTerminal::OutcomeUnknown
|
||||||
));
|
));
|
||||||
|
await_command_terminal(&mut events).await;
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await;
|
||||||
assert!(
|
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
!marker.exists(),
|
assert_eq!(body["stdout_preview"], "reused");
|
||||||
"CommandGuard cleanup allowed a force-closed command to continue"
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = bash
|
|
||||||
.execute(
|
|
||||||
r#"{"command":"printf 'reused'","cwd":"."}"#,
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("workdir session remains reusable after cleanup");
|
|
||||||
assert_eq!(output.content.as_deref(), Some("reused"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanity: unused Path import guard
|
// Sanity: unused Path import guard
|
||||||
const _: fn() -> &'static Path = || Path::new("/");
|
const _: fn() -> &'static Path = || Path::new("/");
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parent_and_sibling_broker_commands_keep_output_ownership_after_revoke() {
|
||||||
|
let (root, source, parent_view, parent_bash) = output_fixture();
|
||||||
|
let broker = WorkdirToolBroker::new(source);
|
||||||
|
let mut leases = Vec::new();
|
||||||
|
let mut views = Vec::new();
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
for name in ["left", "right"] {
|
||||||
|
std::fs::create_dir(root.path().join(name)).unwrap();
|
||||||
|
let lease = Arc::new(
|
||||||
|
broker
|
||||||
|
.scope(WorkdirToolScope {
|
||||||
|
rules: vec![WorkdirToolScopeRule {
|
||||||
|
target: WorkdirPath::new(name).unwrap(),
|
||||||
|
permission: WorkdirToolScopePermission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
|
}],
|
||||||
|
cwd: WorkdirPath::new(name).unwrap(),
|
||||||
|
command: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let authority = lease.clone();
|
||||||
|
let view = parent_view.child(Arc::new(move || authority.is_active()));
|
||||||
|
let (_, bash) = tools::bash_tool(lease.tool_session(), view.clone())();
|
||||||
|
leases.push(lease);
|
||||||
|
views.push(view);
|
||||||
|
commands.push(bash);
|
||||||
|
}
|
||||||
|
let left_input =
|
||||||
|
json!({"command":"printf 'left-%.0s' {1..10000}; printf left-err >&2", "cwd":"."});
|
||||||
|
let right_input =
|
||||||
|
json!({"command":"printf 'right-%.0s' {1..10000}; printf right-err >&2", "cwd":"."});
|
||||||
|
let parent_input = json!({"command":"printf parent; printf parent-err >&2", "cwd":"."});
|
||||||
|
let (left, right, parent) = tokio::join!(
|
||||||
|
call(&commands[0], left_input),
|
||||||
|
call(&commands[1], right_input),
|
||||||
|
call(&parent_bash, parent_input)
|
||||||
|
);
|
||||||
|
let outputs = [left, right, parent]
|
||||||
|
.map(|out| serde_json::from_str::<serde_json::Value>(&out.content.unwrap()).unwrap());
|
||||||
|
for (index, label) in ["left", "right", "parent"].iter().enumerate() {
|
||||||
|
let id = outputs[index]["output"]["id"].as_str().unwrap();
|
||||||
|
let bytes = parent_view
|
||||||
|
.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bytes, format!("{label}-err").as_bytes());
|
||||||
|
for (child_index, view) in views.iter().enumerate() {
|
||||||
|
assert_eq!(view.metadata(id).is_some(), child_index == index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left_id = outputs[0]["output"]["id"].as_str().unwrap();
|
||||||
|
leases[0].close().await.unwrap();
|
||||||
|
assert!(views[0].metadata(left_id).is_none());
|
||||||
|
assert!(parent_view.metadata(left_id).is_some());
|
||||||
|
assert!(
|
||||||
|
views[1]
|
||||||
|
.metadata(outputs[1]["output"]["id"].as_str().unwrap())
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
leases[1].close().await.unwrap();
|
||||||
|
broker.close().await.unwrap();
|
||||||
|
parent_view.revoke();
|
||||||
|
assert!(parent_view.metadata(left_id).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn utf8_quota_boundary_preserves_raw_prefix_and_reports_saved_vs_seen() {
|
||||||
|
let (_root, _session, view, bash) = output_fixture();
|
||||||
|
let out = call(
|
||||||
|
&bash,
|
||||||
|
json!({"command":"printf '%4194303s' ''; printf '€'", "cwd":"."}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||||
|
let id = body["output"]["id"].as_str().unwrap();
|
||||||
|
let saved = body["output"]["stdout_saved"].as_u64().unwrap() as usize;
|
||||||
|
assert_eq!(saved, 4 * 1024 * 1024);
|
||||||
|
assert_eq!(body["output"]["stdout_seen"], 4 * 1024 * 1024 + 2);
|
||||||
|
assert_eq!(body["output"]["stderr_seen"], 0);
|
||||||
|
assert_eq!(body["output"]["storage_truncated"], true);
|
||||||
|
assert_eq!(
|
||||||
|
view.read(id, workdir::CommandStream::Stdout, saved - 1, 10)
|
||||||
|
.unwrap(),
|
||||||
|
vec![0xe2]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
view.line_window(id, workdir::CommandStream::Stdout, 0, 1)
|
||||||
|
.unwrap()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(out.summary.starts_with("$ "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_terminal_fences_retained_stdout_and_stderr() {
|
||||||
|
let (_root, _session, view, bash) = output_fixture();
|
||||||
|
let error = tokio::time::timeout(std::time::Duration::from_secs(5), bash.execute(r#"{"command":"printf before; printf err-before >&2; while :; do :; done","cwd":".","timeout":1}"#, Default::default())).await.unwrap().unwrap_err();
|
||||||
|
let ToolError::Interrupted(out) = error else {
|
||||||
|
panic!("expected timeout output");
|
||||||
|
};
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||||
|
let id = body["output"]["id"].as_str().unwrap();
|
||||||
|
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
|
||||||
|
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||||
|
assert_eq!(
|
||||||
|
view.read(id, workdir::CommandStream::Stdout, 0, 65536)
|
||||||
|
.unwrap(),
|
||||||
|
b"before"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
view.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||||
|
.unwrap(),
|
||||||
|
b"err-before"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration: real process pipes and provider replay into the production Bash collector.
|
||||||
|
// Only response delivery is fault-injected; Runtime's HTTP suite covers actual wire routing.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_appending() {
|
||||||
|
for lose_terminal_only in [false, true] {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let session = Arc::new(ExternalValidatingSession {
|
||||||
|
inner: LocalWorkdirSession::new(
|
||||||
|
Scope::writable(dir.path()).unwrap(),
|
||||||
|
dir.path().into(),
|
||||||
|
),
|
||||||
|
truncate_first_binary_response: false,
|
||||||
|
mutate_after_first_binary_response: None,
|
||||||
|
command_response_losses: std::sync::atomic::AtomicUsize::new(1),
|
||||||
|
lose_terminal_only,
|
||||||
|
});
|
||||||
|
let view = tools::bash_output::BashOutputView::new();
|
||||||
|
let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone());
|
||||||
|
let bash = Registry::new(definitions).get("Bash");
|
||||||
|
let out = call(&bash, json!({"cwd":".","command":"printf once >> effect.txt; printf 'x%.0s' {1..200000}; printf err >&2; exit 3"})).await;
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(dir.path().join("effect.txt")).unwrap(),
|
||||||
|
b"once"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.command_response_losses
|
||||||
|
.load(std::sync::atomic::Ordering::Acquire),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(body["output"]["stdout_saved"], 200000);
|
||||||
|
assert_eq!(body["output"]["stdout_delivered"], 200000);
|
||||||
|
assert_eq!(body["output"]["stderr_saved"], 3);
|
||||||
|
assert_eq!(body["output"]["terminal"]["exit_code"], 3);
|
||||||
|
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||||
|
assert_eq!(body["output"]["storage_truncated"], false);
|
||||||
|
let id = body["output"]["id"].as_str().unwrap();
|
||||||
|
for offset in (0..200000).step_by(65536) {
|
||||||
|
assert_eq!(
|
||||||
|
view.read(id, workdir::CommandStream::Stdout, offset, 65536)
|
||||||
|
.unwrap(),
|
||||||
|
vec![b'x'; (200000 - offset).min(65536)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
use workdir::WorkdirSession as _;
|
||||||
|
session.close().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ serde_json.workspace = true
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["io-util", "process", "rt", "sync", "time"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! Bounded collection for tests whose subject is command routing/lifecycle,
|
||||||
|
//! rather than the byte-page protocol itself.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct CollectedCommand {
|
||||||
|
pub content: String,
|
||||||
|
pub status: CommandStatus,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
pub timed_out: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub(crate) trait CollectCommand: WorkdirSession {
|
||||||
|
async fn collect_command_output(
|
||||||
|
&self,
|
||||||
|
mut request: CommandOutputRequest,
|
||||||
|
) -> Result<CollectedCommand, WorkdirError> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
request.wait = true;
|
||||||
|
loop {
|
||||||
|
let page = self.command_output(request.clone()).await?;
|
||||||
|
for chunk in page.chunks {
|
||||||
|
bytes.extend(chunk.bytes);
|
||||||
|
assert!(
|
||||||
|
bytes.len() <= 16 * 1024 * 1024,
|
||||||
|
"fixture output exceeded bounded test collection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
request.cursor = page.next_cursor;
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
return Ok(CollectedCommand {
|
||||||
|
content: String::from_utf8_lossy(&bytes).into_owned(),
|
||||||
|
status: terminal.status,
|
||||||
|
exit_code: terminal.exit_code,
|
||||||
|
timed_out: terminal.status == CommandStatus::TimedOut,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<T: WorkdirSession + ?Sized> CollectCommand for T {}
|
||||||
+136
-38
@@ -9,7 +9,8 @@
|
|||||||
//! COMMAND remains separate explicit authority. General session capability intersections may form
|
//! COMMAND remains separate explicit authority. General session capability intersections may form
|
||||||
//! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical
|
//! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical
|
||||||
//! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes
|
//! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes
|
||||||
//! to a directory. Output is bounded and inline; provider-local spill paths are not exposed.
|
//! to a directory. Output uses bounded byte pages with cursor acknowledgements;
|
||||||
|
//! providers never receive caller-selected output paths.
|
||||||
|
|
||||||
use std::path::{Component, Path};
|
use std::path::{Component, Path};
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||||
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
||||||
|
|
||||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2;
|
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 3;
|
||||||
/// Maximum serialized Backend-to-provider command frame size.
|
/// Maximum serialized Backend-to-provider command frame size.
|
||||||
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||||
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
||||||
@@ -33,9 +34,9 @@ const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
|||||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||||
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
|
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = crate::MAX_COMMAND_READ_BYTES;
|
||||||
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||||
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
|
const MAX_EXTERNAL_COMMAND_CURSOR: u64 = crate::MAX_COMMAND_TRANSPORT_BYTES;
|
||||||
|
|
||||||
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
||||||
!handle.0.is_empty()
|
!handle.0.is_empty()
|
||||||
@@ -272,11 +273,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
|||||||
}
|
}
|
||||||
WorkdirSessionOperation::CommandStart(request) => {
|
WorkdirSessionOperation::CommandStart(request) => {
|
||||||
!request.command.is_empty()
|
!request.command.is_empty()
|
||||||
|
&& request.validate().is_ok()
|
||||||
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
||||||
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
||||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
|
|
||||||
&& is_root_relative(&request.cwd)
|
&& is_root_relative(&request.cwd)
|
||||||
&& request.spill_dir.is_none()
|
|
||||||
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
||||||
!tool_call_id.is_empty()
|
!tool_call_id.is_empty()
|
||||||
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
||||||
@@ -462,11 +462,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
|||||||
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
||||||
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
||||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||||
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
output.transport_bounds_valid()
|
||||||
|
&& output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||||
&& output
|
&& output
|
||||||
.next_cursor
|
.chunks
|
||||||
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
|
.iter()
|
||||||
&& output.output_path.is_none()
|
.map(|chunk| chunk.bytes.len())
|
||||||
|
.sum::<usize>()
|
||||||
|
<= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||||
|
&& output.next_cursor <= MAX_EXTERNAL_COMMAND_CURSOR
|
||||||
|
&& output
|
||||||
|
.terminal
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|terminal| terminal.status != crate::CommandStatus::Running)
|
||||||
}
|
}
|
||||||
WorkdirSessionOperationResult::CommandCancel => true,
|
WorkdirSessionOperationResult::CommandCancel => true,
|
||||||
};
|
};
|
||||||
@@ -813,11 +821,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||||
let future = r#"{
|
for version in [
|
||||||
"version": 3,
|
EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION - 1,
|
||||||
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + 1,
|
||||||
}"#;
|
] {
|
||||||
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
|
let unsupported = serde_json::json!({
|
||||||
|
"version": version,
|
||||||
|
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(unsupported).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
let invalid_limits = r#"{
|
let invalid_limits = r#"{
|
||||||
"max_source_bytes": 1024,
|
"max_source_bytes": 1024,
|
||||||
@@ -837,32 +850,35 @@ mod tests {
|
|||||||
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
|
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
|
||||||
|
|
||||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||||
command: "cat /home/operator/private/session.log".to_string(),
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: "printf unsafe".to_string(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(),
|
||||||
spill_dir: Some("/home/operator/private/output".into()),
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
});
|
});
|
||||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||||
|
|
||||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf bounded".to_string(),
|
command: "printf bounded".to_string(),
|
||||||
timeout_secs: 30,
|
timeout_secs: 30,
|
||||||
output_limit: 12 * 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("tool-call-1".to_string()),
|
tool_call_id: Some("tool-call-1".to_string()),
|
||||||
});
|
});
|
||||||
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
||||||
assert!(
|
assert!(
|
||||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
||||||
timeout_secs: 30,
|
timeout_secs: 30,
|
||||||
output_limit: 12 * 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
@@ -899,13 +915,17 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||||
crate::CommandOutput {
|
crate::CommandOutput {
|
||||||
status: crate::CommandStatus::Completed,
|
chunks: vec![],
|
||||||
exit_code: Some(0),
|
next_cursor: 0,
|
||||||
timed_out: false,
|
terminal: Some(crate::CommandTerminal {
|
||||||
content: "bounded".to_string(),
|
status: crate::CommandStatus::Completed,
|
||||||
next_cursor: None,
|
exit_code: Some(0),
|
||||||
truncated: false,
|
stdout_bytes: 0,
|
||||||
output_path: None,
|
stderr_bytes: 0,
|
||||||
|
stdout_delivered_bytes: 0,
|
||||||
|
stderr_delivered_bytes: 0,
|
||||||
|
truncation_reason: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -913,13 +933,14 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||||
crate::CommandOutput {
|
crate::CommandOutput {
|
||||||
status: crate::CommandStatus::Completed,
|
chunks: vec![crate::CommandByteChunk {
|
||||||
exit_code: Some(0),
|
sequence: 0,
|
||||||
timed_out: false,
|
stream: crate::CommandStream::Stdout,
|
||||||
content: "bounded".to_string(),
|
offset: 0,
|
||||||
next_cursor: None,
|
bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1]
|
||||||
truncated: true,
|
}],
|
||||||
output_path: Some("/provider/private/output".into()),
|
next_cursor: 0,
|
||||||
|
terminal: None
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -970,7 +991,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||||
let unknown = r#"{
|
let unknown = r#"{
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"message": {
|
"message": {
|
||||||
"kind": "heartbeat",
|
"kind": "heartbeat",
|
||||||
"generation": 1,
|
"generation": 1,
|
||||||
@@ -1024,4 +1045,81 @@ mod tests {
|
|||||||
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
|
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
|
||||||
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
|
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_budget_and_page_metadata_obey_external_hard_bounds() {
|
||||||
|
fn request(budget: u64) -> WorkdirSessionOperation {
|
||||||
|
WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||||
|
command: "true".into(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
transport_byte_budget: budget,
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for budget in [0, crate::MAX_COMMAND_TRANSPORT_BYTES] {
|
||||||
|
let operation = ExternalWorkdirOperation::try_from(request(budget)).unwrap();
|
||||||
|
let wire = serde_json::to_value(operation).unwrap();
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperation>(wire).is_ok());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ExternalWorkdirOperation::try_from(request(crate::MAX_COMMAND_TRANSPORT_BYTES + 1))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
fn page() -> crate::CommandOutput {
|
||||||
|
crate::CommandOutput {
|
||||||
|
chunks: vec![crate::CommandByteChunk {
|
||||||
|
sequence: 0,
|
||||||
|
stream: crate::CommandStream::Stdout,
|
||||||
|
offset: 0,
|
||||||
|
bytes: b"abc".to_vec(),
|
||||||
|
}],
|
||||||
|
next_cursor: 3,
|
||||||
|
terminal: Some(crate::CommandTerminal {
|
||||||
|
status: crate::CommandStatus::Completed,
|
||||||
|
exit_code: Some(0),
|
||||||
|
stdout_bytes: 100 * 1024 * 1024,
|
||||||
|
stderr_bytes: 7,
|
||||||
|
stdout_delivered_bytes: 3,
|
||||||
|
stderr_delivered_bytes: 0,
|
||||||
|
truncation_reason: Some(crate::CommandOutputTruncation::ByteBudget),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let accepted = ExternalWorkdirOperationResult::try_from(
|
||||||
|
WorkdirSessionOperationResult::CommandOutput(page()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<ExternalWorkdirOperationResult>(
|
||||||
|
serde_json::to_value(accepted).unwrap()
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
for mutate in [
|
||||||
|
|p: &mut crate::CommandOutput| p.next_cursor = crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||||
|
|p: &mut crate::CommandOutput| p.chunks[0].offset = crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
|p: &mut crate::CommandOutput| p.chunks[0].sequence = u64::MAX,
|
||||||
|
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_delivered_bytes = 4,
|
||||||
|
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_bytes = 2,
|
||||||
|
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().truncation_reason = None,
|
||||||
|
] {
|
||||||
|
let mut invalid = page();
|
||||||
|
mutate(&mut invalid);
|
||||||
|
let wire = serde_json::to_value(WorkdirSessionOperationResult::CommandOutput(invalid))
|
||||||
|
.unwrap();
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire).is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandOutput(
|
||||||
|
crate::CommandOutputRequest {
|
||||||
|
handle: crate::CommandHandle("command-1".into()),
|
||||||
|
cursor: crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||||
|
limit: 1,
|
||||||
|
wait: false,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#[cfg(test)]
|
||||||
|
use crate::command_test_support::CollectCommand as _;
|
||||||
use crate::http::{
|
use crate::http::{
|
||||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
||||||
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||||
@@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() {
|
|||||||
let command = WorkdirSession::start_command(
|
let command = WorkdirSession::start_command(
|
||||||
&session,
|
&session,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "echo changed > item.txt".to_string(),
|
command: "echo changed > item.txt".to_string(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() {
|
|||||||
let command = WorkdirSession::start_command(
|
let command = WorkdirSession::start_command(
|
||||||
&session,
|
&session,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "touch command-ran".to_string(),
|
command: "touch command-ran".to_string(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces
|
|||||||
|
|
||||||
let handle = session
|
let handle = session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf command-ok".to_string(),
|
command: "printf command-ok".to_string(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let output = session
|
let output = session
|
||||||
.command_output(CommandOutputRequest {
|
.collect_command_output(CommandOutputRequest {
|
||||||
handle,
|
handle,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
limit: 1024,
|
limit: 1024,
|
||||||
|
|||||||
+67
-17
@@ -847,6 +847,7 @@ mod client {
|
|||||||
&self,
|
&self,
|
||||||
request: CommandRequest,
|
request: CommandRequest,
|
||||||
) -> Result<CommandHandle, WorkdirError> {
|
) -> Result<CommandHandle, WorkdirError> {
|
||||||
|
request.validate()?;
|
||||||
match self
|
match self
|
||||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||||
.await?
|
.await?
|
||||||
@@ -873,17 +874,39 @@ mod client {
|
|||||||
&self,
|
&self,
|
||||||
request: CommandOutputRequest,
|
request: CommandOutputRequest,
|
||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
|
if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) {
|
||||||
|
return Err(WorkdirError::InvalidArgument(
|
||||||
|
"command byte limit is outside transport bounds".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let wait = request.wait;
|
let wait = request.wait;
|
||||||
loop {
|
match self
|
||||||
match self
|
.operate(WorkdirSessionOperation::CommandOutput(request.clone()))
|
||||||
.operate(WorkdirSessionOperation::CommandOutput(request.clone()))
|
.await?
|
||||||
.await?
|
{
|
||||||
{
|
WorkdirSessionOperationResult::CommandOutput(result) => {
|
||||||
WorkdirSessionOperationResult::CommandOutput(result)
|
let size = result
|
||||||
if wait && result.status == CommandStatus::Running => {}
|
.chunks
|
||||||
WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result),
|
.iter()
|
||||||
_ => return Err(Self::mismatch("command_output")),
|
.try_fold(0usize, |size, chunk| size.checked_add(chunk.bytes.len()));
|
||||||
|
if !result.transport_bounds_valid()
|
||||||
|
|| size.is_none_or(|size| {
|
||||||
|
size > request.limit
|
||||||
|
|| request.cursor.checked_add(size as u64)
|
||||||
|
!= Some(result.next_cursor)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(Self::mismatch("bounded command byte page"));
|
||||||
|
}
|
||||||
|
if wait && result.chunks.is_empty() && result.terminal.is_none() {
|
||||||
|
// A waiting request must block at the provider. Never spin HTTP
|
||||||
|
// requests against an immediate empty response. A caller may retry
|
||||||
|
// this exact cursor with its own bounded transport retry policy.
|
||||||
|
return Err(Self::mismatch("waiting command byte page"));
|
||||||
|
}
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
_ => return Err(Self::mismatch("command_output")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,20 +965,47 @@ mod client {
|
|||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
) -> Result<T, WorkdirError> {
|
) -> Result<T, WorkdirError> {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
response.json().await.map_err(http_unavailable)
|
let bytes = bounded_response_bytes(response).await?;
|
||||||
|
serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into()))
|
||||||
} else {
|
} else {
|
||||||
Err(decode_error(response).await)
|
Err(decode_error(response).await)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn bounded_response_bytes(
|
||||||
|
mut response: reqwest::Response,
|
||||||
|
) -> Result<Vec<u8>, WorkdirError> {
|
||||||
|
const MAX_RESPONSE_BYTES: usize = crate::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES;
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|size| size > MAX_RESPONSE_BYTES as u64)
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::Transport(
|
||||||
|
"Runtime Workdir response exceeded transport bound".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? {
|
||||||
|
if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) {
|
||||||
|
return Err(WorkdirError::Transport(
|
||||||
|
"Runtime Workdir response exceeded transport bound".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
async fn decode_error(response: reqwest::Response) -> WorkdirError {
|
async fn decode_error(response: reqwest::Response) -> WorkdirError {
|
||||||
response
|
match bounded_response_bytes(response).await {
|
||||||
.json::<WorkdirTransportError>()
|
Ok(bytes) => serde_json::from_slice::<WorkdirTransportError>(&bytes)
|
||||||
.await
|
.map(WorkdirTransportError::into_workdir_error)
|
||||||
.map(WorkdirTransportError::into_workdir_error)
|
.unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(|_| {
|
WorkdirError::Transport("Runtime Workdir error response was invalid".into())
|
||||||
WorkdirError::Transport("Runtime Workdir error response was invalid".to_string())
|
}),
|
||||||
})
|
Err(error) => error,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
|
fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
|
||||||
|
|||||||
@@ -618,3 +618,6 @@ mod capability_tests {
|
|||||||
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod command_test_support;
|
||||||
|
|||||||
+100
-875
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
|||||||
|
//! Captured process bytes use a bounded queue, never the lossy Console channel.
|
||||||
|
//! Pipe backpressure bounds unread process output without temporary-file growth.
|
||||||
|
//! A replay page survives a lost HTTP response until its next cursor is sent.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
const QUEUE_CHUNKS: usize = 8;
|
||||||
|
pub(super) const MAX_COMMANDS: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Message {
|
||||||
|
Bytes(crate::CommandByteChunk),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct LocalCommand {
|
||||||
|
pub task: JoinHandle<()>,
|
||||||
|
pub cancel: watch::Sender<bool>,
|
||||||
|
pub status: watch::Receiver<Option<crate::CommandTerminal>>,
|
||||||
|
pub transport: Arc<Mutex<Transport>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct Transport {
|
||||||
|
receiver: mpsc::Receiver<Message>,
|
||||||
|
pending: Option<crate::CommandByteChunk>,
|
||||||
|
cursor: u64,
|
||||||
|
replay: Option<(u64, CommandOutput)>,
|
||||||
|
terminal: Option<crate::CommandTerminal>,
|
||||||
|
terminal_status: watch::Receiver<Option<crate::CommandTerminal>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transport {
|
||||||
|
pub async fn read(
|
||||||
|
&mut self,
|
||||||
|
request: &CommandOutputRequest,
|
||||||
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
|
if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) {
|
||||||
|
return Err(WorkdirError::Conflict(
|
||||||
|
"command byte limit is outside transport bounds".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some((cursor, page)) = &self.replay {
|
||||||
|
if request.cursor == *cursor {
|
||||||
|
if page
|
||||||
|
.chunks
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| chunk.bytes.len())
|
||||||
|
.sum::<usize>()
|
||||||
|
> request.limit
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::Conflict(
|
||||||
|
"retry byte limit is smaller than retained page".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(page.clone());
|
||||||
|
}
|
||||||
|
if request.cursor != page.next_cursor {
|
||||||
|
return Err(WorkdirError::Conflict(
|
||||||
|
"command cursor is not the current page acknowledgement".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.cursor = page.next_cursor;
|
||||||
|
self.replay = None;
|
||||||
|
}
|
||||||
|
if request.cursor != self.cursor {
|
||||||
|
return Err(WorkdirError::Conflict(
|
||||||
|
"command cursor was acknowledged or is ahead of output".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut page = CommandOutput {
|
||||||
|
chunks: Vec::new(),
|
||||||
|
next_cursor: self.cursor,
|
||||||
|
terminal: self.terminal.clone(),
|
||||||
|
};
|
||||||
|
let mut remaining = request.limit;
|
||||||
|
loop {
|
||||||
|
let message = if let Some(chunk) = self.pending.take() {
|
||||||
|
Some(Message::Bytes(chunk))
|
||||||
|
} else if page.chunks.is_empty() && page.terminal.is_none() && request.wait {
|
||||||
|
self.receiver.recv().await
|
||||||
|
} else {
|
||||||
|
match self.receiver.try_recv() {
|
||||||
|
Ok(message) => Some(message),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||||
|
Err(mpsc::error::TryRecvError::Disconnected) => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match message {
|
||||||
|
Some(Message::Bytes(mut chunk)) => {
|
||||||
|
let count = remaining.min(chunk.bytes.len());
|
||||||
|
if count < chunk.bytes.len() {
|
||||||
|
let tail = chunk.bytes.split_off(count);
|
||||||
|
self.pending = Some(crate::CommandByteChunk {
|
||||||
|
sequence: chunk.sequence + count as u64,
|
||||||
|
stream: chunk.stream,
|
||||||
|
offset: chunk.offset + count as u64,
|
||||||
|
bytes: tail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
page.next_cursor += count as u64;
|
||||||
|
remaining -= count;
|
||||||
|
page.chunks.push(chunk);
|
||||||
|
if remaining == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if page.terminal.is_some() => break,
|
||||||
|
None => {
|
||||||
|
let terminal = self.terminal_status.borrow().clone().ok_or_else(|| {
|
||||||
|
WorkdirError::Unavailable(
|
||||||
|
"command transport ended before terminal fence".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
self.terminal = Some(terminal.clone());
|
||||||
|
page.terminal = Some(terminal);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An empty poll must not become a permanently replayed empty page.
|
||||||
|
if !page.chunks.is_empty() || page.terminal.is_some() {
|
||||||
|
self.replay = Some((self.cursor, page.clone()));
|
||||||
|
}
|
||||||
|
Ok(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn terminal_delivered(&self) -> bool {
|
||||||
|
self.replay
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|(_, page)| page.terminal.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
self.receiver.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn start(
|
||||||
|
cwd: PathBuf,
|
||||||
|
request: CommandRequest,
|
||||||
|
command_id: String,
|
||||||
|
telemetry: CommandTelemetry,
|
||||||
|
environment: BTreeMap<String, String>,
|
||||||
|
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||||
|
) -> LocalCommand {
|
||||||
|
let (sender, receiver) = mpsc::channel(QUEUE_CHUNKS);
|
||||||
|
let (cancel, cancel_rx) = watch::channel(false);
|
||||||
|
let (status_tx, status) = watch::channel(None);
|
||||||
|
let transport = Arc::new(Mutex::new(Transport {
|
||||||
|
receiver,
|
||||||
|
pending: None,
|
||||||
|
cursor: 0,
|
||||||
|
replay: None,
|
||||||
|
terminal: None,
|
||||||
|
terminal_status: status.clone(),
|
||||||
|
}));
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let _resources = resources;
|
||||||
|
let terminal = execute(
|
||||||
|
cwd,
|
||||||
|
request,
|
||||||
|
&command_id,
|
||||||
|
&telemetry,
|
||||||
|
environment,
|
||||||
|
cancel_rx,
|
||||||
|
&sender,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
telemetry.terminal(&command_id, terminal.status, terminal.exit_code);
|
||||||
|
// Closing the byte queue fences this already-published terminal, without
|
||||||
|
// requiring spare queue capacity or a receiver to release task resources.
|
||||||
|
let _ = status_tx.send(Some(terminal));
|
||||||
|
});
|
||||||
|
LocalCommand {
|
||||||
|
task,
|
||||||
|
cancel,
|
||||||
|
status,
|
||||||
|
transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ByteAccounting {
|
||||||
|
stdout: u64,
|
||||||
|
stderr: u64,
|
||||||
|
stdout_delivered: u64,
|
||||||
|
stderr_delivered: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ByteAccounting {
|
||||||
|
fn terminal(&self, status: CommandStatus, exit_code: Option<i32>) -> crate::CommandTerminal {
|
||||||
|
crate::CommandTerminal {
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
|
stdout_bytes: self.stdout,
|
||||||
|
stderr_bytes: self.stderr,
|
||||||
|
stdout_delivered_bytes: self.stdout_delivered,
|
||||||
|
stderr_delivered_bytes: self.stderr_delivered,
|
||||||
|
truncation_reason: (self.stdout > self.stdout_delivered
|
||||||
|
|| self.stderr > self.stderr_delivered)
|
||||||
|
.then_some(crate::CommandOutputTruncation::ByteBudget),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump<R: AsyncRead + Unpin>(
|
||||||
|
mut stdout: R,
|
||||||
|
mut stderr: impl AsyncRead + Unpin,
|
||||||
|
sender: &mpsc::Sender<Message>,
|
||||||
|
telemetry: &CommandTelemetry,
|
||||||
|
command_id: &str,
|
||||||
|
mut remaining: u64,
|
||||||
|
accounting: &mut ByteAccounting,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut stdout_done = false;
|
||||||
|
let mut stderr_done = false;
|
||||||
|
let mut stdout_decoder = CommandOutputDecoder::default();
|
||||||
|
let mut stderr_decoder = CommandOutputDecoder::default();
|
||||||
|
let mut sequence = 0;
|
||||||
|
let mut stdout_buffer = [0; COMMAND_EVENT_CHUNK_BYTES];
|
||||||
|
let mut stderr_buffer = [0; COMMAND_EVENT_CHUNK_BYTES];
|
||||||
|
while !stdout_done || !stderr_done {
|
||||||
|
// After quota, drain locally even if nobody acknowledges the queue.
|
||||||
|
// Before quota, reserve first to retain bounded pipe backpressure.
|
||||||
|
let permit = if remaining > 0 {
|
||||||
|
let Ok(permit) = sender.reserve().await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
Some(permit)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (stream, result) = tokio::select! {
|
||||||
|
result = stdout.read(&mut stdout_buffer), if !stdout_done => (CommandStream::Stdout, result),
|
||||||
|
result = stderr.read(&mut stderr_buffer), if !stderr_done => (CommandStream::Stderr, result),
|
||||||
|
};
|
||||||
|
let buffer = match stream {
|
||||||
|
CommandStream::Stdout => &stdout_buffer,
|
||||||
|
CommandStream::Stderr => &stderr_buffer,
|
||||||
|
};
|
||||||
|
let count = result?;
|
||||||
|
let decoder = match stream {
|
||||||
|
CommandStream::Stdout => &mut stdout_decoder,
|
||||||
|
CommandStream::Stderr => &mut stderr_decoder,
|
||||||
|
};
|
||||||
|
if count == 0 {
|
||||||
|
match stream {
|
||||||
|
CommandStream::Stdout => stdout_done = true,
|
||||||
|
CommandStream::Stderr => stderr_done = true,
|
||||||
|
}
|
||||||
|
publish_decoded_output(decoder, telemetry, command_id, stream, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (seen, delivered) = match stream {
|
||||||
|
CommandStream::Stdout => (&mut accounting.stdout, &mut accounting.stdout_delivered),
|
||||||
|
CommandStream::Stderr => (&mut accounting.stderr, &mut accounting.stderr_delivered),
|
||||||
|
};
|
||||||
|
*seen = seen.saturating_add(count as u64);
|
||||||
|
let captured = remaining.min(count as u64) as usize;
|
||||||
|
if captured > 0 {
|
||||||
|
let offset = *delivered;
|
||||||
|
decoder.read_offset += captured as u64;
|
||||||
|
decoder.pending.extend_from_slice(&buffer[..captured]);
|
||||||
|
publish_decoded_output(decoder, telemetry, command_id, stream, false);
|
||||||
|
permit
|
||||||
|
.expect("reserved while budget remains")
|
||||||
|
.send(Message::Bytes(crate::CommandByteChunk {
|
||||||
|
sequence,
|
||||||
|
stream,
|
||||||
|
offset,
|
||||||
|
bytes: buffer[..captured].to_vec(),
|
||||||
|
}));
|
||||||
|
*delivered += captured as u64;
|
||||||
|
sequence += captured as u64;
|
||||||
|
remaining -= captured as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
cwd: PathBuf,
|
||||||
|
request: CommandRequest,
|
||||||
|
command_id: &str,
|
||||||
|
telemetry: &CommandTelemetry,
|
||||||
|
environment: BTreeMap<String, String>,
|
||||||
|
mut cancel: watch::Receiver<bool>,
|
||||||
|
sender: &mpsc::Sender<Message>,
|
||||||
|
) -> crate::CommandTerminal {
|
||||||
|
telemetry.started(command_id, request.tool_call_id);
|
||||||
|
let mut command = Command::new("bash");
|
||||||
|
command
|
||||||
|
.arg("-c")
|
||||||
|
.arg(request.command)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.envs(environment)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
command.as_std_mut().process_group(0);
|
||||||
|
let mut accounting = ByteAccounting::default();
|
||||||
|
let Ok(mut child) = command.spawn() else {
|
||||||
|
return accounting.terminal(CommandStatus::Failed, None);
|
||||||
|
};
|
||||||
|
let mut process_group = CommandProcessGroupGuard::new(child.id());
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
|
let (status, exit_code) = {
|
||||||
|
let output = pump(
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
sender,
|
||||||
|
telemetry,
|
||||||
|
command_id,
|
||||||
|
request.transport_byte_budget,
|
||||||
|
&mut accounting,
|
||||||
|
);
|
||||||
|
tokio::pin!(output);
|
||||||
|
let mut drained = false;
|
||||||
|
let timeout = tokio::time::sleep(Duration::from_secs(request.timeout_secs.max(1)));
|
||||||
|
tokio::pin!(timeout);
|
||||||
|
let terminal = loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut output, if !drained => {
|
||||||
|
drained = true;
|
||||||
|
if result.is_err() || sender.is_closed() {
|
||||||
|
process_group.terminate();
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let _ = child.wait().await;
|
||||||
|
break (CommandStatus::Failed, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit = child.wait() => {
|
||||||
|
process_group.terminate();
|
||||||
|
break match exit {
|
||||||
|
Ok(exit) => (if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, exit.code()),
|
||||||
|
Err(_) => (CommandStatus::Failed, None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ = &mut timeout => {
|
||||||
|
process_group.terminate();
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let code = child.wait().await.ok().and_then(|exit| exit.code());
|
||||||
|
break (CommandStatus::TimedOut, code);
|
||||||
|
}
|
||||||
|
_ = sender.closed() => {
|
||||||
|
process_group.terminate();
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let _ = child.wait().await;
|
||||||
|
break (CommandStatus::Cancelled, None);
|
||||||
|
}
|
||||||
|
changed = cancel.changed() => {
|
||||||
|
if changed.is_err() || *cancel.borrow() {
|
||||||
|
process_group.terminate();
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let code = child.wait().await.ok().and_then(|exit| exit.code());
|
||||||
|
break (CommandStatus::Cancelled, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Even after kill, preserve the bounded pipe tail before publishing terminal.
|
||||||
|
if !drained {
|
||||||
|
let _ = output.await;
|
||||||
|
}
|
||||||
|
terminal
|
||||||
|
};
|
||||||
|
accounting.terminal(status, exit_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
//! Process/pipe integration through the production local provider. Temporary roots
|
||||||
|
//! and provider tasks are RAII-owned; byte pulls and bounded timeouts synchronize tests.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
session: LocalWorkdirSession,
|
||||||
|
_root: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let session = LocalWorkdirSession::new(
|
||||||
|
Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
_root: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self, command: &str, timeout_secs: u64) -> CommandHandle {
|
||||||
|
self.start_budget(command, timeout_secs, crate::MAX_COMMAND_TRANSPORT_BYTES)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(command: &str, timeout_secs: u64, budget: u64) -> CommandRequest {
|
||||||
|
CommandRequest {
|
||||||
|
transport_byte_budget: budget,
|
||||||
|
command: command.into(),
|
||||||
|
timeout_secs,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_budget(&self, command: &str, timeout_secs: u64, budget: u64) -> CommandHandle {
|
||||||
|
self.session
|
||||||
|
.start_command(Self::request(command, timeout_secs, budget))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page(&self, handle: &CommandHandle, cursor: u64, limit: usize) -> CommandOutput {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
self.session.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
wait: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("bounded command pull")
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect(
|
||||||
|
&self,
|
||||||
|
handle: &CommandHandle,
|
||||||
|
mut cursor: u64,
|
||||||
|
) -> (Vec<crate::CommandByteChunk>, crate::CommandTerminal) {
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
loop {
|
||||||
|
let page = self
|
||||||
|
.page(handle, cursor, crate::MAX_COMMAND_READ_BYTES)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
page.chunks
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| chunk.bytes.len())
|
||||||
|
.sum::<usize>()
|
||||||
|
<= crate::MAX_COMMAND_READ_BYTES
|
||||||
|
);
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
chunks.extend(page.chunks);
|
||||||
|
assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::<usize>() <= 8 * 1024 * 1024);
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
return (chunks, terminal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec<u8> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for chunk in chunks.iter().filter(|chunk| chunk.stream == stream) {
|
||||||
|
assert_eq!(chunk.offset, bytes.len() as u64);
|
||||||
|
bytes.extend_from_slice(&chunk.bytes);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn high_single_line_output_survives_absent_and_lagged_console_subscribers() {
|
||||||
|
for lagged in [false, true] {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let _lagged_receiver = lagged.then(|| harness.session.subscribe_command_events().unwrap());
|
||||||
|
let handle = harness.start("head -c 3145728 /dev/zero | tr '\\0' x; head -c 65536 /dev/zero | tr '\\0' e >&2; exit 7", 20).await;
|
||||||
|
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Failed);
|
||||||
|
assert_eq!(terminal.exit_code, Some(7));
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stdout),
|
||||||
|
vec![b'x'; 3145728]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stderr),
|
||||||
|
vec![b'e'; 65536]
|
||||||
|
);
|
||||||
|
let mut cursor = 0;
|
||||||
|
for chunk in chunks {
|
||||||
|
assert_eq!(chunk.sequence, cursor);
|
||||||
|
cursor += chunk.bytes.len() as u64;
|
||||||
|
}
|
||||||
|
harness.session.close().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_byte_pages_preserve_raw_utf8_invalid_bytes_and_replay_until_acknowledged() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let handle = harness
|
||||||
|
.start(
|
||||||
|
"printf '\\342\\202\\254\\377\\000'; printf '\\346\\227\\245' >&2",
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
let mut cursor = 0;
|
||||||
|
loop {
|
||||||
|
let page = harness.page(&handle, cursor, 1).await;
|
||||||
|
assert_eq!(harness.page(&handle, cursor, 1).await, page);
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
chunks.extend(page.chunks);
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stdout),
|
||||||
|
b"\xe2\x82\xac\xff\0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stderr),
|
||||||
|
b"\xe6\x97\xa5"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1,
|
||||||
|
wait: false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_while_backpressured_drains_bytes_before_terminal_fence() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let handle = harness.start("printf ready; exec yes x", 30).await;
|
||||||
|
let first = harness.page(&handle, 0, 1).await;
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.cancel_command(handle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (mut chunks, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||||
|
let mut all = first.chunks;
|
||||||
|
all.append(&mut chunks);
|
||||||
|
assert!(stream_bytes(&all, CommandStream::Stdout).starts_with(b"ready"));
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Cancelled);
|
||||||
|
let final_page = harness
|
||||||
|
.page(
|
||||||
|
&handle,
|
||||||
|
all.iter().map(|chunk| chunk.bytes.len() as u64).sum(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(final_page.chunks.is_empty());
|
||||||
|
assert_eq!(final_page.terminal, Some(terminal));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_and_session_close_finalize_without_an_output_subscriber() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let handle = harness
|
||||||
|
.start("printf ready; exec tail -f /dev/null", 1)
|
||||||
|
.await;
|
||||||
|
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||||
|
assert_eq!(stream_bytes(&chunks, CommandStream::Stdout), b"ready");
|
||||||
|
assert_eq!(terminal.status, CommandStatus::TimedOut);
|
||||||
|
let handle = harness.start("exec yes x", 30).await;
|
||||||
|
// Receiving one byte establishes actual process output, without a timing sleep.
|
||||||
|
harness.page(&handle, 0, 1).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), harness.session.close())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(harness.session.command_status(handle).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interleaved_streams_keep_independent_offsets_and_bounded_read_validation() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let handle = harness
|
||||||
|
.start(
|
||||||
|
"for ((i=0;i<100;i++)); do printf 'out%s;' \"$i\"; printf 'err%s;' \"$i\" >&2; done",
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for limit in [0, crate::MAX_COMMAND_READ_BYTES + 1] {
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor: 0,
|
||||||
|
limit,
|
||||||
|
wait: false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||||
|
let expected = |prefix| {
|
||||||
|
(0..100)
|
||||||
|
.map(|i| format!("{prefix}{i};"))
|
||||||
|
.collect::<String>()
|
||||||
|
.into_bytes()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stdout),
|
||||||
|
expected("out")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stream_bytes(&chunks, CommandStream::Stderr),
|
||||||
|
expected("err")
|
||||||
|
);
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn byte_budget_zero_exact_partial_and_shared_keep_seen_and_delivered_distinct() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
for (command, budget, stdout_seen, stderr_seen, exit_code) in [
|
||||||
|
("printf abc; printf defg >&2", 0, 3, 4, 0),
|
||||||
|
("printf abc", 3, 3, 0, 0),
|
||||||
|
("printf abcdefg", 3, 7, 0, 0),
|
||||||
|
("printf abcdefg; exit 7", 3, 7, 0, 7),
|
||||||
|
(
|
||||||
|
"head -c 32768 /dev/zero; head -c 32768 /dev/zero >&2",
|
||||||
|
12345,
|
||||||
|
32768,
|
||||||
|
32768,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let handle = harness.start_budget(command, 5, budget).await;
|
||||||
|
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||||
|
let stdout = stream_bytes(&chunks, CommandStream::Stdout);
|
||||||
|
let stderr = stream_bytes(&chunks, CommandStream::Stderr);
|
||||||
|
let mut cursor = 0;
|
||||||
|
for chunk in &chunks {
|
||||||
|
assert_eq!(chunk.sequence, cursor);
|
||||||
|
cursor += chunk.bytes.len() as u64;
|
||||||
|
}
|
||||||
|
assert_eq!(cursor, budget.min(stdout_seen + stderr_seen));
|
||||||
|
assert_eq!(terminal.stdout_bytes, stdout_seen);
|
||||||
|
assert_eq!(terminal.stderr_bytes, stderr_seen);
|
||||||
|
assert_eq!(terminal.stdout_delivered_bytes, stdout.len() as u64);
|
||||||
|
assert_eq!(terminal.stderr_delivered_bytes, stderr.len() as u64);
|
||||||
|
assert_eq!(terminal.exit_code, Some(exit_code));
|
||||||
|
assert_eq!(
|
||||||
|
terminal.status,
|
||||||
|
if exit_code == 0 {
|
||||||
|
CommandStatus::Completed
|
||||||
|
} else {
|
||||||
|
CommandStatus::Failed
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.truncation_reason,
|
||||||
|
(cursor < stdout_seen + stderr_seen)
|
||||||
|
.then_some(crate::CommandOutputTruncation::ByteBudget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exhausted_budget_finishes_without_receiver_and_keeps_timeout_cancel_distinct() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
for budget in [0, 1, (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64] {
|
||||||
|
let handle = harness
|
||||||
|
.start_budget("head -c 1048576 /dev/zero; printf error >&2", 5, budget)
|
||||||
|
.await;
|
||||||
|
let mut status = harness.session.inner.commands.lock().await[&handle.0]
|
||||||
|
.status
|
||||||
|
.clone();
|
||||||
|
let terminal = tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
status.wait_for(|value| value.is_some()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||||
|
assert_eq!(terminal.stdout_bytes, 1048576);
|
||||||
|
assert_eq!(terminal.stderr_bytes, 5);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.stdout_delivered_bytes + terminal.stderr_delivered_bytes,
|
||||||
|
budget
|
||||||
|
);
|
||||||
|
// Full capture queue cannot retain the process task or its resources.
|
||||||
|
{
|
||||||
|
let mut commands = harness.session.inner.commands.lock().await;
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
&mut commands.get_mut(&handle.0).unwrap().task,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
// Only now acknowledge the captured queue and terminal fence.
|
||||||
|
let (_, delivered_terminal) = harness.collect(&handle, 0).await;
|
||||||
|
assert_eq!(delivered_terminal, terminal);
|
||||||
|
}
|
||||||
|
for (cancel, expected) in [
|
||||||
|
(false, CommandStatus::TimedOut),
|
||||||
|
(true, CommandStatus::Cancelled),
|
||||||
|
] {
|
||||||
|
let handle = harness
|
||||||
|
.start_budget(
|
||||||
|
"printf ready-extra; head -c 32768 /dev/zero; exec tail -f /dev/null",
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let first = harness.page(&handle, 0, 5).await;
|
||||||
|
assert_eq!(first.next_cursor, 5);
|
||||||
|
if cancel {
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.cancel_command(handle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let (rest, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||||
|
assert!(rest.is_empty());
|
||||||
|
assert_eq!(terminal.status, expected);
|
||||||
|
assert_eq!(terminal.stdout_delivered_bytes, 5);
|
||||||
|
assert!(terminal.stdout_bytes >= 11);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.truncation_reason,
|
||||||
|
Some(crate::CommandOutputTruncation::ByteBudget)
|
||||||
|
);
|
||||||
|
if !cancel {
|
||||||
|
assert_eq!(terminal.stdout_bytes, 32779);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.truncation_reason,
|
||||||
|
Some(crate::CommandOutputTruncation::ByteBudget)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn budget_replay_preserves_unique_bytes_and_rejects_future_and_old_cursors() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
let handle = harness.start_budget("printf abcdefghij", 5, 7).await;
|
||||||
|
let first = harness.page(&handle, 0, 2).await;
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(harness.page(&handle, 0, 2).await, first);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor: 8,
|
||||||
|
limit: 2,
|
||||||
|
wait: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let (rest, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||||
|
let mut all = first.chunks;
|
||||||
|
all.extend(rest);
|
||||||
|
assert_eq!(stream_bytes(&all, CommandStream::Stdout), b"abcdefg");
|
||||||
|
assert_eq!(terminal.stdout_bytes, 10);
|
||||||
|
assert_eq!(terminal.stdout_delivered_bytes, 7);
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 2,
|
||||||
|
wait: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_budget_is_rejected_before_process_start() {
|
||||||
|
let harness = Harness::new();
|
||||||
|
assert!(matches!(
|
||||||
|
harness
|
||||||
|
.session
|
||||||
|
.start_command(Harness::request(
|
||||||
|
"touch must-not-exist",
|
||||||
|
5,
|
||||||
|
crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||||
|
))
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::InvalidArgument(_))
|
||||||
|
));
|
||||||
|
assert!(!harness._root.path().join("must-not-exist").exists());
|
||||||
|
assert!(harness.session.inner.commands.lock().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn slow_receiver_backpressures_before_quota_but_full_queue_does_not_block_discard() {
|
||||||
|
use std::future::Future;
|
||||||
|
use std::task::Poll;
|
||||||
|
let telemetry = CommandTelemetry::new();
|
||||||
|
let input = vec![b'x'; 1024 * 1024];
|
||||||
|
for budget in [
|
||||||
|
crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
(QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64,
|
||||||
|
] {
|
||||||
|
let (sender, mut receiver) = mpsc::channel(QUEUE_CHUNKS);
|
||||||
|
let mut accounting = ByteAccounting::default();
|
||||||
|
{
|
||||||
|
let output = pump(
|
||||||
|
input.as_slice(),
|
||||||
|
&b""[..],
|
||||||
|
&sender,
|
||||||
|
&telemetry,
|
||||||
|
"test",
|
||||||
|
budget,
|
||||||
|
&mut accounting,
|
||||||
|
);
|
||||||
|
tokio::pin!(output);
|
||||||
|
let polled = std::future::poll_fn(|cx| Poll::Ready(output.as_mut().poll(cx))).await;
|
||||||
|
if budget == crate::MAX_COMMAND_TRANSPORT_BYTES {
|
||||||
|
assert!(polled.is_pending(), "a slow receiver must stop pipe reads");
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
matches!(polled, Poll::Ready(Ok(()))),
|
||||||
|
"discard must not reserve queue capacity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(receiver.len(), QUEUE_CHUNKS);
|
||||||
|
assert_eq!(
|
||||||
|
accounting.stdout_delivered,
|
||||||
|
(QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
accounting.stdout,
|
||||||
|
if budget == crate::MAX_COMMAND_TRANSPORT_BYTES {
|
||||||
|
accounting.stdout_delivered
|
||||||
|
} else {
|
||||||
|
input.len() as u64
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let mut queued_bytes = 0;
|
||||||
|
while let Ok(Message::Bytes(chunk)) = receiver.try_recv() {
|
||||||
|
queued_bytes += chunk.bytes.len();
|
||||||
|
}
|
||||||
|
assert_eq!(queued_bytes, QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-19
@@ -1,5 +1,3 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct CommandHandle(pub String);
|
pub struct CommandHandle(pub String);
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CommandRequest {
|
pub struct CommandRequest {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
/// Aggregate unique-byte capture allowance, independent of consumer storage.
|
||||||
pub output_limit: usize,
|
/// Providers drain and count excess bytes without delivering their payload.
|
||||||
|
#[schemars(range(min = 0, max = 67_108_864_u64))]
|
||||||
|
pub transport_byte_budget: u64,
|
||||||
/// Workdir-relative command directory. Providers validate it against the
|
/// Workdir-relative command directory. Providers validate it against the
|
||||||
/// active session before process start.
|
/// active session before process start.
|
||||||
pub cwd: fs_operation::FsPath,
|
pub cwd: fs_operation::FsPath,
|
||||||
/// Provider-local directory where complete output is retained when the
|
|
||||||
/// inline result exceeds `output_limit`.
|
|
||||||
pub spill_dir: Option<PathBuf>,
|
|
||||||
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
||||||
/// user-facing command telemetry can update the corresponding Console row
|
/// user-facing command telemetry can update the corresponding Console row
|
||||||
/// without exposing provider/session handles.
|
/// without exposing provider/session handles.
|
||||||
@@ -27,12 +25,23 @@ pub struct CommandRequest {
|
|||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CommandRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), crate::WorkdirError> {
|
||||||
|
if self.transport_byte_budget > MAX_COMMAND_TRANSPORT_BYTES {
|
||||||
|
return Err(crate::WorkdirError::InvalidArgument(
|
||||||
|
"command transport byte budget exceeds provider maximum".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct CommandOutputRequest {
|
pub struct CommandOutputRequest {
|
||||||
pub handle: CommandHandle,
|
pub handle: CommandHandle,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 67_108_864_u64))]
|
||||||
pub cursor: usize,
|
pub cursor: u64,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 1, max = 65_536_usize))]
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
pub wait: bool,
|
pub wait: bool,
|
||||||
}
|
}
|
||||||
@@ -107,16 +116,150 @@ pub enum CommandEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sequence is the merged transport byte position. Offset addresses raw bytes
|
||||||
|
/// within the named stream; neither ordering nor offsets depend on UTF-8 decoding.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct CommandOutput {
|
pub struct CommandByteChunk {
|
||||||
|
pub sequence: u64,
|
||||||
|
pub stream: CommandStream,
|
||||||
|
pub offset: u64,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CommandOutputTruncation {
|
||||||
|
ByteBudget,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw totals include discarded pipe bytes. Delivered totals count only prefixes
|
||||||
|
/// admitted to the transport queue, not page reads or retries by a consumer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct CommandTerminal {
|
||||||
pub status: CommandStatus,
|
pub status: CommandStatus,
|
||||||
#[schemars(range(min = -2147483648_i32, max = 2147483647_i32))]
|
|
||||||
pub exit_code: Option<i32>,
|
pub exit_code: Option<i32>,
|
||||||
pub timed_out: bool,
|
pub stdout_bytes: u64,
|
||||||
pub content: String,
|
pub stderr_bytes: u64,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
pub stdout_delivered_bytes: u64,
|
||||||
pub next_cursor: Option<usize>,
|
pub stderr_delivered_bytes: u64,
|
||||||
pub truncated: bool,
|
pub truncation_reason: Option<CommandOutputTruncation>,
|
||||||
/// Complete output retained by the provider when `truncated` is true.
|
}
|
||||||
pub output_path: Option<PathBuf>,
|
|
||||||
|
/// Sending next_cursor acknowledges this page. Only the current page may be
|
||||||
|
/// retried; acknowledged bytes expire. Terminal fences all captured bytes,
|
||||||
|
/// including on timeout/cancel. Consumers must pull while the process runs.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CommandOutput {
|
||||||
|
pub chunks: Vec<CommandByteChunk>,
|
||||||
|
pub next_cursor: u64,
|
||||||
|
pub terminal: Option<CommandTerminal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandOutput {
|
||||||
|
pub(crate) fn transport_bounds_valid(&self) -> bool {
|
||||||
|
let mut end = None;
|
||||||
|
let mut stream_ends = [None, None];
|
||||||
|
for chunk in &self.chunks {
|
||||||
|
let stream_index = match chunk.stream {
|
||||||
|
CommandStream::Stdout => 0,
|
||||||
|
CommandStream::Stderr => 1,
|
||||||
|
};
|
||||||
|
if chunk.bytes.is_empty()
|
||||||
|
|| chunk.offset > chunk.sequence
|
||||||
|
|| stream_ends[stream_index].is_some_and(|end| chunk.offset != end)
|
||||||
|
|| end.is_some_and(|end| chunk.sequence != end)
|
||||||
|
|| chunk
|
||||||
|
.sequence
|
||||||
|
.checked_add(chunk.bytes.len() as u64)
|
||||||
|
.is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES)
|
||||||
|
|| chunk
|
||||||
|
.offset
|
||||||
|
.checked_add(chunk.bytes.len() as u64)
|
||||||
|
.is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
end = chunk.sequence.checked_add(chunk.bytes.len() as u64);
|
||||||
|
stream_ends[stream_index] = chunk.offset.checked_add(chunk.bytes.len() as u64);
|
||||||
|
}
|
||||||
|
self.next_cursor <= MAX_COMMAND_TRANSPORT_BYTES
|
||||||
|
&& end.is_none_or(|end| end == self.next_cursor)
|
||||||
|
&& self.terminal.as_ref().is_none_or(|terminal| {
|
||||||
|
let truncated = terminal.stdout_bytes > terminal.stdout_delivered_bytes
|
||||||
|
|| terminal.stderr_bytes > terminal.stderr_delivered_bytes;
|
||||||
|
stream_ends[0].is_none_or(|end| end == terminal.stdout_delivered_bytes)
|
||||||
|
&& stream_ends[1].is_none_or(|end| end == terminal.stderr_delivered_bytes)
|
||||||
|
&& terminal.status != CommandStatus::Running
|
||||||
|
&& terminal.stdout_delivered_bytes <= terminal.stdout_bytes
|
||||||
|
&& terminal.stderr_delivered_bytes <= terminal.stderr_bytes
|
||||||
|
&& terminal
|
||||||
|
.stdout_delivered_bytes
|
||||||
|
.checked_add(terminal.stderr_delivered_bytes)
|
||||||
|
== Some(self.next_cursor)
|
||||||
|
&& terminal.truncation_reason
|
||||||
|
== truncated.then_some(CommandOutputTruncation::ByteBudget)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MAX_COMMAND_TRANSPORT_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub const MAX_COMMAND_READ_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_wire_rejects_legacy_output_host_paths() {
|
||||||
|
let command = serde_json::json!({
|
||||||
|
"command": "printf bytes", "timeout_secs": 1, "cwd": "", "transport_byte_budget": 0
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<CommandRequest>(command.clone()).is_ok());
|
||||||
|
for field in ["spill_dir", "output_limit"] {
|
||||||
|
let mut legacy = command.clone();
|
||||||
|
legacy[field] = serde_json::json!("/host/private");
|
||||||
|
assert!(serde_json::from_value::<CommandRequest>(legacy).is_err());
|
||||||
|
}
|
||||||
|
let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"});
|
||||||
|
assert!(serde_json::from_value::<CommandOutput>(output).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_budget_wire_requires_field_and_checks_provider_hard_bound() {
|
||||||
|
let wire = serde_json::json!({"command": "true", "timeout_secs": 1, "cwd": ""});
|
||||||
|
assert!(serde_json::from_value::<CommandRequest>(wire.clone()).is_err());
|
||||||
|
for budget in [
|
||||||
|
0,
|
||||||
|
MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||||
|
] {
|
||||||
|
let mut value = wire.clone();
|
||||||
|
value["transport_byte_budget"] = serde_json::json!(budget);
|
||||||
|
let request: CommandRequest = serde_json::from_value(value).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
request.validate().is_ok(),
|
||||||
|
budget <= MAX_COMMAND_TRANSPORT_BYTES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut negative = wire;
|
||||||
|
negative["transport_byte_budget"] = serde_json::json!(-1);
|
||||||
|
assert!(serde_json::from_value::<CommandRequest>(negative).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(CommandOutputTruncation::ByteBudget).unwrap(),
|
||||||
|
"byte_budget"
|
||||||
|
);
|
||||||
|
let schema = serde_json::to_value(schemars::schema_for!(CommandRequest)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
schema["properties"]["transport_byte_budget"]["maximum"],
|
||||||
|
MAX_COMMAND_TRANSPORT_BYTES
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
schema["required"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&serde_json::json!("transport_byte_budget"))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
let handle = request.handle.clone();
|
let handle = request.handle.clone();
|
||||||
let output = self.state.session.command_output(request).await?;
|
let output = self.state.session.command_output(request).await?;
|
||||||
self.state.mark_terminal(&handle, output.status);
|
if let Some(terminal) = &output.terminal {
|
||||||
|
self.state.mark_terminal(&handle, terminal.status);
|
||||||
|
}
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,11 +791,12 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let handle = routed
|
let handle = routed
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf done".to_string(),
|
command: "printf done".to_string(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: crate::WorkdirPath::root(),
|
cwd: crate::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -823,11 +826,12 @@ mod tests {
|
|||||||
let routed = router.attach(alias, Arc::clone(&underlying)).unwrap();
|
let routed = router.attach(alias, Arc::clone(&underlying)).unwrap();
|
||||||
let handle = routed
|
let handle = routed
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30".to_string(),
|
command: "sleep 30".to_string(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: crate::WorkdirPath::root(),
|
cwd: crate::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -850,11 +854,12 @@ mod tests {
|
|||||||
let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap();
|
let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap();
|
||||||
let handle = routed
|
let handle = routed
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30".to_string(),
|
command: "sleep 30".to_string(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: crate::WorkdirPath::root(),
|
cwd: crate::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
+291
-42
@@ -10,6 +10,9 @@
|
|||||||
//! provider-side before name enumeration, descent, or content reads. Returned coordinates are not
|
//! provider-side before name enumeration, descent, or content reads. Returned coordinates are not
|
||||||
//! prefixed with cwd again.
|
//! prefixed with cwd again.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::command_test_support::CollectCommand as _;
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -124,6 +127,7 @@ impl WorkdirToolBroker {
|
|||||||
next_lease_id: AtomicU64::new(1),
|
next_lease_id: AtomicU64::new(1),
|
||||||
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
command_reads: Mutex::new(HashMap::new()),
|
||||||
pending_command_events: Arc::new(Mutex::new(HashMap::new())),
|
pending_command_events: Arc::new(Mutex::new(HashMap::new())),
|
||||||
starting_tool_calls: Arc::new(Mutex::new(HashSet::new())),
|
starting_tool_calls: Arc::new(Mutex::new(HashSet::new())),
|
||||||
forwarded_starts: Arc::new(Mutex::new(HashSet::new())),
|
forwarded_starts: Arc::new(Mutex::new(HashSet::new())),
|
||||||
@@ -230,25 +234,48 @@ impl WorkdirScopeLease {
|
|||||||
.source
|
.source
|
||||||
.cancel_command(handle.clone())
|
.cancel_command(handle.clone())
|
||||||
.await;
|
.await;
|
||||||
let terminal = self
|
let read_cursor = self
|
||||||
.broker
|
.broker
|
||||||
.authority
|
.authority
|
||||||
.source
|
.command_reads
|
||||||
.command_output(CommandOutputRequest {
|
.lock()
|
||||||
handle,
|
.expect("command cursor mutex poisoned")
|
||||||
cursor: 0,
|
.get(&command_id)
|
||||||
limit: 1,
|
.cloned()
|
||||||
wait: true,
|
.expect("owned command cursor");
|
||||||
})
|
let mut read_cursor = read_cursor.lock().await;
|
||||||
.await;
|
let mut cursor = *read_cursor;
|
||||||
|
let terminal = loop {
|
||||||
|
let output = self
|
||||||
|
.broker
|
||||||
|
.authority
|
||||||
|
.source
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: crate::MAX_COMMAND_READ_BYTES,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match output {
|
||||||
|
Ok(output) => {
|
||||||
|
cursor = output.next_cursor;
|
||||||
|
*read_cursor = cursor;
|
||||||
|
if let Some(terminal) = output.terminal {
|
||||||
|
break Ok(terminal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => break Err(error),
|
||||||
|
}
|
||||||
|
};
|
||||||
match (cancel, terminal) {
|
match (cancel, terminal) {
|
||||||
(_, Ok(output)) => {
|
(_, Ok(output)) => {
|
||||||
self.broker.authority.publish_terminal_if_missing(
|
self.broker.authority.publish_terminal_if_missing(
|
||||||
&command_id,
|
&command_id,
|
||||||
output.status,
|
output.status,
|
||||||
output.exit_code,
|
output.exit_code,
|
||||||
output.next_cursor.unwrap_or(output.content.len()) as u64,
|
cursor,
|
||||||
&output.content,
|
"",
|
||||||
);
|
);
|
||||||
self.broker
|
self.broker
|
||||||
.authority
|
.authority
|
||||||
@@ -582,7 +609,12 @@ struct ScopedWorkdirSession {
|
|||||||
scope_lock: tokio::sync::Mutex<()>,
|
scope_lock: tokio::sync::Mutex<()>,
|
||||||
next_lease_id: AtomicU64,
|
next_lease_id: AtomicU64,
|
||||||
close_lock: Arc<tokio::sync::Mutex<()>>,
|
close_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
|
// Live cleanup responsibility ends at terminal delivery. Read ownership is
|
||||||
|
// retained separately so a lost terminal response can still be replayed.
|
||||||
owned_commands: Arc<Mutex<HashSet<String>>>,
|
owned_commands: Arc<Mutex<HashSet<String>>>,
|
||||||
|
// Starting commands prunes completed entries at the bounded replay ceiling;
|
||||||
|
// current scope validity is still checked before every read or retry.
|
||||||
|
command_reads: Mutex<HashMap<String, Arc<tokio::sync::Mutex<u64>>>>,
|
||||||
pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>,
|
pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>,
|
||||||
starting_tool_calls: Arc<Mutex<HashSet<String>>>,
|
starting_tool_calls: Arc<Mutex<HashSet<String>>>,
|
||||||
forwarded_starts: Arc<Mutex<HashSet<String>>>,
|
forwarded_starts: Arc<Mutex<HashSet<String>>>,
|
||||||
@@ -700,6 +732,11 @@ impl ScopedWorkdirSession {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("scoped command set mutex poisoned")
|
.expect("scoped command set mutex poisoned")
|
||||||
.contains(&handle.0)
|
.contains(&handle.0)
|
||||||
|
|| self
|
||||||
|
.command_reads
|
||||||
|
.lock()
|
||||||
|
.expect("command cursor mutex poisoned")
|
||||||
|
.contains_key(&handle.0)
|
||||||
{
|
{
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession {
|
|||||||
next_lease_id: AtomicU64::new(1),
|
next_lease_id: AtomicU64::new(1),
|
||||||
close_lock: close_lock.clone(),
|
close_lock: close_lock.clone(),
|
||||||
owned_commands,
|
owned_commands,
|
||||||
|
command_reads: Mutex::new(HashMap::new()),
|
||||||
pending_command_events,
|
pending_command_events,
|
||||||
starting_tool_calls,
|
starting_tool_calls,
|
||||||
forwarded_starts,
|
forwarded_starts,
|
||||||
@@ -1410,6 +1448,16 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("scoped command set mutex poisoned");
|
.expect("scoped command set mutex poisoned");
|
||||||
owned.insert(handle.0.clone());
|
owned.insert(handle.0.clone());
|
||||||
|
{
|
||||||
|
let mut reads = self
|
||||||
|
.command_reads
|
||||||
|
.lock()
|
||||||
|
.expect("command cursor mutex poisoned");
|
||||||
|
if reads.len() >= 64 {
|
||||||
|
reads.retain(|id, _| owned.contains(id));
|
||||||
|
}
|
||||||
|
reads.insert(handle.0.clone(), Arc::new(tokio::sync::Mutex::new(0)));
|
||||||
|
}
|
||||||
if let Some(tool_call_id) = &tool_call_id {
|
if let Some(tool_call_id) = &tool_call_id {
|
||||||
self.starting_tool_calls
|
self.starting_tool_calls
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1460,14 +1508,28 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
self.ensure_owned_command(&request.handle)?;
|
self.ensure_owned_command(&request.handle)?;
|
||||||
let command_id = request.handle.0.clone();
|
let command_id = request.handle.0.clone();
|
||||||
|
let read_cursor = self
|
||||||
|
.command_reads
|
||||||
|
.lock()
|
||||||
|
.expect("command cursor mutex poisoned")
|
||||||
|
.get(&command_id)
|
||||||
|
.cloned();
|
||||||
|
let mut read_cursor = match &read_cursor {
|
||||||
|
Some(cursor) => Some(cursor.lock().await),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
self.ensure_owned_command(&request.handle)?;
|
||||||
let output = self.source.command_output(request).await?;
|
let output = self.source.command_output(request).await?;
|
||||||
if !matches!(output.status, CommandStatus::Running) {
|
if let Some(cursor) = &mut read_cursor {
|
||||||
|
**cursor = output.next_cursor;
|
||||||
|
}
|
||||||
|
if let Some(terminal) = &output.terminal {
|
||||||
self.publish_terminal_if_missing(
|
self.publish_terminal_if_missing(
|
||||||
&command_id,
|
&command_id,
|
||||||
output.status,
|
terminal.status,
|
||||||
output.exit_code,
|
terminal.exit_code,
|
||||||
output.next_cursor.unwrap_or(output.content.len()) as u64,
|
output.next_cursor,
|
||||||
&output.content,
|
"",
|
||||||
);
|
);
|
||||||
self.owned_commands
|
self.owned_commands
|
||||||
.lock()
|
.lock()
|
||||||
@@ -2083,20 +2145,21 @@ mod tests {
|
|||||||
session: &WorkdirSessionHandle,
|
session: &WorkdirSessionHandle,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
tool_call_id: impl Into<String>,
|
tool_call_id: impl Into<String>,
|
||||||
) -> CommandOutput {
|
) -> crate::command_test_support::CollectedCommand {
|
||||||
let handle = session
|
let handle = session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some(tool_call_id.into()),
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
session
|
session
|
||||||
.command_output(CommandOutputRequest {
|
.collect_command_output(CommandOutputRequest {
|
||||||
handle,
|
handle,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
limit: 1024,
|
limit: 1024,
|
||||||
@@ -2115,11 +2178,12 @@ mod tests {
|
|||||||
.expect("delegation wrapper must preserve command observation");
|
.expect("delegation wrapper must preserve command observation");
|
||||||
let handle = parent
|
let handle = parent
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf ready; sleep 0.2; printf done".into(),
|
command: "printf ready; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("tool-delegated".into()),
|
tool_call_id: Some("tool-delegated".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2142,7 +2206,7 @@ mod tests {
|
|||||||
assert_eq!(snapshots[0].stdout.content, "ready");
|
assert_eq!(snapshots[0].stdout.content, "ready");
|
||||||
|
|
||||||
let output = parent
|
let output = parent
|
||||||
.command_output(CommandOutputRequest {
|
.collect_command_output(CommandOutputRequest {
|
||||||
handle,
|
handle,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
limit: 1024,
|
limit: 1024,
|
||||||
@@ -2182,11 +2246,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let error = child
|
let error = child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "pwd".into(),
|
command: "pwd".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2214,11 +2279,12 @@ mod tests {
|
|||||||
|
|
||||||
let handle = first
|
let handle = first
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "pwd; sleep 0.2".into(),
|
command: "pwd; sleep 0.2".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("first-command".into()),
|
tool_call_id: Some("first-command".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2242,7 +2308,7 @@ mod tests {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let output = first
|
let output = first
|
||||||
.command_output(CommandOutputRequest {
|
.collect_command_output(CommandOutputRequest {
|
||||||
handle,
|
handle,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
limit: 4096,
|
limit: 4096,
|
||||||
@@ -2319,11 +2385,12 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child
|
child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf denied".into(),
|
command: "printf denied".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("read-only-command".into()),
|
tool_call_id: Some("read-only-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -2625,11 +2692,12 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child
|
child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf revoked".into(),
|
command: "printf revoked".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("revoked-child-command".into()),
|
tool_call_id: Some("revoked-child-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -2727,11 +2795,12 @@ mod tests {
|
|||||||
for index in 0..MAX_SCOPED_COMMANDS {
|
for index in 0..MAX_SCOPED_COMMANDS {
|
||||||
child
|
child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some(format!("command-{index}")),
|
tool_call_id: Some(format!("command-{index}")),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2740,11 +2809,12 @@ mod tests {
|
|||||||
|
|
||||||
let error = child
|
let error = child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("command-over-limit".into()),
|
tool_call_id: Some("command-over-limit".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2774,11 +2844,12 @@ mod tests {
|
|||||||
let command = tokio::spawn(async move {
|
let command = tokio::spawn(async move {
|
||||||
command_child
|
command_child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("racing-command".into()),
|
tool_call_id: Some("racing-command".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2820,11 +2891,12 @@ mod tests {
|
|||||||
let mut events = child.subscribe_command_events().unwrap();
|
let mut events = child.subscribe_command_events().unwrap();
|
||||||
let handle = child
|
let handle = child
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "sleep 30; printf leaked > marker".into(),
|
command: "sleep 30; printf leaked > marker".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("owned-command".into()),
|
tool_call_id: Some("owned-command".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -3015,11 +3087,12 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parent
|
parent
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "printf closed".into(),
|
command: "printf closed".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
cwd: FsPath::root(),
|
cwd: FsPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("closed-parent-command".into()),
|
tool_call_id: Some("closed-parent-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -3031,4 +3104,180 @@ mod tests {
|
|||||||
"child result after parent close: {child_result:?}"
|
"child result after parent close: {child_result:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_byte_cursor_survives_partial_ack_then_revocation_and_terminal_retry() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(root.path().join("child")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let child = parent
|
||||||
|
.scope(request("child", WorkdirToolScopePermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let tool = child.tool_session();
|
||||||
|
let handle = tool
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: "printf ready; exec yes x".into(),
|
||||||
|
timeout_secs: 10,
|
||||||
|
cwd: FsPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
for _ in 0..2 {
|
||||||
|
let page = tool
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: 1,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
}
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), child.close())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
tool.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor,
|
||||||
|
limit: 1,
|
||||||
|
wait: false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let handle = parent
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: "printf parent".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
cwd: FsPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
loop {
|
||||||
|
let request = CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
};
|
||||||
|
let page = parent.command_output(request.clone()).await.unwrap();
|
||||||
|
assert_eq!(parent.command_output(request).await.unwrap(), page);
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
if page.terminal.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_terminal_response_loss_replays_without_duplicate_capture_and_rejects_invalid_cursors()
|
||||||
|
{
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(root.path().join("child")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let child = parent
|
||||||
|
.scope(request("child", WorkdirToolScopePermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let tool = child.tool_session();
|
||||||
|
let handle = tool
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: "printf abcdef".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
cwd: FsPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
let mut captured = Vec::new();
|
||||||
|
let terminal_request = loop {
|
||||||
|
let request = CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: 2,
|
||||||
|
wait: true,
|
||||||
|
};
|
||||||
|
// Simulate losing each produced HTTP response, including terminal:
|
||||||
|
// append only the retried page, never both copies.
|
||||||
|
let lost = tool.command_output(request.clone()).await.unwrap();
|
||||||
|
let replay = tool.command_output(request.clone()).await.unwrap();
|
||||||
|
assert_eq!(replay, lost);
|
||||||
|
for chunk in &replay.chunks {
|
||||||
|
captured.extend_from_slice(&chunk.bytes);
|
||||||
|
}
|
||||||
|
cursor = replay.next_cursor;
|
||||||
|
if let Some(terminal) = replay.terminal {
|
||||||
|
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||||
|
break request;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(captured, b"abcdef");
|
||||||
|
assert!(
|
||||||
|
!child
|
||||||
|
.broker
|
||||||
|
.authority
|
||||||
|
.owned_commands
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&handle.0)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
child
|
||||||
|
.broker
|
||||||
|
.authority
|
||||||
|
.command_reads
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key(&handle.0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tool.command_status(handle.clone()).await.unwrap(),
|
||||||
|
CommandStatus::Completed
|
||||||
|
);
|
||||||
|
for invalid in [0, cursor + 1] {
|
||||||
|
assert!(
|
||||||
|
tool.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor: invalid,
|
||||||
|
limit: 2,
|
||||||
|
wait: false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Invalid cursor requests do not destroy the retained terminal page.
|
||||||
|
assert!(
|
||||||
|
tool.command_output(terminal_request)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.terminal
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
child.close().await.unwrap();
|
||||||
|
assert!(
|
||||||
|
tool.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor,
|
||||||
|
limit: 2,
|
||||||
|
wait: false
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bounded scoped terminal replay");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession {
|
|||||||
}
|
}
|
||||||
fn command() -> CommandRequest {
|
fn command() -> CommandRequest {
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "fake command, never executed".into(),
|
command: "fake command, never executed".into(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 100,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
|
|||||||
assert_reason(
|
assert_reason(
|
||||||
child
|
child
|
||||||
.start_command(workdir::CommandRequest {
|
.start_command(workdir::CommandRequest {
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
command: "must-not-run".into(),
|
command: "must-not-run".into(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1,
|
|
||||||
cwd: WorkdirPath::root(),
|
cwd: WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ server-api = { path = "../server-api" }
|
|||||||
workdir.workspace = true
|
workdir.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
serial_test = "3.4.0"
|
serial_test = "3.4.0"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation,
|
CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation,
|
||||||
http::{
|
http::{
|
||||||
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
|
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
|
||||||
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult,
|
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult,
|
||||||
@@ -914,13 +914,9 @@ async fn run_workdir_session_operation(
|
|||||||
{
|
{
|
||||||
Ok(result) => result?,
|
Ok(result) => result?,
|
||||||
Err(_) => CommandOutput {
|
Err(_) => CommandOutput {
|
||||||
exit_code: None,
|
chunks: Vec::new(),
|
||||||
timed_out: false,
|
next_cursor: cursor,
|
||||||
status: CommandStatus::Running,
|
terminal: None,
|
||||||
content: String::new(),
|
|
||||||
next_cursor: Some(cursor),
|
|
||||||
truncated: false,
|
|
||||||
output_path: None,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
WorkdirSessionOperationResult::CommandOutput(output)
|
WorkdirSessionOperationResult::CommandOutput(output)
|
||||||
@@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
mod command_transport_tests;
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
mod session_material_tests;
|
mod session_material_tests;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! Loopback integration: production RemoteWorkdirSession, signed HTTP middleware,
|
||||||
|
//! command operation handler, local process pipes, and JSON DTOs. Only opening the
|
||||||
|
//! pre-seeded session is a fixture; no Runtime Worker/materializer is claimed here.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
use workdir::http::{
|
||||||
|
RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture,
|
||||||
|
};
|
||||||
|
use workdir::{CommandOutputRequest, CommandRequest, CommandStream, WorkdirSession};
|
||||||
|
|
||||||
|
struct SignedAuthorization {
|
||||||
|
identity: RuntimeIdentityMaterial,
|
||||||
|
trust: WorkspaceIssuerTrustRecord,
|
||||||
|
}
|
||||||
|
impl std::fmt::Debug for SignedAuthorization {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("SignedAuthorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl WorkdirHttpAuthorization for SignedAuthorization {
|
||||||
|
fn bearer_token(
|
||||||
|
&self,
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> WorkdirHttpAuthorizationFuture {
|
||||||
|
let claims = signed_claims(&self.trust, &method, &path, &body);
|
||||||
|
let token =
|
||||||
|
issue_workspace_capability_token(&self.identity.signing_key().unwrap(), &claims)
|
||||||
|
.unwrap();
|
||||||
|
Box::pin(async move { Ok(token) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
server: tokio::task::JoinHandle<()>,
|
||||||
|
session: WorkdirSessionHandle,
|
||||||
|
_root: tempfile::TempDir,
|
||||||
|
remote: RemoteWorkdirSession,
|
||||||
|
client: reqwest::Client,
|
||||||
|
operation_url: url::Url,
|
||||||
|
authorization: Arc<SignedAuthorization>,
|
||||||
|
}
|
||||||
|
impl Drop for Harness {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.server.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Harness {
|
||||||
|
async fn raw_operation(&self, operation: serde_json::Value) -> reqwest::Response {
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({"operation": operation})).unwrap();
|
||||||
|
let token = self
|
||||||
|
.authorization
|
||||||
|
.bearer_token(
|
||||||
|
"POST".into(),
|
||||||
|
self.operation_url.path().into(),
|
||||||
|
body.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
self.client
|
||||||
|
.post(self.operation_url.clone())
|
||||||
|
.bearer_auth(token)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new() -> Self {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||||
|
Workdir::new("wd-transport"),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
));
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("transport-key").unwrap();
|
||||||
|
let trust = workspace_trust(&identity);
|
||||||
|
let state = RuntimeHttpState {
|
||||||
|
runtime: Runtime::new_memory(),
|
||||||
|
local_token: None,
|
||||||
|
workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))),
|
||||||
|
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
|
||||||
|
"session-transport".into(),
|
||||||
|
RuntimeHttpWorkdirSession {
|
||||||
|
owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"),
|
||||||
|
session: session.clone(),
|
||||||
|
},
|
||||||
|
)]))),
|
||||||
|
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/v1/working-directories/{id}/sessions",
|
||||||
|
post(|| async {
|
||||||
|
Json(OpenWorkdirSessionResponse {
|
||||||
|
session_id: WorkdirSessionId::new("session-transport").unwrap(),
|
||||||
|
workdir_id: Workdir::new("wd-transport").id().clone(),
|
||||||
|
capabilities: WorkdirSessionCapabilities::ALL,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/workdir-sessions/{session_id}/operations",
|
||||||
|
post(run_workdir_session_operation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/v1/workdir-sessions/{session_id}",
|
||||||
|
delete(close_workdir_session),
|
||||||
|
)
|
||||||
|
.with_state(state.clone())
|
||||||
|
.layer(middleware::from_fn_with_state(state, require_runtime_auth));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base: url::Url = format!("http://{}", listener.local_addr().unwrap())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
RUNTIME_WORKSPACE_SCOPE_HEADER,
|
||||||
|
"workspace-a".parse().unwrap(),
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.default_headers(headers)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let authorization = Arc::new(SignedAuthorization { identity, trust });
|
||||||
|
let operation_url = base
|
||||||
|
.join("/v1/workdir-sessions/session-transport/operations")
|
||||||
|
.unwrap();
|
||||||
|
let remote = RemoteWorkdirSession::open_with_authorization(
|
||||||
|
client.clone(),
|
||||||
|
base,
|
||||||
|
authorization.clone(),
|
||||||
|
Workdir::new("wd-transport").id().clone(),
|
||||||
|
OpenWorkdirSessionRequest::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
Self {
|
||||||
|
server,
|
||||||
|
session,
|
||||||
|
_root: root,
|
||||||
|
remote,
|
||||||
|
client,
|
||||||
|
operation_url,
|
||||||
|
authorization,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn real_http_command_pages_preserve_binary_streams_replay_and_nonzero_exit() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), async {
|
||||||
|
let harness = Harness::new().await;
|
||||||
|
let handle = harness.remote.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: "head -c 2097152 /dev/zero | tr '\\0' x; printf '\\342\\202\\254\\377\\000'; printf 'stderr\\346\\227\\245' >&2; exit 9".into(),
|
||||||
|
timeout_secs: 15, cwd: WorkdirPath::root(), tool_call_id: Some("http-tool-call".into()),
|
||||||
|
}).await.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
let mut stdout = Vec::new();
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
let terminal = loop {
|
||||||
|
let request = CommandOutputRequest { handle: handle.clone(), cursor, limit: 16384, wait: true };
|
||||||
|
let page = harness.remote.command_output(request.clone()).await.unwrap();
|
||||||
|
assert_eq!(harness.remote.command_output(request).await.unwrap(), page);
|
||||||
|
assert!(page.chunks.iter().map(|chunk| chunk.bytes.len()).sum::<usize>() <= 16384);
|
||||||
|
for chunk in page.chunks {
|
||||||
|
assert_eq!(chunk.sequence, cursor);
|
||||||
|
cursor += chunk.bytes.len() as u64;
|
||||||
|
let target = match chunk.stream { CommandStream::Stdout => &mut stdout, CommandStream::Stderr => &mut stderr };
|
||||||
|
assert_eq!(chunk.offset, target.len() as u64);
|
||||||
|
target.extend(chunk.bytes);
|
||||||
|
}
|
||||||
|
assert_eq!(cursor, page.next_cursor);
|
||||||
|
if let Some(terminal) = page.terminal { break terminal; }
|
||||||
|
};
|
||||||
|
let mut expected = vec![b'x'; 2097152];
|
||||||
|
expected.extend_from_slice(b"\xe2\x82\xac\xff\0");
|
||||||
|
assert_eq!(stdout, expected);
|
||||||
|
assert_eq!(stderr, b"stderr\xe6\x97\xa5");
|
||||||
|
assert_eq!(terminal.status, workdir::CommandStatus::Failed);
|
||||||
|
assert_eq!(terminal.exit_code, Some(9));
|
||||||
|
assert!(harness.remote.command_output(CommandOutputRequest { handle, cursor: 0, limit: 1, wait: false }).await.is_err());
|
||||||
|
harness.remote.close().await.unwrap();
|
||||||
|
assert!(harness.session.command_snapshot().is_empty());
|
||||||
|
}).await.expect("bounded HTTP command transport");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn real_http_cancel_drains_tail_before_terminal_and_timeout_is_distinct() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), async {
|
||||||
|
let harness = Harness::new().await;
|
||||||
|
for (command, timeout, cancel, expected) in [
|
||||||
|
(
|
||||||
|
"printf ready; exec yes x",
|
||||||
|
10,
|
||||||
|
true,
|
||||||
|
workdir::CommandStatus::Cancelled,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"printf ready; exec tail -f /dev/null",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
workdir::CommandStatus::TimedOut,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let handle = harness
|
||||||
|
.remote
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
|
command: command.into(),
|
||||||
|
timeout_secs: timeout,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
let first = harness
|
||||||
|
.remote
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: 5,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first
|
||||||
|
.chunks
|
||||||
|
.iter()
|
||||||
|
.flat_map(|chunk| chunk.bytes.iter().copied())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
b"ready"
|
||||||
|
);
|
||||||
|
cursor = first.next_cursor;
|
||||||
|
if cancel {
|
||||||
|
harness.remote.cancel_command(handle.clone()).await.unwrap();
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let page = harness
|
||||||
|
.remote
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
assert_eq!(terminal.status, expected);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
harness.remote.close().await.unwrap();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bounded cancellation and timeout over HTTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn real_http_command_budget_bounds_unique_payload_and_reports_larger_seen_totals() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), async {
|
||||||
|
let harness = Harness::new().await;
|
||||||
|
for budget in [0, 12345] {
|
||||||
|
let handle = harness
|
||||||
|
.remote
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "head -c 1048576 /dev/zero; head -c 65536 /dev/zero >&2; exit 9"
|
||||||
|
.into(),
|
||||||
|
timeout_secs: 10,
|
||||||
|
transport_byte_budget: budget,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut cursor = 0;
|
||||||
|
let mut delivered = [0_u64; 2];
|
||||||
|
let terminal = loop {
|
||||||
|
let request = CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor,
|
||||||
|
limit: 997,
|
||||||
|
wait: true,
|
||||||
|
};
|
||||||
|
let page = harness
|
||||||
|
.remote
|
||||||
|
.command_output(request.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(harness.remote.command_output(request).await.unwrap(), page);
|
||||||
|
for chunk in page.chunks {
|
||||||
|
assert_eq!(chunk.sequence, cursor);
|
||||||
|
cursor += chunk.bytes.len() as u64;
|
||||||
|
let index = match chunk.stream {
|
||||||
|
CommandStream::Stdout => 0,
|
||||||
|
CommandStream::Stderr => 1,
|
||||||
|
};
|
||||||
|
assert_eq!(chunk.offset, delivered[index]);
|
||||||
|
delivered[index] += chunk.bytes.len() as u64;
|
||||||
|
}
|
||||||
|
assert_eq!(cursor, page.next_cursor);
|
||||||
|
assert!(
|
||||||
|
cursor <= budget,
|
||||||
|
"RemoteWorkdirSession must not receive bytes beyond budget"
|
||||||
|
);
|
||||||
|
if let Some(terminal) = page.terminal {
|
||||||
|
break terminal;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(cursor, budget);
|
||||||
|
assert_eq!(terminal.stdout_bytes, 1048576);
|
||||||
|
assert_eq!(terminal.stderr_bytes, 65536);
|
||||||
|
assert_eq!(terminal.stdout_delivered_bytes, delivered[0]);
|
||||||
|
assert_eq!(terminal.stderr_delivered_bytes, delivered[1]);
|
||||||
|
assert_eq!(
|
||||||
|
terminal.truncation_reason,
|
||||||
|
Some(workdir::CommandOutputTruncation::ByteBudget)
|
||||||
|
);
|
||||||
|
assert_eq!(terminal.status, workdir::CommandStatus::Failed);
|
||||||
|
assert_eq!(terminal.exit_code, Some(9));
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.remote
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle: handle.clone(),
|
||||||
|
cursor: budget + 1,
|
||||||
|
limit: 1,
|
||||||
|
wait: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
if budget > 0 {
|
||||||
|
assert!(
|
||||||
|
harness
|
||||||
|
.remote
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1,
|
||||||
|
wait: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
harness.remote.close().await.unwrap();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bounded HTTP byte-budget transport");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn real_http_rejects_invalid_or_legacy_budget_requests_before_process_start() {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
let harness = Harness::new().await;
|
||||||
|
let request = CommandRequest {
|
||||||
|
command: "touch must-not-exist".into(),
|
||||||
|
timeout_secs: 1,
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||||
|
cwd: WorkdirPath::root(),
|
||||||
|
tool_call_id: None,
|
||||||
|
};
|
||||||
|
let oversize =
|
||||||
|
serde_json::to_value(WorkdirSessionOperation::CommandStart(request)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
harness.raw_operation(oversize.clone()).await.status(),
|
||||||
|
reqwest::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
let mut missing = oversize.clone();
|
||||||
|
// CommandStart is externally tagged by operation / request.
|
||||||
|
let object = missing.as_object_mut().unwrap();
|
||||||
|
let request = object.get_mut("request").unwrap().as_object_mut().unwrap();
|
||||||
|
request.remove("transport_byte_budget");
|
||||||
|
assert_eq!(
|
||||||
|
harness.raw_operation(missing).await.status(),
|
||||||
|
reqwest::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
let mut legacy = oversize;
|
||||||
|
let request = legacy["request"].as_object_mut().unwrap();
|
||||||
|
request.insert("transport_byte_budget".into(), serde_json::json!(0));
|
||||||
|
request.insert("spill_dir".into(), serde_json::json!("/private/host"));
|
||||||
|
assert_eq!(
|
||||||
|
harness.raw_operation(legacy).await.status(),
|
||||||
|
reqwest::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
assert!(!harness._root.path().join("must-not-exist").exists());
|
||||||
|
harness.remote.close().await.unwrap();
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bounded HTTP budget admission");
|
||||||
|
}
|
||||||
@@ -147,7 +147,6 @@ use worker::{
|
|||||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||||
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||||
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||||
bash_output_dir_for_worker_id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
#[cfg(feature = "ws-server")]
|
||||||
@@ -1090,29 +1089,24 @@ fn runtime_local_workdir_session(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
scope: manifest::SharedScope,
|
scope: manifest::SharedScope,
|
||||||
command_output_scope: manifest::SharedScope,
|
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
command_environment: std::collections::BTreeMap<String, String>,
|
command_environment: std::collections::BTreeMap<String, String>,
|
||||||
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
||||||
) -> WorkdirSessionHandle {
|
) -> WorkdirSessionHandle {
|
||||||
Arc::new(
|
Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
|
||||||
LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope(
|
Workdir::new(workdir_id),
|
||||||
Workdir::new(workdir_id),
|
root.to_path_buf(),
|
||||||
root.to_path_buf(),
|
cwd.to_path_buf(),
|
||||||
cwd.to_path_buf(),
|
scope,
|
||||||
scope,
|
capabilities,
|
||||||
command_output_scope,
|
command_environment,
|
||||||
capabilities,
|
resources,
|
||||||
command_environment,
|
))
|
||||||
resources,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_local_workdir_router(
|
fn runtime_local_workdir_router(
|
||||||
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||||
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
|
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
|
||||||
command_output_scope: manifest::SharedScope,
|
|
||||||
authorization_client: Option<Arc<dyn WorkspaceClient>>,
|
authorization_client: Option<Arc<dyn WorkspaceClient>>,
|
||||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
@@ -1127,7 +1121,6 @@ fn runtime_local_workdir_router(
|
|||||||
binding.root(),
|
binding.root(),
|
||||||
binding.cwd(),
|
binding.cwd(),
|
||||||
scope,
|
scope,
|
||||||
command_output_scope.clone(),
|
|
||||||
capabilities,
|
capabilities,
|
||||||
binding.command_environment(),
|
binding.command_environment(),
|
||||||
binding
|
binding
|
||||||
@@ -1153,7 +1146,6 @@ fn restored_workdir_router(
|
|||||||
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||||
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
|
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
|
||||||
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
||||||
scope: manifest::SharedScope,
|
|
||||||
workspace_client: Arc<dyn WorkspaceClient>,
|
workspace_client: Arc<dyn WorkspaceClient>,
|
||||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||||
let mut local_capabilities = local_attachments
|
let mut local_capabilities = local_attachments
|
||||||
@@ -1173,7 +1165,6 @@ fn restored_workdir_router(
|
|||||||
let router = runtime_local_workdir_router(
|
let router = runtime_local_workdir_router(
|
||||||
local_attachments,
|
local_attachments,
|
||||||
&local_capabilities,
|
&local_capabilities,
|
||||||
scope,
|
|
||||||
Some(workspace_client.clone()),
|
Some(workspace_client.clone()),
|
||||||
)?;
|
)?;
|
||||||
for attachment in logical_attachments {
|
for attachment in logical_attachments {
|
||||||
@@ -1529,7 +1520,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||||
|
|
||||||
let run_dir = self.worker_run_dir(&request.worker_ref)?;
|
let run_dir = self.worker_run_dir(&request.worker_ref)?;
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
|
||||||
let bootstrap = WorkerBootstrap::new(
|
let bootstrap = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
@@ -1538,7 +1528,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
bash_output_dir,
|
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
);
|
);
|
||||||
@@ -1583,7 +1572,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
worker.bind_workdir_sessions(runtime_local_workdir_router(
|
worker.bind_workdir_sessions(runtime_local_workdir_router(
|
||||||
&request.workdir_attachments,
|
&request.workdir_attachments,
|
||||||
&workdir_capabilities,
|
&workdir_capabilities,
|
||||||
worker.scope().clone(),
|
|
||||||
Some(worker.workspace_client_handle()),
|
Some(worker.workspace_client_handle()),
|
||||||
)?);
|
)?);
|
||||||
if let (Some(runtime_id), Some(workspace_id)) =
|
if let (Some(runtime_id), Some(workspace_id)) =
|
||||||
@@ -1851,7 +1839,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
&request.workdir_attachments,
|
&request.workdir_attachments,
|
||||||
&request.logical_workdir_attachments,
|
&request.logical_workdir_attachments,
|
||||||
&request.request.workdir_attachments,
|
&request.request.workdir_attachments,
|
||||||
worker.scope().clone(),
|
|
||||||
worker.workspace_client_handle(),
|
worker.workspace_client_handle(),
|
||||||
)?;
|
)?;
|
||||||
worker.bind_workdir_sessions(workdir_sessions);
|
worker.bind_workdir_sessions(workdir_sessions);
|
||||||
@@ -1888,13 +1875,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
|
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?;
|
let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?;
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
|
||||||
let cleanup_sessions = worker.workdir_sessions();
|
let cleanup_sessions = worker.workdir_sessions();
|
||||||
let started = PreparedWorker::new(
|
let started = PreparedWorker::new(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
bash_output_dir,
|
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
)
|
)
|
||||||
@@ -3768,7 +3753,6 @@ where
|
|||||||
working_directory_id,
|
working_directory_id,
|
||||||
binding.root(),
|
binding.root(),
|
||||||
binding.cwd(),
|
binding.cwd(),
|
||||||
scope.clone(),
|
|
||||||
scope,
|
scope,
|
||||||
WorkdirSessionCapabilities::ALL,
|
WorkdirSessionCapabilities::ALL,
|
||||||
binding.command_environment(),
|
binding.command_environment(),
|
||||||
@@ -6817,8 +6801,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
|
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap());
|
|
||||||
let client = WorkerWorkspaceContext::local_filesystem(Some(
|
let client = WorkerWorkspaceContext::local_filesystem(Some(
|
||||||
WorkspaceId::new("workspace-a").unwrap(),
|
WorkspaceId::new("workspace-a").unwrap(),
|
||||||
))
|
))
|
||||||
@@ -6833,7 +6815,6 @@ mod tests {
|
|||||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
}],
|
}],
|
||||||
&[],
|
&[],
|
||||||
scope,
|
|
||||||
client,
|
client,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -6878,7 +6859,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let alias = WorkdirAttachmentAlias::new("docs").unwrap();
|
let alias = WorkdirAttachmentAlias::new("docs").unwrap();
|
||||||
let workdir_id = binding.working_directory.id.clone();
|
let workdir_id = binding.working_directory.id.clone();
|
||||||
let command_output_scope = manifest::SharedScope::new(manifest::Scope::empty());
|
|
||||||
let attachments = BTreeMap::from([(alias.clone(), binding)]);
|
let attachments = BTreeMap::from([(alias.clone(), binding)]);
|
||||||
let logical = [LogicalWorkdirAttachment {
|
let logical = [LogicalWorkdirAttachment {
|
||||||
alias: alias.clone(),
|
alias: alias.clone(),
|
||||||
@@ -6890,9 +6870,7 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.client_handle();
|
.client_handle();
|
||||||
|
|
||||||
let router =
|
let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap();
|
||||||
restored_workdir_router(&attachments, &logical, &[], command_output_scope, client)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let session = router.session(&alias).unwrap();
|
let session = router.session(&alias).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -7579,14 +7557,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let bash_output_dir = self.runtime_base.join("bash-output");
|
let (handle, shutdown_rx) =
|
||||||
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed(
|
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
||||||
worker,
|
.await
|
||||||
&self.runtime_base,
|
.map_err(|err| err.to_string())?;
|
||||||
&bash_output_dir,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
handle,
|
handle,
|
||||||
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
||||||
@@ -8710,7 +8684,6 @@ mod tests {
|
|||||||
"working-directory-42",
|
"working-directory-42",
|
||||||
root.path(),
|
root.path(),
|
||||||
root.path(),
|
root.path(),
|
||||||
spawned_scope.clone(),
|
|
||||||
spawned_scope,
|
spawned_scope,
|
||||||
WorkdirSessionCapabilities::READ_ONLY,
|
WorkdirSessionCapabilities::READ_ONLY,
|
||||||
Default::default(),
|
Default::default(),
|
||||||
@@ -8721,7 +8694,6 @@ mod tests {
|
|||||||
"working-directory-42",
|
"working-directory-42",
|
||||||
root.path(),
|
root.path(),
|
||||||
root.path(),
|
root.path(),
|
||||||
restored_scope.clone(),
|
|
||||||
restored_scope,
|
restored_scope,
|
||||||
WorkdirSessionCapabilities::READ_ONLY,
|
WorkdirSessionCapabilities::READ_ONLY,
|
||||||
Default::default(),
|
Default::default(),
|
||||||
@@ -10246,9 +10218,7 @@ mod tests {
|
|||||||
|
|
||||||
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
|
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||||
let attachments = BTreeMap::from([(alias, binding.clone())]);
|
let attachments = BTreeMap::from([(alias, binding.clone())]);
|
||||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
|
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), None).unwrap();
|
||||||
let router =
|
|
||||||
runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope, None).unwrap();
|
|
||||||
session_clone.close().await.unwrap();
|
session_clone.close().await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
materializer.cleanup_working_directory(id).unwrap_err().code,
|
materializer.cleanup_working_directory(id).unwrap_err().code,
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir()?;
|
let runtime_tmp = tempfile::tempdir()?;
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
||||||
let (handle, _shutdown_rx) =
|
|
||||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
|
|
||||||
|
|
||||||
// Check initial status via shared state
|
// Check initial status via shared state
|
||||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||||
|
|||||||
@@ -17,28 +17,9 @@ use manifest::WorkerManifest;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkerBootstrapLayout {
|
pub enum WorkerBootstrapLayout {
|
||||||
/// A direct Worker rooted below the supplied runtime base directory.
|
/// A direct Worker rooted below the supplied runtime base directory.
|
||||||
Direct {
|
Direct { runtime_base: PathBuf },
|
||||||
runtime_base: PathBuf,
|
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
},
|
|
||||||
/// A runtime-managed Worker with an exact persisted run directory.
|
/// A runtime-managed Worker with an exact persisted run directory.
|
||||||
RuntimeManagedRun {
|
RuntimeManagedRun { run_dir: PathBuf },
|
||||||
run_dir: PathBuf,
|
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the temporary Bash spill directory owned by a stable Worker identity.
|
|
||||||
///
|
|
||||||
/// The directory deliberately lives outside session/run-generation storage so a
|
|
||||||
/// restarted controller for the same Worker keeps the same readable artifact
|
|
||||||
/// boundary.
|
|
||||||
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
|
|
||||||
std::env::temp_dir()
|
|
||||||
.join("yoi")
|
|
||||||
.join("workers")
|
|
||||||
.join(worker_id.to_string())
|
|
||||||
.join("bash-output")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construction and controller inputs that are stable for one Worker launch.
|
/// Construction and controller inputs that are stable for one Worker launch.
|
||||||
@@ -286,27 +267,12 @@ where
|
|||||||
{
|
{
|
||||||
let cleanup_sessions = worker.workdir_sessions();
|
let cleanup_sessions = worker.workdir_sessions();
|
||||||
let controller = match layout {
|
let controller = match layout {
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct { runtime_base } => {
|
||||||
runtime_base,
|
WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await
|
||||||
bash_output_dir,
|
|
||||||
} => {
|
|
||||||
WorkerController::spawn_with_transport_owned(
|
|
||||||
worker,
|
|
||||||
&runtime_base,
|
|
||||||
&bash_output_dir,
|
|
||||||
transport,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
||||||
run_dir,
|
|
||||||
bash_output_dir,
|
|
||||||
} => {
|
|
||||||
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
||||||
worker,
|
worker, &run_dir, transport,
|
||||||
&run_dir,
|
|
||||||
&bash_output_dir,
|
|
||||||
transport,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -327,22 +293,3 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::bash_output_dir_for_worker_id;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bash_output_directory_is_stable_per_worker_below_system_temp() {
|
|
||||||
let path = bash_output_dir_for_worker_id("019c1234-worker");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
path,
|
|
||||||
std::env::temp_dir()
|
|
||||||
.join("yoi")
|
|
||||||
.join("workers")
|
|
||||||
.join("019c1234-worker")
|
|
||||||
.join("bash-output")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ enum WriteResponseFault {
|
|||||||
LostBody,
|
LostBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum CommandResponseFault {
|
||||||
|
LostTerminal,
|
||||||
|
EmptyWaiting,
|
||||||
|
}
|
||||||
|
|
||||||
struct ProviderState {
|
struct ProviderState {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
requests: Mutex<Vec<(String, String)>>,
|
requests: Mutex<Vec<(String, String)>>,
|
||||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||||
|
command_fault: Mutex<Option<CommandResponseFault>>,
|
||||||
observe_refusal: Mutex<bool>,
|
observe_refusal: Mutex<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +159,21 @@ async fn operate(
|
|||||||
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
|
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if let WorkdirSessionOperation::CommandOutput(output) = &request.operation {
|
||||||
|
if matches!(
|
||||||
|
*state.command_fault.lock().unwrap(),
|
||||||
|
Some(CommandResponseFault::EmptyWaiting)
|
||||||
|
) {
|
||||||
|
return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput(
|
||||||
|
workdir::CommandOutput {
|
||||||
|
chunks: vec![],
|
||||||
|
next_cursor: output.cursor,
|
||||||
|
terminal: None,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
let result =
|
let result =
|
||||||
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||||
match result {
|
match result {
|
||||||
@@ -159,6 +181,15 @@ async fn operate(
|
|||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let fault = if is_write {
|
let fault = if is_write {
|
||||||
state.write_fault.lock().unwrap().take()
|
state.write_fault.lock().unwrap().take()
|
||||||
|
} else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some())
|
||||||
|
{
|
||||||
|
let mut command_fault = state.command_fault.lock().unwrap();
|
||||||
|
if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) {
|
||||||
|
command_fault.take();
|
||||||
|
Some(WriteResponseFault::LostBody)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -237,6 +268,7 @@ impl HttpFixture {
|
|||||||
requests: Mutex::new(Vec::new()),
|
requests: Mutex::new(Vec::new()),
|
||||||
operations: Mutex::new(Vec::new()),
|
operations: Mutex::new(Vec::new()),
|
||||||
write_fault: Mutex::new(None),
|
write_fault: Mutex::new(None),
|
||||||
|
command_fault: Mutex::new(None),
|
||||||
observe_refusal: Mutex::new(false),
|
observe_refusal: Mutex::new(false),
|
||||||
});
|
});
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -801,6 +833,88 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full collection/projection integration: production HTTP client, local process pipes,
|
||||||
|
// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() {
|
||||||
|
watchdog(async {
|
||||||
|
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||||
|
let remote = Arc::new(f.open(TOKEN).await.unwrap());
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap();
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap();
|
||||||
|
let view = tools::bash_output::BashOutputView::new();
|
||||||
|
crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap();
|
||||||
|
let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap();
|
||||||
|
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal);
|
||||||
|
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap();
|
||||||
|
f.public_output(run.content.as_ref().unwrap());
|
||||||
|
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(result["metadata"]["stdout_saved"], 200013);
|
||||||
|
assert_eq!(result["metadata"]["stderr_saved"], 4);
|
||||||
|
assert_eq!(result["metadata"]["terminal"]["exit_code"], 7);
|
||||||
|
assert_eq!(result["metadata"]["terminal"]["transport_complete"], true);
|
||||||
|
assert_eq!(result["metadata"]["storage_truncated"], false);
|
||||||
|
assert_eq!(result["preview_omitted"], true);
|
||||||
|
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||||
|
runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap();
|
||||||
|
}
|
||||||
|
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||||
|
let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap();
|
||||||
|
assert!(search.content.unwrap().contains("needle"));
|
||||||
|
let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap();
|
||||||
|
let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap();
|
||||||
|
use base64::Engine as _;
|
||||||
|
assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat());
|
||||||
|
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap();
|
||||||
|
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(result["metadata"]["stdout_saved"], 4194304);
|
||||||
|
assert_eq!(result["metadata"]["stdout_seen"], 4194306);
|
||||||
|
assert_eq!(result["metadata"]["transport_truncation"], "byte_budget");
|
||||||
|
assert_eq!(result["metadata"]["storage_truncated"], true);
|
||||||
|
let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap();
|
||||||
|
let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]);
|
||||||
|
assert_eq!(tail["eof"], true);
|
||||||
|
assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2);
|
||||||
|
assert!(f.state.command_fault.lock().unwrap().is_none());
|
||||||
|
router.close_all().await.unwrap();
|
||||||
|
f.assert_endpoints();
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() {
|
||||||
|
watchdog(async {
|
||||||
|
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||||
|
let remote = f.open(TOKEN).await.unwrap();
|
||||||
|
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting);
|
||||||
|
let error = remote
|
||||||
|
.command_output(workdir::CommandOutputRequest {
|
||||||
|
handle: workdir::CommandHandle("unused".into()),
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||||
|
assert_eq!(
|
||||||
|
f.state
|
||||||
|
.operations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_)))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
remote.close().await.unwrap();
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
|
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
|
|||||||
@@ -477,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla
|
|||||||
let mut registry = WipMountRegistry::new();
|
let mut registry = WipMountRegistry::new();
|
||||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
|
for def in tools::routed_builtin_tools(
|
||||||
|
router,
|
||||||
|
tools::Tracker::new(),
|
||||||
|
tools::bash_output::BashOutputView::new(),
|
||||||
|
) {
|
||||||
let (meta, tool) = def();
|
let (meta, tool) = def();
|
||||||
let p = compatibility_projection(meta, tool, None).unwrap();
|
let p = compatibility_projection(meta, tool, None).unwrap();
|
||||||
registry.mount(p).unwrap();
|
registry.mount(p).unwrap();
|
||||||
|
|||||||
@@ -692,7 +692,6 @@ impl WorkerController {
|
|||||||
pub async fn spawn<C, St>(
|
pub async fn spawn<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -701,7 +700,6 @@ impl WorkerController {
|
|||||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -715,7 +713,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn_with_transport<C, St>(
|
pub async fn spawn_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
@@ -723,42 +721,32 @@ impl WorkerController {
|
|||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let (handle, shutdown, _task) =
|
let (handle, shutdown, _task) =
|
||||||
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
|
Self::spawn_with_transport_owned(worker, runtime_base, transport).await?;
|
||||||
.await?;
|
|
||||||
Ok((handle, shutdown))
|
Ok((handle, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn spawn_with_transport_owned<C, St>(
|
pub(crate) async fn spawn_with_transport_owned<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self::spawn_inner(
|
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
||||||
worker,
|
|
||||||
runtime_base,
|
|
||||||
bash_output_dir,
|
|
||||||
false,
|
|
||||||
None,
|
|
||||||
transport,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a Worker owned by `worker-runtime`.
|
/// Spawn a Worker owned by `worker-runtime`.
|
||||||
///
|
///
|
||||||
/// The controller uses an ephemeral directory for Unix sockets while tool
|
/// The controller uses an ephemeral directory for Unix sockets. Bash output is
|
||||||
/// spill artifacts use the separately supplied Worker-owned temporary path.
|
/// owned by its live Feature instance, independently of filesystem layout.
|
||||||
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
|
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
|
||||||
/// projections.
|
/// projections.
|
||||||
pub async fn spawn_runtime_managed<C, St>(
|
pub async fn spawn_runtime_managed<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -767,7 +755,6 @@ impl WorkerController {
|
|||||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -780,7 +767,6 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run<C, St>(
|
pub async fn spawn_runtime_managed_run<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -789,7 +775,6 @@ impl WorkerController {
|
|||||||
Self::spawn_runtime_managed_run_with_transport(
|
Self::spawn_runtime_managed_run_with_transport(
|
||||||
worker,
|
worker,
|
||||||
run_dir,
|
run_dir,
|
||||||
bash_output_dir,
|
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -800,27 +785,23 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
|
let (handle, shutdown, _task) =
|
||||||
worker,
|
Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport)
|
||||||
run_dir,
|
.await?;
|
||||||
bash_output_dir,
|
|
||||||
transport,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok((handle, shutdown))
|
Ok((handle, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
|
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
@@ -830,21 +811,13 @@ impl WorkerController {
|
|||||||
let parent = run_dir
|
let parent = run_dir
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||||
Self::spawn_inner(
|
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
||||||
worker,
|
|
||||||
parent,
|
|
||||||
bash_output_dir,
|
|
||||||
true,
|
|
||||||
Some(run_dir),
|
|
||||||
transport,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_inner<C, St>(
|
async fn spawn_inner<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -857,7 +830,6 @@ impl WorkerController {
|
|||||||
let result = Self::spawn_initialized(
|
let result = Self::spawn_initialized(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
|
||||||
runtime_managed,
|
runtime_managed,
|
||||||
runtime_run,
|
runtime_run,
|
||||||
transport,
|
transport,
|
||||||
@@ -874,7 +846,7 @@ impl WorkerController {
|
|||||||
async fn spawn_initialized<C, St>(
|
async fn spawn_initialized<C, St>(
|
||||||
mut worker: Worker<C, St>,
|
mut worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
bash_output_dir: &Path,
|
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -888,8 +860,7 @@ impl WorkerController {
|
|||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
// === 1. Initialization (channels / RuntimeDir / worker-immutable
|
// === 1. Initialization (channels / RuntimeDir / worker-immutable
|
||||||
// snapshots / SpawnedWorkerRegistry / alerter attach /
|
// snapshots / SpawnedWorkerRegistry / alerter attach) ===
|
||||||
// bash-output scope) ===
|
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
||||||
let (working_event_tx, _) = broadcast::channel::<Event>(256);
|
let (working_event_tx, _) = broadcast::channel::<Event>(256);
|
||||||
@@ -897,8 +868,8 @@ impl WorkerController {
|
|||||||
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
|
|
||||||
// Runtime directory is created before tool registration because it owns
|
// Runtime directory is created before tool registration and before
|
||||||
// bounded tool artifacts, and before initial status/history writes consume
|
// initial status/history writes consume
|
||||||
// the greeting we build after registration is complete.
|
// the greeting we build after registration is complete.
|
||||||
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
|
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
|
||||||
RuntimeDir::create_worker_run(run_dir).await?
|
RuntimeDir::create_worker_run(run_dir).await?
|
||||||
@@ -935,26 +906,6 @@ impl WorkerController {
|
|||||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||||
worker.attach_working_event_tx(working_event_tx.clone());
|
worker.attach_working_event_tx(working_event_tx.clone());
|
||||||
|
|
||||||
// Bash spill artifacts are owned by the stable Worker identity rather
|
|
||||||
// than a controller session/run generation. Push a recursive
|
|
||||||
// `allow(Read)` for the exact tool output path into the Worker's shared
|
|
||||||
// runtime scope so the Workdir session and system prompt stay aligned.
|
|
||||||
let bash_output_dir = bash_output_dir.to_path_buf();
|
|
||||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
|
||||||
std::io::Error::other(format!(
|
|
||||||
"create bash output dir {}: {e}",
|
|
||||||
bash_output_dir.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
worker
|
|
||||||
.add_scope_rules([manifest::ScopeRule {
|
|
||||||
target: bash_output_dir.clone(),
|
|
||||||
permission: manifest::Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
|
||||||
.map_err(std::io::Error::other)?;
|
|
||||||
|
|
||||||
// === 1.5. Direct writer wiring ===
|
// === 1.5. Direct writer wiring ===
|
||||||
//
|
//
|
||||||
// Engine callbacks fire `on_history_append` for each assistant
|
// Engine callbacks fire `on_history_append` for each assistant
|
||||||
@@ -974,7 +925,6 @@ impl WorkerController {
|
|||||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||||
let fs_for_view = register_worker_tools(
|
let fs_for_view = register_worker_tools(
|
||||||
&mut worker,
|
&mut worker,
|
||||||
bash_output_dir,
|
|
||||||
runtime_base.to_path_buf(),
|
runtime_base.to_path_buf(),
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
Some(method_tx.downgrade()),
|
Some(method_tx.downgrade()),
|
||||||
@@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets(
|
|||||||
/// the shared state.
|
/// the shared state.
|
||||||
pub(crate) async fn register_worker_tools<C, St>(
|
pub(crate) async fn register_worker_tools<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
||||||
@@ -1493,6 +1443,7 @@ where
|
|||||||
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
||||||
let prompts = worker.prompts().clone();
|
let prompts = worker.prompts().clone();
|
||||||
let tracker = tools::Tracker::new();
|
let tracker = tools::Tracker::new();
|
||||||
|
let bash_output = spawned_registry.bash_output();
|
||||||
{
|
{
|
||||||
let paste_store = worker.store().clone();
|
let paste_store = worker.store().clone();
|
||||||
let paste_session_id = worker.session_id();
|
let paste_session_id = worker.session_id();
|
||||||
@@ -1515,13 +1466,13 @@ where
|
|||||||
tools::routed_builtin_tools_for_current_capabilities(
|
tools::routed_builtin_tools_for_current_capabilities(
|
||||||
workdir_sessions.clone(),
|
workdir_sessions.clone(),
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
bash_output_dir.clone(),
|
bash_output.clone(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
tools::routed_builtin_tools(
|
tools::routed_builtin_tools(
|
||||||
workdir_sessions.clone(),
|
workdir_sessions.clone(),
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
bash_output_dir.clone(),
|
bash_output.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
worker.engine_mut().register_tools(workdir_tools);
|
worker.engine_mut().register_tools(workdir_tools);
|
||||||
@@ -1918,6 +1869,20 @@ where
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
if wip_mode {
|
||||||
|
crate::feature::builtin::bash::mount(
|
||||||
|
&mut wip_mount_registry,
|
||||||
|
worker.workdir_sessions(),
|
||||||
|
bash_output.clone(),
|
||||||
|
wip_permissions.clone(),
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
format!("mount Bash output WIP projection: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||||
&mut wip_mount_registry,
|
&mut wip_mount_registry,
|
||||||
@@ -2048,7 +2013,6 @@ where
|
|||||||
spawner_workspace_context,
|
spawner_workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
bash_output_dir.clone(),
|
|
||||||
spawner_workspace_root,
|
spawner_workspace_root,
|
||||||
workdir_tool_broker,
|
workdir_tool_broker,
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
@@ -3334,6 +3298,8 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spawned_registry.bash_output().revoke();
|
||||||
|
|
||||||
if let Some(observer) = command_observer.take() {
|
if let Some(observer) = command_observer.take() {
|
||||||
observer.abort();
|
observer.abort();
|
||||||
let _ = observer.await;
|
let _ = observer.await;
|
||||||
@@ -4234,7 +4200,6 @@ mod tests {
|
|||||||
.descriptor();
|
.descriptor();
|
||||||
let registered = register_worker_tools(
|
let registered = register_worker_tools(
|
||||||
&mut worker,
|
&mut worker,
|
||||||
root.path().join("bash-output"),
|
|
||||||
root.path().join("runtime"),
|
root.path().join("runtime"),
|
||||||
SpawnedWorkerRegistry::new_for_internal_services(),
|
SpawnedWorkerRegistry::new_for_internal_services(),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ async fn prepared(
|
|||||||
WorkerFilesystemAuthority::None,
|
WorkerFilesystemAuthority::None,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash"),
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
|
|
||||||
let started = match start_worker_controller(
|
let started = match start_worker_controller(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: runtime_base.clone(),
|
runtime_base: runtime_base.clone(),
|
||||||
bash_output_dir,
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! an external plugin-loading surface.
|
//! an external plugin-loading surface.
|
||||||
|
|
||||||
pub mod backend_job_result;
|
pub mod backend_job_result;
|
||||||
|
pub(crate) mod bash;
|
||||||
pub mod chat_invocation;
|
pub mod chat_invocation;
|
||||||
pub mod drive;
|
pub mod drive;
|
||||||
pub mod flow_transition;
|
pub mod flow_transition;
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
//! Bash Feature projection. The view is instance-owned and shared only by explicit parent
|
||||||
|
//! routing. No output is indexable; committed result entries directly address immutable files.
|
||||||
|
//! Root namespace knowledge grants nothing: every publication and read checks the current view.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
file_operation::{self, Contract},
|
||||||
|
permission::permission_action_for,
|
||||||
|
wip::{
|
||||||
|
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||||
|
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||||
|
WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use agen::tool::{Tool, ToolError};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{collections::BTreeMap, sync::Arc};
|
||||||
|
use tools::{BashTool, bash_output::BashOutputView};
|
||||||
|
use wip_protocol::{
|
||||||
|
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||||
|
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||||
|
ReturnDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
use workdir::{CommandStream, WorkdirSessionRouter};
|
||||||
|
|
||||||
|
const ROOT: &str = "/bash-output";
|
||||||
|
|
||||||
|
pub(crate) fn mount(
|
||||||
|
registry: &mut WipMountRegistry,
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
view: BashOutputView,
|
||||||
|
permissions: Option<ToolPermissionConfig>,
|
||||||
|
) -> Result<(), WipMountError> {
|
||||||
|
registry.allocate_namespace("bash", "bash-output")?;
|
||||||
|
let provider = Arc::new(Provider {
|
||||||
|
service: BashTool::new(router, view.clone()),
|
||||||
|
view,
|
||||||
|
permissions,
|
||||||
|
incarnation: uuid::Uuid::now_v7().to_string(),
|
||||||
|
});
|
||||||
|
registry.mount(provider.root())?;
|
||||||
|
registry.replace_compatibility_tools(ROOT, ["Bash"])?;
|
||||||
|
registry.mount_subtree(WipSubtreeMount {
|
||||||
|
root: ROOT.into(),
|
||||||
|
provider: Arc::new(provider),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider {
|
||||||
|
service: BashTool,
|
||||||
|
view: BashOutputView,
|
||||||
|
permissions: Option<ToolPermissionConfig>,
|
||||||
|
incarnation: String,
|
||||||
|
}
|
||||||
|
impl Provider {
|
||||||
|
fn allowed(&self, tool: &str, args: &Json) -> bool {
|
||||||
|
self.view.is_active()
|
||||||
|
&& self
|
||||||
|
.permissions
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow)
|
||||||
|
}
|
||||||
|
fn may_allow(&self, tool: &str) -> bool {
|
||||||
|
if !self.view.is_active() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(p) = &self.permissions else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) {
|
||||||
|
if rule.action == ToolPermissionAction::Allow {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if rule.pattern == "*" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.default_action == ToolPermissionAction::Allow
|
||||||
|
}
|
||||||
|
fn root(self: &Arc<Self>) -> WipProjection {
|
||||||
|
let interface = contextual_reference("yoi.bash/command/v1", ROOT);
|
||||||
|
let descriptor = InterfaceDescriptor {
|
||||||
|
format: INTERFACE_FORMAT_V1.into(),
|
||||||
|
documentation: Some(Documentation {
|
||||||
|
summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
types: vec![],
|
||||||
|
operations: vec![OperationDeclaration {
|
||||||
|
name: "run".into(),
|
||||||
|
documentation: None,
|
||||||
|
parameters: vec![ParameterDeclaration {
|
||||||
|
name: "input".into(),
|
||||||
|
required: true,
|
||||||
|
documentation: Some(Documentation {
|
||||||
|
summary: "Bash input, validated by the existing Tool argument contract".into(),
|
||||||
|
details: Some(BashTool::input_schema().to_string()),
|
||||||
|
}),
|
||||||
|
r#type: TypeExpr::Json,
|
||||||
|
}],
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Record {
|
||||||
|
fields: vec![
|
||||||
|
field("summary", TypeExpr::String),
|
||||||
|
field("stdout_preview", TypeExpr::String),
|
||||||
|
field("stderr_preview", TypeExpr::String),
|
||||||
|
field("preview_omitted", TypeExpr::Boolean),
|
||||||
|
field("metadata", TypeExpr::Json),
|
||||||
|
field("stdout_entry", TypeExpr::Entry),
|
||||||
|
field("stderr_entry", TypeExpr::Entry),
|
||||||
|
field("order_entry", TypeExpr::Entry),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
WipProjection {
|
||||||
|
route: ROOT.into(),
|
||||||
|
capability: "bash:command".into(),
|
||||||
|
kind: WipProjectionKind::Native,
|
||||||
|
object: Object {
|
||||||
|
name: "bash-output".into(),
|
||||||
|
description: Some("Bash command and nonindexable retained output".into()),
|
||||||
|
interfaces: vec![interface.clone()],
|
||||||
|
r#ref: Some(format!("bash:{}", self.incarnation)),
|
||||||
|
validator: Some(self.incarnation.as_bytes().to_vec()),
|
||||||
|
},
|
||||||
|
interface,
|
||||||
|
interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()),
|
||||||
|
descriptor,
|
||||||
|
handler: Arc::new(Run {
|
||||||
|
provider: self.clone(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn parse(&self, path: &str) -> Option<(String, Option<CommandStream>)> {
|
||||||
|
let rest = path.strip_prefix("/bash-output/")?;
|
||||||
|
let (id, stream) = rest.split_once('/')?;
|
||||||
|
if uuid::Uuid::parse_str(id).ok()?.to_string() != id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let stream = match stream {
|
||||||
|
"stdout" => Some(CommandStream::Stdout),
|
||||||
|
"stderr" => Some(CommandStream::Stderr),
|
||||||
|
"order" => None,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((id.into(), stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WipSubtreeProvider for Arc<Provider> {
|
||||||
|
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||||
|
if !self.view.is_active() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if path == ROOT {
|
||||||
|
let projection = self.root();
|
||||||
|
return Ok(Some(WipPublication {
|
||||||
|
scope: Some(projection.object.clone()),
|
||||||
|
projection,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let Some((id, stream)) = self.parse(path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(metadata) = self.view.metadata(&id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !self.may_allow("Read") && !self.may_allow("Grep") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut contracts = vec![];
|
||||||
|
if self.may_allow("Read") {
|
||||||
|
contracts.extend([Contract::ReadLines, Contract::ReadBytes]);
|
||||||
|
}
|
||||||
|
if self.may_allow("Grep") {
|
||||||
|
contracts.push(Contract::SearchText);
|
||||||
|
}
|
||||||
|
let primary = contracts[0];
|
||||||
|
let interface = primary.reference();
|
||||||
|
let descriptor = primary.descriptor();
|
||||||
|
let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec();
|
||||||
|
let mut projection = WipProjection {
|
||||||
|
route: path.into(),
|
||||||
|
capability: "bash:output".into(),
|
||||||
|
kind: WipProjectionKind::Native,
|
||||||
|
object: Object {
|
||||||
|
name: path.rsplit('/').next().unwrap().into(),
|
||||||
|
description: Some(format!(
|
||||||
|
"Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}",
|
||||||
|
metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated
|
||||||
|
)),
|
||||||
|
interfaces: vec![interface.clone()],
|
||||||
|
r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)),
|
||||||
|
validator: Some(validator.clone()),
|
||||||
|
},
|
||||||
|
interface,
|
||||||
|
descriptor: descriptor.clone(),
|
||||||
|
interface_validator: Some(primary.validator()),
|
||||||
|
handler: Arc::new(File {
|
||||||
|
provider: self.clone(),
|
||||||
|
id,
|
||||||
|
stream,
|
||||||
|
path: path.into(),
|
||||||
|
validator,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
file_operation::expose(&mut projection, contracts);
|
||||||
|
Ok(Some(WipPublication {
|
||||||
|
projection,
|
||||||
|
scope: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Run {
|
||||||
|
provider: Arc<Provider>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl WipOperationHandler for Run {
|
||||||
|
fn is_visible(&self) -> bool {
|
||||||
|
self.provider.view.is_active()
|
||||||
|
}
|
||||||
|
fn operation_available(&self, op: &str) -> bool {
|
||||||
|
op == "run" && self.provider.may_allow("Bash")
|
||||||
|
}
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
op: &str,
|
||||||
|
args: &BTreeMap<String, Value>,
|
||||||
|
ctx: WipCallContext,
|
||||||
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
|
if op != "run" {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::OperationNotFound,
|
||||||
|
"unknown Bash operation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let input = args
|
||||||
|
.get("input")
|
||||||
|
.ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?;
|
||||||
|
let input =
|
||||||
|
wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||||
|
if !self.provider.allowed("Bash", &input) {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::PermissionDenied,
|
||||||
|
"Bash not permitted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let result = self
|
||||||
|
.provider
|
||||||
|
.service
|
||||||
|
.execute_result(&input.to_string(), ctx.execution)
|
||||||
|
.await
|
||||||
|
.map_err(tool_error)?;
|
||||||
|
if result.cancelled {
|
||||||
|
return Err(WipOperationError::Cancelled(result.tool_output()));
|
||||||
|
}
|
||||||
|
if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
|
||||||
|
return Err(WipOperationError::Interrupted(result.tool_output()));
|
||||||
|
}
|
||||||
|
// Entry fields are typed by the native Descriptor. No display-string parsing is involved.
|
||||||
|
Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([
|
||||||
|
("summary".into(), Value::String(result.summary.clone())),
|
||||||
|
(
|
||||||
|
"stdout_preview".into(),
|
||||||
|
Value::String(result.stdout_preview.clone()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"stderr_preview".into(),
|
||||||
|
Value::String(result.stderr_preview.clone()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"preview_omitted".into(),
|
||||||
|
Value::Boolean(result.preview_omitted),
|
||||||
|
),
|
||||||
|
("stdout_entry".into(), Value::String(result.stdout_entry())),
|
||||||
|
("stderr_entry".into(), Value::String(result.stderr_entry())),
|
||||||
|
("order_entry".into(), Value::String(result.order_entry())),
|
||||||
|
(
|
||||||
|
"metadata".into(),
|
||||||
|
json_to_wip(&serde_json::to_value(result.metadata).unwrap())
|
||||||
|
.map_err(|e| fail(ProtocolErrorCode::Internal, &e))?,
|
||||||
|
),
|
||||||
|
]))))
|
||||||
|
}
|
||||||
|
async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> {
|
||||||
|
self.provider.service.cancel_execution(ctx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct File {
|
||||||
|
provider: Arc<Provider>,
|
||||||
|
id: String,
|
||||||
|
stream: Option<CommandStream>,
|
||||||
|
path: String,
|
||||||
|
validator: Vec<u8>,
|
||||||
|
}
|
||||||
|
impl File {
|
||||||
|
fn order_text(&self) -> Result<String, WipOperationError> {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let ranges = self
|
||||||
|
.provider
|
||||||
|
.view
|
||||||
|
.order(&self.id)
|
||||||
|
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||||
|
let mut text = String::new();
|
||||||
|
for r in ranges {
|
||||||
|
writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap();
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl WipOperationHandler for File {
|
||||||
|
fn is_visible(&self) -> bool {
|
||||||
|
self.provider.view.metadata(&self.id).is_some()
|
||||||
|
}
|
||||||
|
fn object_validator(&self) -> Option<Vec<u8>> {
|
||||||
|
self.is_visible().then(|| self.validator.clone())
|
||||||
|
}
|
||||||
|
fn operation_available(&self, op: &str) -> bool {
|
||||||
|
match op {
|
||||||
|
"read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"),
|
||||||
|
"search_text" | "search" => self.provider.may_allow("Grep"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
op: &str,
|
||||||
|
args: &BTreeMap<String, Value>,
|
||||||
|
_: WipCallContext,
|
||||||
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
|
if !self.operation_available(op) {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::PermissionDenied,
|
||||||
|
"output operation not permitted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut input = wip_to_json(&Value::Record(args.clone()))
|
||||||
|
.map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||||
|
let tool = if op == "search_text" {
|
||||||
|
input["path"] = json!(self.path);
|
||||||
|
"Grep"
|
||||||
|
} else {
|
||||||
|
input["file_path"] = json!(self.path);
|
||||||
|
"Read"
|
||||||
|
};
|
||||||
|
if !self.provider.allowed(tool, &input) {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::PermissionDenied,
|
||||||
|
"output operation not permitted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let metadata = self
|
||||||
|
.provider
|
||||||
|
.view
|
||||||
|
.metadata(&self.id)
|
||||||
|
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||||
|
if op == "read_bytes" {
|
||||||
|
let (offset, limit) = file_operation::byte_range(args)?;
|
||||||
|
let (bytes, total) = if let Some(stream) = self.stream {
|
||||||
|
let total = if stream == CommandStream::Stdout {
|
||||||
|
metadata.stdout_saved
|
||||||
|
} else {
|
||||||
|
metadata.stderr_saved
|
||||||
|
};
|
||||||
|
if offset > total as u64 {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"byte offset exceeds output length",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bytes = self
|
||||||
|
.provider
|
||||||
|
.view
|
||||||
|
.read(&self.id, stream, offset as usize, limit)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
fail(ProtocolErrorCode::NotFound, "output expired or revoked")
|
||||||
|
})?;
|
||||||
|
(bytes, total)
|
||||||
|
} else {
|
||||||
|
let text = self.order_text()?;
|
||||||
|
if offset > text.len() as u64 {
|
||||||
|
return Err(fail(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"byte offset exceeds output length",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(
|
||||||
|
text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())]
|
||||||
|
.to_vec(),
|
||||||
|
text.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let eof = offset + bytes.len() as u64 == total as u64;
|
||||||
|
return Ok(WipOperationOutput::native_with_validator(
|
||||||
|
file_operation::bytes_result(args, &bytes, eof)?,
|
||||||
|
self.validator.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (offset, limit) = file_operation::window(args, op == "search_text")?;
|
||||||
|
let value = if let Some(stream) = self.stream {
|
||||||
|
let (text, more) = self
|
||||||
|
.provider
|
||||||
|
.view
|
||||||
|
.line_window(&self.id, stream, offset, limit)
|
||||||
|
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?
|
||||||
|
.map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?;
|
||||||
|
file_operation::page_result(op, args, &text, offset, more)?
|
||||||
|
} else {
|
||||||
|
file_operation::text_result(op, args, &self.order_text()?)?
|
||||||
|
};
|
||||||
|
Ok(WipOperationOutput::native_with_validator(
|
||||||
|
value,
|
||||||
|
self.validator.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||||
|
WipOperationError::Protocol(ProtocolError {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn tool_error(error: ToolError) -> WipOperationError {
|
||||||
|
match error {
|
||||||
|
ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"),
|
||||||
|
ToolError::Cancelled(output) => WipOperationError::Cancelled(output),
|
||||||
|
ToolError::Interrupted(output) => WipOperationError::Interrupted(output),
|
||||||
|
_ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration {
|
||||||
|
FieldDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
required: true,
|
||||||
|
documentation: None,
|
||||||
|
r#type,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_command(
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
&self,
|
|
||||||
mut request: CommandRequest,
|
|
||||||
) -> Result<CommandHandle, WorkdirError> {
|
|
||||||
// `spill_dir` belongs to the Worker host running BashTool. This remote
|
|
||||||
// WorkdirSession provider cannot safely resolve or write that host path.
|
|
||||||
request.spill_dir = None;
|
|
||||||
match self.operate(WorkdirSessionOperation::CommandStart(request))? {
|
match self.operate(WorkdirSessionOperation::CommandStart(request))? {
|
||||||
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
|
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
|
||||||
_ => Err(Self::mismatch("command_start")),
|
_ => Err(Self::mismatch("command_start")),
|
||||||
@@ -1660,9 +1654,9 @@ mod tests {
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "true".to_string(),
|
command: "true".to_string(),
|
||||||
timeout_secs: 120,
|
timeout_secs: 120,
|
||||||
output_limit: 1024,
|
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
cwd: workdir::WorkdirPath::root(),
|
cwd: workdir::WorkdirPath::root(),
|
||||||
spill_dir: Some("/worker-local/bash-output".into()),
|
|
||||||
tool_call_id: Some("call-1".to_string()),
|
tool_call_id: Some("call-1".to_string()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -1675,7 +1669,7 @@ mod tests {
|
|||||||
assert_eq!(body["operation"]["operation"], "command_start");
|
assert_eq!(body["operation"]["operation"], "command_start");
|
||||||
assert_eq!(body["operation"]["request"]["command"], "true");
|
assert_eq!(body["operation"]["request"]["command"], "true");
|
||||||
assert_eq!(body["operation"]["request"]["cwd"], "");
|
assert_eq!(body["operation"]["request"]["cwd"], "");
|
||||||
assert!(body["operation"]["request"]["spill_dir"].is_null());
|
assert!(body["operation"]["request"].get("spill_dir").is_none());
|
||||||
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
|
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
|
||||||
assert!(
|
assert!(
|
||||||
!requests[0]
|
!requests[0]
|
||||||
@@ -1942,9 +1936,9 @@ mod tests {
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf done".to_string(),
|
command: "printf done".to_string(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
cwd: workdir::WorkdirPath::root(),
|
cwd: workdir::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ mod worker;
|
|||||||
|
|
||||||
pub use bootstrap::{
|
pub use bootstrap::{
|
||||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||||
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
|
WorkerBootstrapLayout, start_worker_controller,
|
||||||
};
|
};
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
scope_delegated: Vec<ScopeRule>,
|
scope_delegated: Vec<ScopeRule>,
|
||||||
workdir_tool_scope: WorkdirScopeLeaseSet,
|
workdir_tool_scope: Arc<WorkdirScopeLeaseSet>,
|
||||||
#[cfg(test)] installed_tools: Vec<String>,
|
#[cfg(test)] installed_tools: Vec<String>,
|
||||||
session: InternalWorkerSessionHandle,
|
session: InternalWorkerSessionHandle,
|
||||||
child_registry: Arc<SpawnedWorkerRegistry>,
|
child_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
Self {
|
Self {
|
||||||
worker_name: worker_name.clone(),
|
worker_name: worker_name.clone(),
|
||||||
scope_delegated,
|
scope_delegated,
|
||||||
workdir_tool_scope: Arc::new(workdir_tool_scope),
|
workdir_tool_scope,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
installed_tools: installed_tools.into(),
|
installed_tools: installed_tools.into(),
|
||||||
published: Arc::new(super::published::PublishedProtocol::new(
|
published: Arc::new(super::published::PublishedProtocol::new(
|
||||||
@@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry {
|
|||||||
pending_internal_notify: Notify,
|
pending_internal_notify: Notify,
|
||||||
internal_spawn_cleanup_failed: AtomicBool,
|
internal_spawn_cleanup_failed: AtomicBool,
|
||||||
parent_scope: Option<SharedScope>,
|
parent_scope: Option<SharedScope>,
|
||||||
|
bash_output: Mutex<tools::bash_output::BashOutputView>,
|
||||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||||
protocol_publish_lock: Arc<Mutex<()>>,
|
protocol_publish_lock: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
@@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnedWorkerRegistry {
|
impl SpawnedWorkerRegistry {
|
||||||
|
pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView {
|
||||||
|
self.bash_output.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) {
|
||||||
|
*self.bash_output.lock().unwrap() = view;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
@@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pending_internal_notify: Notify::new(),
|
pending_internal_notify: Notify::new(),
|
||||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
|
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
})
|
||||||
@@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pending_internal_notify: Notify::new(),
|
pending_internal_notify: Notify::new(),
|
||||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
|
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
})
|
||||||
@@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pending_internal_notify: Notify::new(),
|
pending_internal_notify: Notify::new(),
|
||||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: Some(parent_scope),
|
parent_scope: Some(parent_scope),
|
||||||
|
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
})
|
||||||
@@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry {
|
|||||||
pending_internal_notify: Notify::new(),
|
pending_internal_notify: Notify::new(),
|
||||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope,
|
parent_scope,
|
||||||
|
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||||
}),
|
}),
|
||||||
@@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
"an in-flight Internal SubWorker failed cleanup during shutdown",
|
"an in-flight Internal SubWorker failed cleanup during shutdown",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let output = self.bash_output();
|
||||||
|
if !output.is_root() {
|
||||||
|
output.revoke();
|
||||||
|
}
|
||||||
first_error.map_or(Ok(()), Err)
|
first_error.map_or(Ok(()), Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option<Eve
|
|||||||
|
|
||||||
impl Drop for SpawnedWorkerRegistry {
|
impl Drop for SpawnedWorkerRegistry {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
self.bash_output.get_mut().unwrap().revoke();
|
||||||
let Some(parent_scope) = &self.parent_scope else {
|
let Some(parent_scope) = &self.parent_scope else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1297,11 +1315,13 @@ mod tests {
|
|||||||
InternalSpawnedWorkerRecord::new(
|
InternalSpawnedWorkerRecord::new(
|
||||||
name.into(),
|
name.into(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
workdir::WorkdirScopeLeaseSet::from_single(
|
Arc::new(
|
||||||
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
workdir::WorkdirScopeLeaseSet::from_single(
|
||||||
delegation,
|
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||||
)
|
delegation,
|
||||||
.unwrap(),
|
)
|
||||||
|
.unwrap(),
|
||||||
|
),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
session,
|
session,
|
||||||
registry(),
|
registry(),
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ pub struct SubWorkerSpawnTool {
|
|||||||
/// Runtime-owned root used for Internal Worker controller state.
|
/// Runtime-owned root used for Internal Worker controller state.
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
/// Parent Worker-owned temporary root used for bounded Bash spill output.
|
/// Parent Worker-owned temporary root used for bounded Bash spill output.
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
@@ -345,7 +345,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -358,7 +358,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
|
||||||
workspace_root,
|
workspace_root,
|
||||||
workdir_tool_broker,
|
workdir_tool_broker,
|
||||||
registry,
|
registry,
|
||||||
@@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||||
|
|
||||||
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
||||||
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
|
||||||
tokio::fs::create_dir_all(&child_bash_output_dir)
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
ToolError::ExecutionFailed(format!(
|
|
||||||
"create Internal Worker Bash output directory {}: {error}",
|
|
||||||
child_bash_output_dir.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
|
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
|
||||||
let tool_scopes = routed_workdir_tool_scopes(
|
let tool_scopes = routed_workdir_tool_scopes(
|
||||||
workdir_tool_broker,
|
workdir_tool_broker,
|
||||||
@@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
|
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let workdir_scope = Arc::new(workdir_scope);
|
||||||
let child_workdir_tool_broker = workdir_scope.broker_router();
|
let child_workdir_tool_broker = workdir_scope.broker_router();
|
||||||
|
|
||||||
let spawn_selector =
|
let spawn_selector =
|
||||||
@@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||||
child
|
|
||||||
.add_scope_rules([ScopeRule {
|
|
||||||
target: child_bash_output_dir.clone(),
|
|
||||||
permission: manifest::Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
|
||||||
.map_err(|error| {
|
|
||||||
ToolError::ExecutionFailed(format!(
|
|
||||||
"grant Internal Worker Bash output scope: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let child_scope = child.scope().clone();
|
let child_scope = child.scope().clone();
|
||||||
child.bind_workdir_sessions(workdir_scope.session_router());
|
child.bind_workdir_sessions(workdir_scope.session_router());
|
||||||
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
||||||
|
let output_lease = workdir_scope.clone();
|
||||||
|
child_registry.inherit_bash_output(
|
||||||
|
self.registry
|
||||||
|
.bash_output()
|
||||||
|
.child(Arc::new(move || output_lease.is_active())),
|
||||||
|
);
|
||||||
register_worker_tools(
|
register_worker_tools(
|
||||||
&mut child,
|
&mut child,
|
||||||
child_bash_output_dir,
|
|
||||||
self.runtime_base.clone(),
|
self.runtime_base.clone(),
|
||||||
child_registry.clone(),
|
child_registry.clone(),
|
||||||
None,
|
None,
|
||||||
@@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
|
||||||
workspace_root,
|
workspace_root,
|
||||||
workdir_tool_broker,
|
workdir_tool_broker,
|
||||||
registry,
|
registry,
|
||||||
@@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context.clone(),
|
workspace_context.clone(),
|
||||||
parent_notifications.clone(),
|
parent_notifications.clone(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
bash_output_dir.clone(),
|
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
workdir_tool_broker.clone(),
|
workdir_tool_broker.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1374,7 +1357,6 @@ enabled = false
|
|||||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||||
),
|
),
|
||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
bash_output_dir.clone(),
|
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(workdir_tool_broker),
|
Some(workdir_tool_broker),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1425,12 +1407,7 @@ enabled = false
|
|||||||
.await
|
.await
|
||||||
.expect("spawn project reviewer as Internal Worker");
|
.expect("spawn project reviewer as Internal Worker");
|
||||||
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
||||||
assert!(
|
assert!(!bash_output_dir.join("sub-workers").exists());
|
||||||
bash_output_dir
|
|
||||||
.join("sub-workers")
|
|
||||||
.join("reviewer-child")
|
|
||||||
.is_dir()
|
|
||||||
);
|
|
||||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||||
let record = registry
|
let record = registry
|
||||||
.get_internal("reviewer-child")
|
.get_internal("reviewer-child")
|
||||||
@@ -1460,6 +1437,27 @@ enabled = false
|
|||||||
record.session.wait_until_idle().await,
|
record.session.wait_until_idle().await,
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||||
);
|
);
|
||||||
|
let child_output_view = record.child_registry.bash_output();
|
||||||
|
let child_bash = tools::BashTool::new(
|
||||||
|
record.workdir_tool_scope.session_router(),
|
||||||
|
child_output_view.clone(),
|
||||||
|
);
|
||||||
|
let child_output = child_bash
|
||||||
|
.execute_result(
|
||||||
|
r#"{"command":"printf retained-child-evidence","cwd":"."}"#,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let output_id = child_output.metadata.id.clone();
|
||||||
|
assert!(child_output_view.metadata(&output_id).is_some());
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.bash_output()
|
||||||
|
.read(&output_id, workdir::CommandStream::Stdout, 0, 65536)
|
||||||
|
.unwrap(),
|
||||||
|
b"retained-child-evidence"
|
||||||
|
);
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
|
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
|
||||||
assert!(observed_instruction_override.load(Ordering::SeqCst));
|
assert!(observed_instruction_override.load(Ordering::SeqCst));
|
||||||
@@ -1563,6 +1561,8 @@ enabled = false
|
|||||||
!record.workdir_tool_scope.is_active(),
|
!record.workdir_tool_scope.is_active(),
|
||||||
"stopped child must revoke cloned scoped sessions"
|
"stopped child must revoke cloned scoped sessions"
|
||||||
);
|
);
|
||||||
|
assert!(child_output_view.metadata(&output_id).is_none());
|
||||||
|
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||||
assert!(registry.get_internal("reviewer-child").is_some());
|
assert!(registry.get_internal("reviewer-child").is_some());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1573,6 +1573,7 @@ enabled = false
|
|||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
assert!(registry.get_internal("reviewer-child").is_none());
|
assert!(registry.get_internal("reviewer-child").is_none());
|
||||||
|
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||||
|
|
||||||
fail_requests.store(false, Ordering::SeqCst);
|
fail_requests.store(false, Ordering::SeqCst);
|
||||||
@@ -1672,7 +1673,6 @@ enabled = false
|
|||||||
),
|
),
|
||||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
bash_output_dir,
|
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(workdir_tool_broker),
|
Some(workdir_tool_broker),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1805,7 +1805,6 @@ enabled = false
|
|||||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||||
),
|
),
|
||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
bash_output_dir.clone(),
|
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(workdir_tool_broker),
|
Some(workdir_tool_broker),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
|
|||||||
@@ -919,7 +919,6 @@ enabled = true
|
|||||||
WorkerFilesystemAuthority::None,
|
WorkerFilesystemAuthority::None,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash-output"),
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
@@ -1113,7 +1112,6 @@ enabled = true
|
|||||||
WorkerFilesystemAuthority::None,
|
WorkerFilesystemAuthority::None,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash"),
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
@@ -1279,7 +1277,6 @@ enabled = true
|
|||||||
WorkerFilesystemAuthority::None,
|
WorkerFilesystemAuthority::None,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash"),
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
@@ -1339,7 +1336,6 @@ enabled = true
|
|||||||
WorkerFilesystemAuthority::None,
|
WorkerFilesystemAuthority::None,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash-output"),
|
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7126,3 +7126,6 @@ mod tests {
|
|||||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bash_output_tests;
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
//! Native Bash output integration through real WIP Client/Host codec and local process pipes.
|
||||||
|
//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested
|
||||||
|
//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view.
|
||||||
|
use super::*;
|
||||||
|
use crate::file_operation::Contract;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tools::bash_output::BashOutputView;
|
||||||
|
use workdir::{
|
||||||
|
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_dir: TempDir,
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
view: BashOutputView,
|
||||||
|
runtime: WipRuntime,
|
||||||
|
}
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap();
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(dir.path()).unwrap(),
|
||||||
|
dir.path().into(),
|
||||||
|
));
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
|
||||||
|
.unwrap();
|
||||||
|
let view = BashOutputView::new();
|
||||||
|
let runtime = runtime(router.clone(), view.clone());
|
||||||
|
Self {
|
||||||
|
_dir: dir,
|
||||||
|
router,
|
||||||
|
view,
|
||||||
|
runtime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn run(&self, command: &str) -> Json {
|
||||||
|
value(
|
||||||
|
self.runtime
|
||||||
|
.invoke(
|
||||||
|
"/bash-output".into(),
|
||||||
|
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||||
|
"run".into(),
|
||||||
|
json!({"input":{"command":command,"cwd":"."}}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn runtime(router: Arc<WorkdirSessionRouter>, view: BashOutputView) -> WipRuntime {
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||||
|
.unwrap();
|
||||||
|
crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap();
|
||||||
|
WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap()
|
||||||
|
}
|
||||||
|
fn value(output: ToolOutput) -> Json {
|
||||||
|
serde_json::from_str(&output.content.unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
async fn invoke(
|
||||||
|
runtime: &WipRuntime,
|
||||||
|
path: &str,
|
||||||
|
contract: Contract,
|
||||||
|
operation: &str,
|
||||||
|
args: Json,
|
||||||
|
) -> Result<Json, ToolError> {
|
||||||
|
runtime
|
||||||
|
.invoke(
|
||||||
|
path.into(),
|
||||||
|
contract.reference(),
|
||||||
|
operation.into(),
|
||||||
|
args,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let result = f
|
||||||
|
.run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3")
|
||||||
|
.await;
|
||||||
|
assert_eq!(result["metadata"]["terminal"]["exit_code"], 3);
|
||||||
|
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||||
|
assert!(stdout.starts_with("/bash-output/"));
|
||||||
|
// The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection
|
||||||
|
// contains capabilities and metadata, not the process output body.
|
||||||
|
let projection = f
|
||||||
|
.runtime
|
||||||
|
.host
|
||||||
|
.projection_live("/bash-output")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else {
|
||||||
|
panic!("native record");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.find(|v| v.name == "stdout_entry")
|
||||||
|
.unwrap()
|
||||||
|
.r#type,
|
||||||
|
TypeExpr::Entry
|
||||||
|
);
|
||||||
|
let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap();
|
||||||
|
assert!(!inspect.content.unwrap().contains("雪 needle"));
|
||||||
|
for path in ["/checkouts/main/real.txt", stdout] {
|
||||||
|
let read = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadLines,
|
||||||
|
"read",
|
||||||
|
json!({"offset":1,"limit":1}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(read["content"].as_str().unwrap().contains("雪 needle"));
|
||||||
|
assert_eq!(read["next_offset"], 2);
|
||||||
|
let search = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::SearchText,
|
||||||
|
"search",
|
||||||
|
json!({"offset":0,"limit":2,"pattern":"needle"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(search["matches"][0]["line"], 2);
|
||||||
|
assert_eq!(search["next_offset"], 2);
|
||||||
|
assert_eq!(search["truncated"], true);
|
||||||
|
}
|
||||||
|
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||||
|
assert_eq!(
|
||||||
|
fields.iter().find(|v| v.name == name).unwrap().r#type,
|
||||||
|
TypeExpr::Entry
|
||||||
|
);
|
||||||
|
f.runtime
|
||||||
|
.inspect(result[name].as_str().unwrap().into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let stderr = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
result["stderr_entry"].as_str().unwrap(),
|
||||||
|
Contract::ReadLines,
|
||||||
|
"read",
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(stderr["content"].as_str().unwrap().contains("error"));
|
||||||
|
let order = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
result["order_entry"].as_str().unwrap(),
|
||||||
|
Contract::ReadLines,
|
||||||
|
"read",
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(order["content"].as_str().unwrap().contains("Stdout"));
|
||||||
|
assert!(order["content"].as_str().unwrap().contains("Stderr"));
|
||||||
|
assert_eq!(result["metadata"]["ordering"], "provider_read_observation");
|
||||||
|
let tree = value(
|
||||||
|
f.runtime
|
||||||
|
.tree("/bash-output".into(), 8, true)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(!tree.to_string().contains(stdout));
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&f.runtime,
|
||||||
|
stdout,
|
||||||
|
Contract::Write,
|
||||||
|
"write",
|
||||||
|
json!({"content":"no"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&f.runtime,
|
||||||
|
stdout,
|
||||||
|
Contract::Edit,
|
||||||
|
"edit",
|
||||||
|
json!({"old_string":"first","new_string":"no"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let result = f.run("printf '%100000s' ''; printf '€\\377'").await;
|
||||||
|
let path = result["stdout_entry"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadLines,
|
||||||
|
"read",
|
||||||
|
json!({"limit":1})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for (offset, expected) in [
|
||||||
|
(99999, vec![b' ', 0xe2]),
|
||||||
|
(100001, vec![0x82, 0xac]),
|
||||||
|
(100003, vec![0xff]),
|
||||||
|
] {
|
||||||
|
let read = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({"offset":offset,"limit":2}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(read["data"].as_str().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bytes, expected);
|
||||||
|
assert_eq!(read["next_offset"], offset + expected.len() as u64);
|
||||||
|
}
|
||||||
|
let eof = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({"offset":100004,"limit":2}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(eof["data"], "");
|
||||||
|
assert_eq!(eof["eof"], true);
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({"offset":100005})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({"limit":32769})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let lease = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||||
|
let child = f.view.child({
|
||||||
|
let lease = lease.clone();
|
||||||
|
Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire))
|
||||||
|
});
|
||||||
|
let child_runtime = runtime(f.router.clone(), child.clone());
|
||||||
|
let result = value(
|
||||||
|
child_runtime
|
||||||
|
.invoke(
|
||||||
|
"/bash-output".into(),
|
||||||
|
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||||
|
"run".into(),
|
||||||
|
json!({"input":{"command":"printf child-evidence","cwd":"."}}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let path = result["stdout_entry"].as_str().unwrap();
|
||||||
|
child_runtime.inspect(path.into(), false).await.unwrap();
|
||||||
|
let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true)));
|
||||||
|
let unrelated = runtime(f.router.clone(), BashOutputView::new());
|
||||||
|
assert!(sibling.inspect(path.into(), false).await.is_err());
|
||||||
|
assert!(unrelated.inspect(path.into(), false).await.is_err());
|
||||||
|
lease.store(false, std::sync::atomic::Ordering::Release);
|
||||||
|
assert!(
|
||||||
|
invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&child_runtime,
|
||||||
|
path,
|
||||||
|
Contract::SearchText,
|
||||||
|
"search",
|
||||||
|
json!({"pattern":"child"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
f.view.revoke();
|
||||||
|
assert!(
|
||||||
|
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let restored = runtime(f.router.clone(), BashOutputView::new());
|
||||||
|
assert!(restored.inspect(path.into(), false).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let session = f.router.only_session().unwrap();
|
||||||
|
let mut events = session.subscribe_command_events().unwrap();
|
||||||
|
let runtime = Arc::new(f.runtime);
|
||||||
|
let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0);
|
||||||
|
let running = runtime.clone();
|
||||||
|
let executing_context = context.clone();
|
||||||
|
let execution = tokio::spawn(async move {
|
||||||
|
running
|
||||||
|
.invoke(
|
||||||
|
"/bash-output".into(),
|
||||||
|
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||||
|
"run".into(),
|
||||||
|
json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}),
|
||||||
|
executing_context,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if matches!(
|
||||||
|
events.recv().await.unwrap(),
|
||||||
|
workdir::CommandEvent::Output { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
runtime.cancel(&context).await.unwrap();
|
||||||
|
let ToolError::Cancelled(output) =
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_err()
|
||||||
|
else {
|
||||||
|
panic!("expected cancelled terminal output")
|
||||||
|
};
|
||||||
|
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
|
let id = body["output"]["id"].as_str().unwrap();
|
||||||
|
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||||
|
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
|
||||||
|
assert_eq!(
|
||||||
|
f.view
|
||||||
|
.read(id, workdir::CommandStream::Stdout, 0, 20)
|
||||||
|
.unwrap(),
|
||||||
|
b"before"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(
|
||||||
|
&runtime,
|
||||||
|
body["stdout_entry"].as_str().unwrap(),
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err();
|
||||||
|
let ToolError::Interrupted(output) = error else {
|
||||||
|
panic!("expected timeout evidence")
|
||||||
|
};
|
||||||
|
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||||
|
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
|
||||||
|
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||||
|
assert!(
|
||||||
|
f.view
|
||||||
|
.metadata(body["output"]["id"].as_str().unwrap())
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
session.close().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn common_read_and_search_permissions_are_independent_from_bash_execution() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let result = f.run("printf needle").await;
|
||||||
|
let path = result["stdout_entry"].as_str().unwrap();
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::feature::builtin::bash::mount(
|
||||||
|
&mut registry,
|
||||||
|
f.router.clone(),
|
||||||
|
f.view.clone(),
|
||||||
|
Some(ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Deny,
|
||||||
|
rules: vec![manifest::ToolPermissionRule {
|
||||||
|
tool: "Grep".into(),
|
||||||
|
pattern: "*".into(),
|
||||||
|
action: ToolPermissionAction::Allow,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let restricted = WipRuntime::new(
|
||||||
|
WipHost::new(registry),
|
||||||
|
SecurityContext::new("restricted"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
restricted.inspect(path.into(), false).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
invoke(&restricted, path, Contract::ReadBytes, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
invoke(&restricted, path, Contract::ReadLines, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let found = invoke(
|
||||||
|
&restricted,
|
||||||
|
path,
|
||||||
|
Contract::SearchText,
|
||||||
|
"search",
|
||||||
|
json!({"pattern":"needle"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(found["matches"].as_array().unwrap().len() == 1);
|
||||||
|
assert!(
|
||||||
|
restricted
|
||||||
|
.invoke(
|
||||||
|
"/bash-output".into(),
|
||||||
|
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||||
|
"run".into(),
|
||||||
|
json!({"input":{"command":"touch denied","cwd":"."}}),
|
||||||
|
Default::default()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(!f._dir.path().join("denied").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() {
|
||||||
|
let f = Fixture::new();
|
||||||
|
let result = f
|
||||||
|
.run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'")
|
||||||
|
.await;
|
||||||
|
let path = result["stdout_entry"].as_str().unwrap();
|
||||||
|
let error = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::SearchText,
|
||||||
|
"search",
|
||||||
|
json!({"pattern":"needle","offset":0,"limit":1}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("ResourceLimitExceeded"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
// No successful page claims to advance past a partial line. Raw bytes can still
|
||||||
|
// retrieve its unread suffix using the same common file contract.
|
||||||
|
let suffix = invoke(
|
||||||
|
&f.runtime,
|
||||||
|
path,
|
||||||
|
Contract::ReadBytes,
|
||||||
|
"read",
|
||||||
|
json!({"offset":66000,"limit":7}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(suffix["data"].as_str().unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
b"needle\n"
|
||||||
|
);
|
||||||
|
assert_eq!(suffix["next_offset"], 66007);
|
||||||
|
}
|
||||||
@@ -17589,12 +17589,10 @@ permission = "write"
|
|||||||
let layout = if runtime_managed {
|
let layout = if runtime_managed {
|
||||||
crate::WorkerBootstrapLayout::RuntimeManagedRun {
|
crate::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: temp.path().join("runtime/runs/1"),
|
run_dir: temp.path().join("runtime/runs/1"),
|
||||||
bash_output_dir: temp.path().join("bash"),
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::WorkerBootstrapLayout::Direct {
|
crate::WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: temp.path().join("runtime"),
|
runtime_base: temp.path().join("runtime"),
|
||||||
bash_output_dir: temp.path().join("bash"),
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
crate::WorkerBootstrap::new(
|
crate::WorkerBootstrap::new(
|
||||||
|
|||||||
@@ -1495,11 +1495,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat
|
|||||||
let session_id = worker.session_id();
|
let session_id = worker.session_id();
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||||
let (handle, shutdown_receiver) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
handle
|
handle
|
||||||
.send(Method::submit_text(
|
.send(Method::submit_text(
|
||||||
@@ -1705,11 +1703,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
|
|||||||
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
|
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
|
||||||
let session_id = worker.session_id();
|
let session_id = worker.session_id();
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||||
let (handle, shutdown_receiver) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
@@ -1830,11 +1826,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
|||||||
let source_session = worker.session_id();
|
let source_session = worker.session_id();
|
||||||
let source_segment = worker.segment_id();
|
let source_segment = worker.segment_id();
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||||
let (handle, shutdown_receiver) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
@@ -1996,8 +1990,7 @@ async fn controller_compact_method_publishes_progress_and_clear() {
|
|||||||
]);
|
]);
|
||||||
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|||||||
@@ -323,8 +323,7 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let runtime_base = tmp.path().to_owned();
|
let runtime_base = tmp.path().to_owned();
|
||||||
std::mem::forget(tmp);
|
std::mem::forget(tmp);
|
||||||
let bash_output_dir = runtime_base.join("bash-output");
|
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
handle
|
handle
|
||||||
@@ -354,13 +353,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (handle, shutdown_rx) = WorkerController::spawn(
|
let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime"))
|
||||||
worker,
|
.await
|
||||||
&root.path().join("runtime"),
|
.unwrap();
|
||||||
&root.path().join("bash-output"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
let file = handle
|
let file = handle
|
||||||
.upload_file_with_context(
|
.upload_file_with_context(
|
||||||
@@ -420,24 +415,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
|
async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() {
|
||||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
let shared_scope = worker.scope().clone();
|
let shared_scope = worker.scope().clone();
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let worker_tmp = tempfile::tempdir().unwrap();
|
let original_rules = shared_scope.snapshot().allow_rules().to_vec();
|
||||||
let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output");
|
|
||||||
|
|
||||||
let (handle, shutdown_rx) =
|
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
.await
|
||||||
.await
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(bash_output_dir.is_dir());
|
assert_eq!(
|
||||||
assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| {
|
shared_scope.snapshot().allow_rules(),
|
||||||
rule.target == bash_output_dir
|
original_rules.as_slice()
|
||||||
&& rule.permission == manifest::Permission::Read
|
);
|
||||||
&& rule.recursive
|
|
||||||
}));
|
|
||||||
assert!(!handle.runtime_dir.path().join("bash-output").exists());
|
assert!(!handle.runtime_dir.path().join("bash-output").exists());
|
||||||
|
|
||||||
handle
|
handle
|
||||||
@@ -463,9 +454,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
cwd: workdir::WorkdirPath::root(),
|
cwd: workdir::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -473,11 +464,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
|
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
|
||||||
|
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||||
let (handle, shutdown_rx) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Shutdown {
|
.send(Method::Shutdown {
|
||||||
command: worker_command(&handle),
|
command: worker_command(&handle),
|
||||||
@@ -513,9 +502,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "sleep 0.2".to_owned(),
|
command: "sleep 0.2".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
cwd: workdir::WorkdirPath::root(),
|
cwd: workdir::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: Some("foreign-tool-call".into()),
|
tool_call_id: Some("foreign-tool-call".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -541,7 +530,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(output.status, workdir::CommandStatus::Completed);
|
assert_eq!(
|
||||||
|
output.terminal.unwrap().status,
|
||||||
|
workdir::CommandStatus::Completed
|
||||||
|
);
|
||||||
handle
|
handle
|
||||||
.send(Method::Shutdown {
|
.send(Method::Shutdown {
|
||||||
command: worker_command(&handle),
|
command: worker_command(&handle),
|
||||||
@@ -750,9 +742,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||||
|
|
||||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
|
||||||
assert!(
|
assert!(
|
||||||
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
|
WorkerController::spawn(worker, &invalid_runtime_base)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
@@ -761,9 +752,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
|
||||||
|
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||||
cwd: workdir::WorkdirPath::root(),
|
cwd: workdir::WorkdirPath::root(),
|
||||||
spill_dir: None,
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -1571,8 +1562,7 @@ permission = "write"
|
|||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = tmp.path().join("bash-output");
|
let result = WorkerController::spawn(worker, tmp.path()).await;
|
||||||
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"feature exposure must not imply delegation authority"
|
"feature exposure must not imply delegation authority"
|
||||||
@@ -2945,11 +2935,9 @@ async fn status_json_reflects_worker_name() {
|
|||||||
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
||||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||||
let (handle, mut shutdown_rx) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Shutdown {
|
.send(Method::Shutdown {
|
||||||
command: worker_command(&handle),
|
command: worker_command(&handle),
|
||||||
@@ -2984,11 +2972,9 @@ async fn shutdown_joins_socket_server_with_active_connection() {
|
|||||||
|
|
||||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||||
let (handle, shutdown_rx) =
|
.await
|
||||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let socket_path = handle.runtime_dir.socket_path();
|
let socket_path = handle.runtime_dir.socket_path();
|
||||||
let _connection = UnixStream::connect(&socket_path).await.unwrap();
|
let _connection = UnixStream::connect(&socket_path).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||||
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
unique_cookie(headers, cookie_name)
|
||||||
cookie_header.split(';').find_map(|pair| {
|
.ok()
|
||||||
let (name, value) = pair.trim().split_once('=')?;
|
.flatten()
|
||||||
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
|
.filter(|value| !value.is_empty())
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Cookie has no path/domain metadata on requests. Duplicates (including identical values
|
||||||
|
// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing.
|
||||||
|
fn unique_cookie(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
cookie_name: &str,
|
||||||
|
) -> std::result::Result<Option<String>, ()> {
|
||||||
|
let mut found = None;
|
||||||
|
for header in headers.get_all(header::COOKIE) {
|
||||||
|
let header = header.to_str().map_err(|_| ())?;
|
||||||
|
for pair in header.split(';') {
|
||||||
|
let pair = pair.trim();
|
||||||
|
let Some((name, value)) = pair.split_once('=') else {
|
||||||
|
if pair == cookie_name {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name == cookie_name {
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
found = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resolve_request_actor(
|
pub async fn resolve_request_actor(
|
||||||
@@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync(
|
|||||||
bearer: Option<String>,
|
bearer: Option<String>,
|
||||||
session: Option<String>,
|
session: Option<String>,
|
||||||
) -> Result<Option<RequestActor>> {
|
) -> Result<Option<RequestActor>> {
|
||||||
|
Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_credentials_sync(
|
||||||
|
store: &dyn ControlPlaneStore,
|
||||||
|
bearer: Option<String>,
|
||||||
|
session: Option<String>,
|
||||||
|
) -> Result<Option<(RequestActor, Option<String>)>> {
|
||||||
if let Some(token) = bearer {
|
if let Some(token) = bearer {
|
||||||
let token_hash = token_hash(&token);
|
let token_hash = token_hash(&token);
|
||||||
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
||||||
@@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
||||||
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
|
return Ok(
|
||||||
|
actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)?
|
||||||
|
.map(|actor| (actor, None)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync(
|
|||||||
if is_expired(&session.expires_at) {
|
if is_expired(&session.expires_at) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
|
return Ok(
|
||||||
|
actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)?
|
||||||
|
.map(|actor| (actor, Some(session.expires_at))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This marker is a non-authenticating reference to the existing non-revoked session row.
|
||||||
|
// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser.
|
||||||
|
// No second session store is introduced; clearing all cookies necessarily loses this evidence.
|
||||||
|
// Keep the established 14-day authentication lifetime. The additional week is only a bounded
|
||||||
|
// return-navigation window (including a week away); it never extends authentication. The store
|
||||||
|
// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami
|
||||||
|
// nor other reads refresh the marker or the row's absolute expiry.
|
||||||
|
pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60;
|
||||||
|
pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
pub fn session_marker_cookie_name(cookie_name: &str) -> String {
|
||||||
|
format!("{cookie_name}_expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_whoami(
|
||||||
|
store: std::sync::Arc<dyn ControlPlaneStore>,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
cookie_name: &str,
|
||||||
|
) -> Result<server_api::WhoamiResponse> {
|
||||||
|
let bearer = parse_bearer(headers);
|
||||||
|
// Even an empty/invalid auth cookie takes precedence over an older marker.
|
||||||
|
// An ambiguous primary is invalid, not absent; never fall back to an older marker.
|
||||||
|
let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new()));
|
||||||
|
let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name));
|
||||||
|
let executor = store.db_executor();
|
||||||
|
executor
|
||||||
|
.run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_whoami_sync(
|
||||||
|
store: &dyn ControlPlaneStore,
|
||||||
|
bearer: Option<String>,
|
||||||
|
session_token: Option<String>,
|
||||||
|
marker: Option<String>,
|
||||||
|
) -> Result<server_api::WhoamiResponse> {
|
||||||
|
use server_api::{BrowserSessionState, WhoamiResponse};
|
||||||
|
let anonymous = || WhoamiResponse {
|
||||||
|
actor: None,
|
||||||
|
session: BrowserSessionState::Anonymous {},
|
||||||
|
};
|
||||||
|
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
|
||||||
|
if let Some((actor, expires_at)) =
|
||||||
|
resolve_credentials_sync(store, bearer, session_token.clone())?
|
||||||
|
{
|
||||||
|
return Ok(WhoamiResponse {
|
||||||
|
actor: Some(actor),
|
||||||
|
session: BrowserSessionState::Authenticated { expires_at },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let hash = match session_token {
|
||||||
|
Some(token) => token_hash(&token),
|
||||||
|
None => match marker {
|
||||||
|
Some(hash) => hash,
|
||||||
|
None => return Ok(anonymous()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let Some(session) = store.resolve_browser_session(&hash)? else {
|
||||||
|
return Ok(anonymous());
|
||||||
|
};
|
||||||
|
if store.get_user(&session.user_id)?.is_none() {
|
||||||
|
return Ok(anonymous());
|
||||||
|
}
|
||||||
|
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
|
||||||
|
return Ok(WhoamiResponse {
|
||||||
|
actor: None,
|
||||||
|
session: BrowserSessionState::Expired {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A marker for a still-valid session cannot restore authentication after partial deletion.
|
||||||
|
Ok(anonymous())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
|
||||||
|
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
|
||||||
|
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
||||||
store: &S,
|
store: &S,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -274,6 +394,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(actor.user_id, "user-auth");
|
assert_eq!(actor.user_id, "user-auth");
|
||||||
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
|
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
|
||||||
|
let whoami = resolve_whoami(store.clone(), &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
whoami.session,
|
||||||
|
server_api::BrowserSessionState::Authenticated { expires_at: None }
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.resolve_api_token(&hash)
|
.resolve_api_token(&hash)
|
||||||
@@ -363,20 +490,119 @@ mod tests {
|
|||||||
assert!(futures::poll!(queued.as_mut()).is_pending());
|
assert!(futures::poll!(queued.as_mut()).is_pending());
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||||
|
let actor_result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
resolve_request_actor(store.clone(), &headers, "session"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(1),
|
std::time::Duration::from_secs(1),
|
||||||
resolve_request_actor(store, &headers, "session"),
|
resolve_whoami(store, &headers, "session"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
release.send(()).unwrap();
|
release.send(()).unwrap();
|
||||||
blocker.await.unwrap().unwrap();
|
blocker.await.unwrap().unwrap();
|
||||||
queued.await.unwrap();
|
queued.await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
actor_result.expect("actor lookup rejects capacity"),
|
||||||
|
Err(Error::DbCapacityExceeded)
|
||||||
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result.expect("capacity rejection must not wait for queued SQL"),
|
result.expect("capacity rejection must not wait for queued SQL"),
|
||||||
Err(Error::DbCapacityExceeded)
|
Err(Error::DbCapacityExceeded)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn whoami_classifies_only_live_store_backed_session_expiry() {
|
||||||
|
use server_api::BrowserSessionState;
|
||||||
|
let store = seeded_store();
|
||||||
|
for (secret, offset, revoked) in [
|
||||||
|
("recent", -60, false),
|
||||||
|
("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false),
|
||||||
|
("revoked", -60, true),
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.create_browser_session(&BrowserSessionRecord {
|
||||||
|
session_id: format!("session-{secret}"),
|
||||||
|
token_hash: token_hash(secret),
|
||||||
|
user_id: "user-auth".into(),
|
||||||
|
created_at: "1".into(),
|
||||||
|
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||||
|
revoked_at: revoked.then(|| now_rfc3339()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let cases = [
|
||||||
|
(String::new(), BrowserSessionState::Anonymous {}),
|
||||||
|
("session=unknown".into(), BrowserSessionState::Anonymous {}),
|
||||||
|
("session=recent".into(), BrowserSessionState::Expired {}),
|
||||||
|
(
|
||||||
|
format!("session_expiry={}", token_hash("recent")),
|
||||||
|
BrowserSessionState::Expired {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("session_expiry={}", token_hash("session-secret")),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_expiry=unknown".into(),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_expiry=recent".into(),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("session=unknown; session_expiry={}", token_hash("recent")),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("session=; session_expiry={}", token_hash("recent")),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
("session=revoked".into(), BrowserSessionState::Anonymous {}),
|
||||||
|
(
|
||||||
|
format!("session_expiry={}", token_hash("revoked")),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
("session=old".into(), BrowserSessionState::Anonymous {}),
|
||||||
|
(
|
||||||
|
format!("session_expiry={}", token_hash("old")),
|
||||||
|
BrowserSessionState::Anonymous {},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
"session=session-secret; session_expiry={}",
|
||||||
|
token_hash("recent")
|
||||||
|
),
|
||||||
|
BrowserSessionState::Authenticated {
|
||||||
|
expires_at: Some("9999-01-01T00:00:00Z".into()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (cookie, expected) in cases {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(header::COOKIE, cookie.parse().unwrap());
|
||||||
|
let response = resolve_whoami(store.clone(), &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.session, expected, "cookie: {cookie}");
|
||||||
|
assert_eq!(
|
||||||
|
response.actor.is_some(),
|
||||||
|
matches!(expected, BrowserSessionState::Authenticated { .. })
|
||||||
|
);
|
||||||
|
if !matches!(expected, BrowserSessionState::Authenticated { .. }) {
|
||||||
|
assert!(
|
||||||
|
resolve_request_actor(store.clone(), &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_code_uses_the_uuid_random_tail() {
|
fn user_code_uses_the_uuid_random_tail() {
|
||||||
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
|
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
|
||||||
@@ -395,4 +621,136 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(code, code.to_ascii_uppercase());
|
assert_eq!(code, code.to_ascii_uppercase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() {
|
||||||
|
let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc);
|
||||||
|
for (offset, expected) in [
|
||||||
|
(-1, false),
|
||||||
|
(0, true),
|
||||||
|
(SESSION_EXPIRY_GRACE_SECONDS - 1, true),
|
||||||
|
(SESSION_EXPIRY_GRACE_SECONDS, false),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
within_session_expiry_grace(
|
||||||
|
&expiry.to_rfc3339(),
|
||||||
|
expiry + Duration::seconds(offset)
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!within_session_expiry_grace("invalid", expiry));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() {
|
||||||
|
use server_api::BrowserSessionState;
|
||||||
|
let store = seeded_store();
|
||||||
|
let expired_hash = token_hash("expired-duplicate");
|
||||||
|
store
|
||||||
|
.create_browser_session(&BrowserSessionRecord {
|
||||||
|
session_id: "expired-duplicate".into(),
|
||||||
|
token_hash: expired_hash.clone(),
|
||||||
|
user_id: "user-auth".into(),
|
||||||
|
created_at: now_rfc3339(),
|
||||||
|
expires_at: rfc3339_after(Duration::seconds(-60)),
|
||||||
|
revoked_at: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
for cookies in [
|
||||||
|
vec!["session=session-secret; session=unknown".to_owned()],
|
||||||
|
vec!["session=session-secret; session=session-secret".to_owned()],
|
||||||
|
vec![
|
||||||
|
"session=session-secret".to_owned(),
|
||||||
|
"session=unknown".to_owned(),
|
||||||
|
],
|
||||||
|
vec![format!(
|
||||||
|
"session=; session=session-secret; session_expiry={expired_hash}"
|
||||||
|
)],
|
||||||
|
vec![format!(
|
||||||
|
"session_expiry={expired_hash}; session_expiry={expired_hash}"
|
||||||
|
)],
|
||||||
|
vec![format!("session; session_expiry={expired_hash}")],
|
||||||
|
] {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for cookie in cookies {
|
||||||
|
headers.append(header::COOKIE, cookie.parse().unwrap());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
resolve_request_actor(store.clone(), &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_whoami(store.clone(), &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.session,
|
||||||
|
BrowserSessionState::Anonymous {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::COOKIE,
|
||||||
|
format!("session=session-secret; session_expiry={expired_hash}")
|
||||||
|
.parse()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
resolve_whoami(store, &headers, "session")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.session,
|
||||||
|
BrowserSessionState::Authenticated { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_issuance_reclaims_only_rows_beyond_recovery_grace() {
|
||||||
|
let store = seeded_store();
|
||||||
|
let record = |secret: &str, offset| BrowserSessionRecord {
|
||||||
|
session_id: secret.into(),
|
||||||
|
token_hash: token_hash(secret),
|
||||||
|
user_id: "user-auth".into(),
|
||||||
|
created_at: now_rfc3339(),
|
||||||
|
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_browser_session(&token_hash("past-grace"))
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_browser_session(&token_hash("past-grace"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_browser_session(&token_hash("within-grace"))
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.resolve_browser_session(&token_hash("new-session"))
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -473,11 +473,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
|||||||
));
|
));
|
||||||
// Seed an already registered command, without launching a real process.
|
// Seed an already registered command, without launching a real process.
|
||||||
let provider_handle = CommandHandle("logging-command".to_string());
|
let provider_handle = CommandHandle("logging-command".to_string());
|
||||||
session
|
session.active_commands.lock().await.insert(
|
||||||
.active_commands
|
provider_handle.0.clone(),
|
||||||
.lock()
|
Arc::new(ExternalProviderCommandState::new(provider_handle.clone())),
|
||||||
.await
|
);
|
||||||
.insert(provider_handle.0.clone(), provider_handle.clone());
|
|
||||||
let handle = fixture
|
let handle = fixture
|
||||||
.api
|
.api
|
||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
@@ -733,7 +732,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
|
|||||||
Reason::ScopedCapabilityDenied,
|
Reason::ScopedCapabilityDenied,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}),
|
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}),
|
||||||
"command_start",
|
"command_start",
|
||||||
"denied",
|
"denied",
|
||||||
Reason::ScopedCapabilityDenied,
|
Reason::ScopedCapabilityDenied,
|
||||||
@@ -955,8 +954,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc
|
|||||||
serde_json::to_vec(&json!({
|
serde_json::to_vec(&json!({
|
||||||
"target_workdir": "checkout",
|
"target_workdir": "checkout",
|
||||||
"operation": {"operation": "command_start", "request": {
|
"operation": {"operation": "command_start", "request": {
|
||||||
"command": "body-secret", "timeout_secs": 1, "output_limit": 1024,
|
"command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||||
"cwd": "", "spill_dir": null, "tool_call_id": "body-secret"
|
"cwd": "", "tool_call_id": "body-secret"
|
||||||
}}
|
}}
|
||||||
}))
|
}))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -1084,8 +1083,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov
|
|||||||
serde_json::to_vec(&json!({
|
serde_json::to_vec(&json!({
|
||||||
"target_workdir": "checkout",
|
"target_workdir": "checkout",
|
||||||
"operation": {"operation": "command_start", "request": {
|
"operation": {"operation": "command_start", "request": {
|
||||||
"command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024,
|
"command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||||
"cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched"
|
"cwd": "", "tool_call_id": "not-dispatched"
|
||||||
}}
|
}}
|
||||||
}))
|
}))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! Provider-bridge lifecycle tests. The production bridge runs against a controlled
|
||||||
|
//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport.
|
||||||
|
use super::*;
|
||||||
|
use workdir::WorkdirSession;
|
||||||
|
|
||||||
|
fn command_fixture() -> (
|
||||||
|
Arc<ExternalProviderWorkdirSession>,
|
||||||
|
tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||||
|
) {
|
||||||
|
let (sender, receiver) = tokio::sync::mpsc::channel(16);
|
||||||
|
let session = Arc::new(ExternalProviderWorkdirSession::new(
|
||||||
|
Arc::new(ExternalProviderConnection {
|
||||||
|
grant_id: "command-grant".into(),
|
||||||
|
workdir_id: "command-workdir".into(),
|
||||||
|
provider_instance_id: "command-provider".into(),
|
||||||
|
generation: 1,
|
||||||
|
expires_at: None,
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||||
|
admission: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||||
|
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
|
sender,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
(session, receiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput {
|
||||||
|
workdir::CommandOutput {
|
||||||
|
chunks: if bytes.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![workdir::CommandByteChunk {
|
||||||
|
sequence: cursor,
|
||||||
|
stream: workdir::CommandStream::Stdout,
|
||||||
|
offset: cursor,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
next_cursor: cursor + bytes.len() as u64,
|
||||||
|
terminal: terminal.then_some(workdir::CommandTerminal {
|
||||||
|
status: workdir::CommandStatus::Cancelled,
|
||||||
|
exit_code: None,
|
||||||
|
stdout_bytes: cursor + bytes.len() as u64,
|
||||||
|
stderr_bytes: 0,
|
||||||
|
stdout_delivered_bytes: cursor + bytes.len() as u64,
|
||||||
|
stderr_delivered_bytes: 0,
|
||||||
|
truncation_reason: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(
|
||||||
|
receiver: &mut tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||||
|
) -> (
|
||||||
|
WorkdirSessionOperation,
|
||||||
|
tokio::sync::oneshot::Sender<
|
||||||
|
std::result::Result<WorkdirSessionOperationResult, WorkdirTransportError>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let ExternalProviderCommand::Operation {
|
||||||
|
operation,
|
||||||
|
response,
|
||||||
|
..
|
||||||
|
} = command
|
||||||
|
else {
|
||||||
|
panic!("expected provider operation")
|
||||||
|
};
|
||||||
|
(operation, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() {
|
||||||
|
let (session, mut receiver) = command_fixture();
|
||||||
|
session.active_commands.lock().await.insert(
|
||||||
|
"external".into(),
|
||||||
|
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||||
|
"provider".into(),
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
let reader = {
|
||||||
|
let session = session.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
session
|
||||||
|
.command_output(workdir::CommandOutputRequest {
|
||||||
|
handle: CommandHandle("external".into()),
|
||||||
|
cursor: 0,
|
||||||
|
limit: 3,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (_, response) = receive(&mut receiver).await;
|
||||||
|
response
|
||||||
|
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||||
|
0, b"one", false,
|
||||||
|
))))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3);
|
||||||
|
|
||||||
|
let reader = {
|
||||||
|
let session = session.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
session
|
||||||
|
.command_output(workdir::CommandOutputRequest {
|
||||||
|
handle: CommandHandle("external".into()),
|
||||||
|
cursor: 3,
|
||||||
|
limit: 3,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (_, pending_read) = receive(&mut receiver).await;
|
||||||
|
let closer = {
|
||||||
|
let session = session.clone();
|
||||||
|
tokio::spawn(async move { session.close().await })
|
||||||
|
};
|
||||||
|
let (operation, response) = receive(&mut receiver).await;
|
||||||
|
assert!(
|
||||||
|
matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider")
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||||
|
.unwrap();
|
||||||
|
pending_read
|
||||||
|
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||||
|
3, b"two", false,
|
||||||
|
))))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6);
|
||||||
|
|
||||||
|
for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] {
|
||||||
|
let (operation, response) = receive(&mut receiver).await;
|
||||||
|
assert!(
|
||||||
|
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait)
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||||
|
cursor, bytes, terminal,
|
||||||
|
))))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(2), closer)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_output_retains_same_binding_for_current_page_replay() {
|
||||||
|
let (session, mut receiver) = command_fixture();
|
||||||
|
session.active_commands.lock().await.insert(
|
||||||
|
"external".into(),
|
||||||
|
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||||
|
"provider".into(),
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
for _ in 0..2 {
|
||||||
|
let reader = {
|
||||||
|
let session = session.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
session
|
||||||
|
.command_output(workdir::CommandOutputRequest {
|
||||||
|
handle: CommandHandle("external".into()),
|
||||||
|
cursor: 0,
|
||||||
|
limit: 3,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (operation, response) = receive(&mut receiver).await;
|
||||||
|
assert!(
|
||||||
|
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0)
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||||
|
0, b"end", true,
|
||||||
|
))))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true));
|
||||||
|
}
|
||||||
|
assert_eq!(session.active_commands.lock().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() {
|
||||||
|
let (source, _) = command_fixture();
|
||||||
|
let worker = RuntimeWorkerRef::new("runtime", "worker");
|
||||||
|
let mut registry = WorkdirSessionRegistry::default();
|
||||||
|
let active = registry.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
"checkout".into(),
|
||||||
|
source.clone(),
|
||||||
|
CommandHandle("active".into()),
|
||||||
|
);
|
||||||
|
let mut last = None;
|
||||||
|
for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 {
|
||||||
|
let handle = registry.register_command(
|
||||||
|
worker.clone(),
|
||||||
|
"checkout".into(),
|
||||||
|
source.clone(),
|
||||||
|
CommandHandle(format!("terminal-{index}")),
|
||||||
|
);
|
||||||
|
registry.mark_terminal(&worker, &handle);
|
||||||
|
last = Some(handle);
|
||||||
|
}
|
||||||
|
assert_eq!(registry.active_command_count(&worker), 1);
|
||||||
|
assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1);
|
||||||
|
assert!(registry.command(&worker, "checkout", &active).is_some());
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.command(&worker, "checkout", &last.unwrap())
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -466,7 +466,6 @@ impl AttentionWorkerFactory {
|
|||||||
worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)),
|
worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)),
|
||||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||||
bash_output_dir: root.join("bash-output"),
|
|
||||||
},
|
},
|
||||||
worker::WorkerControllerTransport::InProcess,
|
worker::WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -799,7 +799,6 @@ impl DeliveryWorkerFactory {
|
|||||||
worker::WorkerFilesystemAuthority::None,
|
worker::WorkerFilesystemAuthority::None,
|
||||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||||
bash_output_dir: root.join("bash-output"),
|
|
||||||
},
|
},
|
||||||
worker::WorkerControllerTransport::InProcess,
|
worker::WorkerControllerTransport::InProcess,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
|
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
|
||||||
self.with_immediate_transaction(|conn| {
|
self.with_immediate_transaction(|conn| {
|
||||||
activation::require_active(conn, &record.user_id)?;
|
activation::require_active(conn, &record.user_id)?;
|
||||||
|
// Retain the real row throughout the non-authenticating recovery window, not
|
||||||
|
// forever. Issuance amortizes reclamation without extending marker lifetime or
|
||||||
|
// introducing another store/scheduler. Inactive stores do not grow between issues.
|
||||||
|
let cutoff = (chrono::Utc::now()
|
||||||
|
- chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS))
|
||||||
|
.to_rfc3339();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL",
|
||||||
|
params![cutoff],
|
||||||
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
|
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||||
|
|||||||
+57
-1
@@ -443,6 +443,56 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"BrowserSessionState": {
|
||||||
|
"description": "Browser navigation evidence, not an authorization grant or a generic 401 classification.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "anonymous",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"expires_at": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"const": "authenticated",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "expired",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"BrowserTicketState": {
|
"BrowserTicketState": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"open",
|
"open",
|
||||||
@@ -15092,8 +15142,14 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"$ref": "#/components/schemas/BrowserSessionState"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"session"
|
||||||
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"WorkerBusyState": {
|
"WorkerBusyState": {
|
||||||
@@ -18099,7 +18155,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01"
|
"x-yoi-source-digest": "sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ sha2.workspace = true
|
|||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
yoi-workspace-server.workspace = true
|
||||||
server-api.workspace = true
|
server-api.workspace = true
|
||||||
webauthn-rs-proto = "0.5.5"
|
webauthn-rs-proto = "0.5.5"
|
||||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。
|
||||||
|
// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。
|
||||||
|
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
|
||||||
|
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { extname, join, normalize } from "@std/path";
|
||||||
|
import { type Browser, type BrowserContext, chromium } from "playwright";
|
||||||
|
|
||||||
|
const [backend, origin, build, handle] = Deno.args;
|
||||||
|
if (!backend || !origin || !build || !handle) {
|
||||||
|
throw new Error(
|
||||||
|
"Expected isolated Backend URL, browser origin, production build, handle",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
};
|
||||||
|
const server = Deno.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: Number(new URL(origin).port),
|
||||||
|
onListen() {},
|
||||||
|
}, async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") {
|
||||||
|
if (request.headers.get("origin") !== origin) {
|
||||||
|
return new Response(null, { status: 403 });
|
||||||
|
}
|
||||||
|
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||||
|
const fixture = await bridge("expired-session", {}) as {
|
||||||
|
set_cookies: string[];
|
||||||
|
};
|
||||||
|
const headers = new Headers({ "cache-control": "no-store" });
|
||||||
|
for (const cookie of fixture.set_cookies) {
|
||||||
|
headers.append("set-cookie", cookie);
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 204, headers });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET"
|
||||||
|
) {
|
||||||
|
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||||
|
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||||
|
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||||
|
) ?? false;
|
||||||
|
return Response.json(present, { headers: { "cache-control": "no-store" } });
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
|
||||||
|
return await fetch(new URL(url.pathname + url.search, backend), {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body: ["GET", "HEAD"].includes(request.method)
|
||||||
|
? undefined
|
||||||
|
: await request.arrayBuffer(),
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||||
|
if (relative.startsWith("..")) {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
|
let file = join(build, relative);
|
||||||
|
try {
|
||||||
|
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||||
|
return new Response(await Deno.readFile(file), {
|
||||||
|
headers: {
|
||||||
|
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||||
|
if (url.pathname.startsWith("/_app/")) {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(await Deno.readFile(join(build, "index.html")), {
|
||||||
|
headers: { "content-type": mime[".html"] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
|
||||||
|
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
|
||||||
|
.getReader();
|
||||||
|
let pending = "";
|
||||||
|
async function bridge(operation: string, options: unknown): Promise<unknown> {
|
||||||
|
await Deno.stdout.write(
|
||||||
|
new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"),
|
||||||
|
);
|
||||||
|
while (!pending.includes("\n")) {
|
||||||
|
const { done, value } = await input.read();
|
||||||
|
if (done) throw new Error("ResidentAuthenticator disconnected");
|
||||||
|
pending += value;
|
||||||
|
if (pending.length > 1024 * 1024) throw new Error("Oversized credential");
|
||||||
|
}
|
||||||
|
const newline = pending.indexOf("\n");
|
||||||
|
const credential = JSON.parse(pending.slice(0, newline));
|
||||||
|
pending = pending.slice(newline + 1);
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
let browser: Browser | undefined;
|
||||||
|
let context: BrowserContext | undefined;
|
||||||
|
const terminate = () => {
|
||||||
|
void (async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await server.shutdown();
|
||||||
|
Deno.exit(143);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
Deno.addSignalListener("SIGTERM", terminate);
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true, timeout: 15_000 });
|
||||||
|
context = await browser.newContext();
|
||||||
|
context.setDefaultTimeout(10_000);
|
||||||
|
await context.route(
|
||||||
|
"**/*",
|
||||||
|
(route) =>
|
||||||
|
new URL(route.request().url()).origin === origin
|
||||||
|
? route.continue()
|
||||||
|
: route.abort(),
|
||||||
|
);
|
||||||
|
await context.exposeBinding(
|
||||||
|
"residentCredential",
|
||||||
|
(_source, operation: string, options: unknown) =>
|
||||||
|
bridge(operation, options),
|
||||||
|
);
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
const encode = (value: unknown): unknown => {
|
||||||
|
if (value instanceof ArrayBuffer) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(value)))
|
||||||
|
.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(encode);
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, item]) => [key, encode(item)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const bytes = (value: string) => {
|
||||||
|
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
return Uint8Array.from(
|
||||||
|
atob(base64),
|
||||||
|
(character) => character.charCodeAt(0),
|
||||||
|
).buffer;
|
||||||
|
};
|
||||||
|
for (const operation of ["create", "get"] as const) {
|
||||||
|
Object.defineProperty(navigator.credentials, operation, {
|
||||||
|
configurable: true,
|
||||||
|
value: async (
|
||||||
|
options: CredentialCreationOptions & CredentialRequestOptions,
|
||||||
|
) => {
|
||||||
|
const bridge = (window as unknown as {
|
||||||
|
residentCredential: (
|
||||||
|
operation: string,
|
||||||
|
options: unknown,
|
||||||
|
) => Promise<{
|
||||||
|
id: string;
|
||||||
|
rawId: string;
|
||||||
|
response: Record<string, string | string[] | null>;
|
||||||
|
extensions?: unknown;
|
||||||
|
}>;
|
||||||
|
}).residentCredential;
|
||||||
|
const wire = await bridge(operation, {
|
||||||
|
publicKey: encode(options.publicKey),
|
||||||
|
});
|
||||||
|
const response = Object.fromEntries(
|
||||||
|
Object.entries(wire.response).map((
|
||||||
|
[key, value],
|
||||||
|
) => [key, typeof value === "string" ? bytes(value) : value]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: wire.id,
|
||||||
|
rawId: bytes(wire.rawId),
|
||||||
|
type: "public-key",
|
||||||
|
getClientExtensionResults: () => wire.extensions ?? {},
|
||||||
|
response: {
|
||||||
|
...response,
|
||||||
|
getTransports: () => wire.response.transports ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const at = async (path: string) => {
|
||||||
|
await page.waitForURL((url) =>
|
||||||
|
url.origin === origin && url.pathname + url.search + url.hash === path
|
||||||
|
);
|
||||||
|
console.error(`navigation-ready: ${new URL(path, origin).pathname}`);
|
||||||
|
};
|
||||||
|
const whoami = async () => {
|
||||||
|
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
||||||
|
assertEquals(response.status(), 200);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
const account = "/account?view=passkeys&filter=a%2Fb#selected";
|
||||||
|
const privateRequests: string[] = [];
|
||||||
|
const observePrivate = (request: { url(): string }) => {
|
||||||
|
const path = new URL(request.url()).pathname;
|
||||||
|
if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) {
|
||||||
|
privateRequests.push(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page.on("request", observePrivate);
|
||||||
|
await page.goto(origin + account);
|
||||||
|
await at("/");
|
||||||
|
await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor();
|
||||||
|
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||||
|
assertEquals(
|
||||||
|
privateRequests,
|
||||||
|
[],
|
||||||
|
"anonymous gate must run before private loaders",
|
||||||
|
);
|
||||||
|
page.off("request", observePrivate);
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`${origin}/register?${new URLSearchParams({ returnTo: account })}`,
|
||||||
|
);
|
||||||
|
await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle);
|
||||||
|
await page.getByLabel("Display name", { exact: true }).fill(
|
||||||
|
"Real browser E2E",
|
||||||
|
);
|
||||||
|
const registration = page.waitForResponse((response) =>
|
||||||
|
new URL(response.url()).pathname ===
|
||||||
|
"/api/auth/passkeys/registration/complete"
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Register new account", exact: true })
|
||||||
|
.click();
|
||||||
|
assertEquals((await registration).status(), 200);
|
||||||
|
await at(account);
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||||
|
const identity = await whoami();
|
||||||
|
assertEquals(identity.actor?.handle, handle);
|
||||||
|
assertEquals(identity.session.kind, "authenticated");
|
||||||
|
assert(typeof identity.session.expires_at === "string");
|
||||||
|
const cookies = await context.cookies(origin);
|
||||||
|
const session = cookies.find((cookie) =>
|
||||||
|
cookie.name === "yoi_workspace_session"
|
||||||
|
);
|
||||||
|
const marker = cookies.find((cookie) =>
|
||||||
|
cookie.name === "yoi_workspace_session_expiry"
|
||||||
|
);
|
||||||
|
assert(session && marker, "Backend must issue both cookies");
|
||||||
|
for (const cookie of [session, marker]) {
|
||||||
|
assert(cookie.httpOnly);
|
||||||
|
assertEquals(cookie.sameSite, "Lax");
|
||||||
|
}
|
||||||
|
const seconds = (Date.parse(identity.session.expires_at) / 1000) -
|
||||||
|
Date.now() / 1000;
|
||||||
|
assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400);
|
||||||
|
assert(
|
||||||
|
Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) <
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2);
|
||||||
|
await page.goto(origin + account);
|
||||||
|
await at(account);
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||||
|
await page.reload();
|
||||||
|
await at(account);
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||||
|
|
||||||
|
// 明示logoutは実Serverのrevoke + 両cookie削除を通る。
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||||
|
await at("/");
|
||||||
|
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||||
|
const afterLogout = await context.cookies(origin);
|
||||||
|
assert(
|
||||||
|
!afterLogout.some((cookie) =>
|
||||||
|
[session.name, marker.name].includes(cookie.name)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const revoked = await page.request.get(`${origin}/api/auth/whoami`, {
|
||||||
|
headers: {
|
||||||
|
cookie:
|
||||||
|
`${session.name}=${session.value}; ${marker.name}=${marker.value}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assertEquals((await revoked.json()).session, { kind: "anonymous" });
|
||||||
|
await page.goto(origin + account);
|
||||||
|
await at("/");
|
||||||
|
|
||||||
|
// 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。
|
||||||
|
await page.goto(
|
||||||
|
`${origin}/login?${new URLSearchParams({ returnTo: account })}`,
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
const login = page.waitForResponse((response) =>
|
||||||
|
new URL(response.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||||
|
.click();
|
||||||
|
assertEquals((await login).status(), 200);
|
||||||
|
await at(account);
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||||
|
assertEquals((await whoami()).actor?.handle, handle);
|
||||||
|
|
||||||
|
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
|
||||||
|
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
|
||||||
|
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||||
|
const catalogResponse = () =>
|
||||||
|
page.waitForResponse((response) =>
|
||||||
|
new URL(response.url()).pathname === "/api/workspaces" &&
|
||||||
|
response.request().method() === "GET"
|
||||||
|
);
|
||||||
|
const initialCatalog = catalogResponse();
|
||||||
|
await page.goto(origin + expiredTarget);
|
||||||
|
assertEquals((await initialCatalog).status(), 200);
|
||||||
|
await at(expiredTarget);
|
||||||
|
await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor();
|
||||||
|
const issuedStatus = await page.evaluate(async () => {
|
||||||
|
const response = await fetch("/__e2e/expired-session", {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
return response.status;
|
||||||
|
});
|
||||||
|
assertEquals(issuedStatus, 204);
|
||||||
|
const initiallyPresent = await page.evaluate(async () =>
|
||||||
|
await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" }))
|
||||||
|
.json()
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
initiallyPresent,
|
||||||
|
true,
|
||||||
|
"short-lived primary must first reach Chromium",
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const deadline = performance.now() + 10_000;
|
||||||
|
do {
|
||||||
|
const response = await fetch("/__e2e/primary-cookie-present", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (response.ok && await response.json() === false) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
} while (performance.now() < deadline);
|
||||||
|
throw new Error("Primary session cookie did not naturally expire");
|
||||||
|
});
|
||||||
|
// 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。
|
||||||
|
const expiredCookieNames = (await context.cookies(origin)).map((cookie) =>
|
||||||
|
cookie.name
|
||||||
|
);
|
||||||
|
assert(!expiredCookieNames.includes(session.name));
|
||||||
|
assert(expiredCookieNames.includes(marker.name));
|
||||||
|
assertEquals(await whoami(), {
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "expired" },
|
||||||
|
});
|
||||||
|
privateRequests.length = 0;
|
||||||
|
page.on("request", observePrivate);
|
||||||
|
const expiredWhoami = page.waitForResponse((response) =>
|
||||||
|
new URL(response.url()).pathname === "/api/auth/whoami"
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
const expiredResponse = await expiredWhoami;
|
||||||
|
assertEquals(expiredResponse.status(), 200);
|
||||||
|
// SPAがloginへ移った後の旧document bodyをCDPから読み直さない。
|
||||||
|
// 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。
|
||||||
|
const expiredLogin = `/login?${new URLSearchParams({
|
||||||
|
returnTo: expiredTarget,
|
||||||
|
})}`;
|
||||||
|
await at(expiredLogin);
|
||||||
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
privateRequests,
|
||||||
|
[],
|
||||||
|
"expired reload must gate the real private catalog loader",
|
||||||
|
);
|
||||||
|
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
|
||||||
|
await page.reload();
|
||||||
|
await at(expiredLogin);
|
||||||
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
privateRequests,
|
||||||
|
[],
|
||||||
|
"login reload must not lose or privately load returnTo",
|
||||||
|
);
|
||||||
|
page.off("request", observePrivate);
|
||||||
|
const recoveredCatalog = catalogResponse();
|
||||||
|
const relogin = page.waitForResponse((response) =>
|
||||||
|
new URL(response.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||||
|
.click();
|
||||||
|
assertEquals((await relogin).status(), 200);
|
||||||
|
await at(expiredTarget);
|
||||||
|
assertEquals((await recoveredCatalog).status(), 200);
|
||||||
|
await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor();
|
||||||
|
const recovered = await whoami();
|
||||||
|
assertEquals(recovered.session.kind, "authenticated");
|
||||||
|
assertEquals(recovered.actor?.user_id, identity.actor.user_id);
|
||||||
|
assertEquals(recovered.actor?.handle, handle);
|
||||||
|
await page.goto(origin + account);
|
||||||
|
await at(account);
|
||||||
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||||
|
|
||||||
|
// fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは
|
||||||
|
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
|
||||||
|
await context.clearCookies({ name: session.name });
|
||||||
|
const partial = await whoami();
|
||||||
|
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
|
||||||
|
await page.reload();
|
||||||
|
await at("/");
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.goto(origin + account);
|
||||||
|
await at("/");
|
||||||
|
assertEquals((await whoami()).session, { kind: "anonymous" });
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context?.close();
|
||||||
|
await browser?.close();
|
||||||
|
await server.shutdown();
|
||||||
|
Deno.removeSignalListener("SIGTERM", terminate);
|
||||||
|
}
|
||||||
|
await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n'));
|
||||||
|
// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。
|
||||||
|
Deno.exit(0);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
|
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
|
||||||
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
|
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
|
||||||
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。
|
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。
|
||||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||||
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||||
//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、
|
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
|
||||||
//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。
|
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||||
|
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs::OpenOptions;
|
use std::fs::OpenOptions;
|
||||||
@@ -29,6 +30,9 @@ mod resident_model;
|
|||||||
use resident_model::ResidentAuthenticator;
|
use resident_model::ResidentAuthenticator;
|
||||||
use yoi_e2e::WorkerE2eEnvironment;
|
use yoi_e2e::WorkerE2eEnvironment;
|
||||||
|
|
||||||
|
#[path = "auth_passkeys/browser_sessions.rs"]
|
||||||
|
mod browser_sessions;
|
||||||
|
|
||||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||||
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。
|
||||||
|
//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。
|
||||||
|
//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、
|
||||||
|
//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。
|
||||||
|
//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。
|
||||||
|
//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。
|
||||||
|
//! OS/物理認証器と14日経過したServer時計は検証しない。
|
||||||
|
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
|
||||||
|
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
|
||||||
|
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
|
||||||
|
use super::*;
|
||||||
|
use std::io::{BufRead, BufReader, Read};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use yoi_workspace_server::auth::{
|
||||||
|
SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id,
|
||||||
|
session_marker_cookie_name, session_set_cookie, token_hash,
|
||||||
|
};
|
||||||
|
use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore};
|
||||||
|
|
||||||
|
// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。
|
||||||
|
fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value> {
|
||||||
|
// default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。
|
||||||
|
let database = environment
|
||||||
|
.server_data_dir
|
||||||
|
.join("server.db")
|
||||||
|
.canonicalize()?;
|
||||||
|
if !database.starts_with(environment.root.canonicalize()?) {
|
||||||
|
return Err("expired-session fixture DB is outside its environment".into());
|
||||||
|
}
|
||||||
|
let store = SqliteWorkspaceStore::open(database)?;
|
||||||
|
let user = store
|
||||||
|
.get_user_by_handle(&environment.account_handle)?
|
||||||
|
.ok_or("browser registration must create the fixture user first")?;
|
||||||
|
let token = mint_secret("yoi_sess");
|
||||||
|
let hash = token_hash(&token);
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
// Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。
|
||||||
|
store.create_browser_session(&BrowserSessionRecord {
|
||||||
|
session_id: new_id("session"),
|
||||||
|
token_hash: hash.clone(),
|
||||||
|
user_id: user.user_id,
|
||||||
|
created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(),
|
||||||
|
expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(),
|
||||||
|
revoked_at: None,
|
||||||
|
})?;
|
||||||
|
let policy = SessionCookiePolicy {
|
||||||
|
cookie_name: "yoi_workspace_session",
|
||||||
|
path: "/",
|
||||||
|
domain: None,
|
||||||
|
secure: false,
|
||||||
|
};
|
||||||
|
let marker_name = session_marker_cookie_name(policy.cookie_name);
|
||||||
|
Ok(json!({"set_cookies": [
|
||||||
|
session_set_cookie(policy, &token, 2),
|
||||||
|
session_set_cookie(SessionCookiePolicy {
|
||||||
|
cookie_name: &marker_name,
|
||||||
|
..policy
|
||||||
|
}, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60),
|
||||||
|
]}))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowserProcess(Option<Child>);
|
||||||
|
impl BrowserProcess {
|
||||||
|
fn child(&mut self) -> &mut Child {
|
||||||
|
self.0.as_mut().expect("browser process retained")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for BrowserProcess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(mut child) = self.0.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pid = child.id() as libc::pid_t;
|
||||||
|
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
let (send, receive) = mpsc::channel();
|
||||||
|
let waiter = std::thread::spawn(move || {
|
||||||
|
let _ = send.send(child.wait());
|
||||||
|
});
|
||||||
|
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = waiter.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires production Web assets, Deno and Playwright Chromium"]
|
||||||
|
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result
|
||||||
|
{
|
||||||
|
// Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let build = root.join("web/workspace/build");
|
||||||
|
if !build.join("index.html").is_file() {
|
||||||
|
return Err("build web/workspace production assets before selecting browser E2E".into());
|
||||||
|
}
|
||||||
|
let mut environment = WorkerE2eEnvironment::new()?;
|
||||||
|
let reservation = TcpListener::bind("127.0.0.1:0")?;
|
||||||
|
let port = reservation.local_addr()?.port();
|
||||||
|
let origin = format!("http://localhost:{port}");
|
||||||
|
std::fs::write(
|
||||||
|
&environment.server_config,
|
||||||
|
format!("[browser]\npublic_url = \"{origin}\"\n"),
|
||||||
|
)?;
|
||||||
|
environment.start_empty_backend()?;
|
||||||
|
let admin =
|
||||||
|
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
|
||||||
|
let bearer = admin["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("missing setup token")?;
|
||||||
|
let base = environment
|
||||||
|
.server_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("missing Backend URL")?;
|
||||||
|
success(
|
||||||
|
http_client()?
|
||||||
|
.put(format!("{base}/api/hosting/settings"))
|
||||||
|
.bearer_auth(bearer)
|
||||||
|
.json(&json!({"self_registration_enabled": true}))
|
||||||
|
.send()?,
|
||||||
|
)?;
|
||||||
|
drop(reservation);
|
||||||
|
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
|
||||||
|
let mut process = BrowserProcess(Some(
|
||||||
|
Command::new("deno")
|
||||||
|
.current_dir(&root)
|
||||||
|
.args([
|
||||||
|
"run",
|
||||||
|
"--config",
|
||||||
|
"tools/web-ux/deno.json",
|
||||||
|
"--no-lock",
|
||||||
|
"--allow-env",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"--allow-write",
|
||||||
|
"--allow-run",
|
||||||
|
"--allow-sys",
|
||||||
|
"tests/e2e/browser/auth_sessions.ts",
|
||||||
|
base,
|
||||||
|
&origin,
|
||||||
|
build.to_str().ok_or("non-UTF8 build path")?,
|
||||||
|
&environment.account_handle,
|
||||||
|
])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(stderr)
|
||||||
|
.spawn()?,
|
||||||
|
));
|
||||||
|
let stdout = process
|
||||||
|
.child()
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or("missing browser stdout")?;
|
||||||
|
let mut stdin = process
|
||||||
|
.child()
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or("missing browser stdin")?;
|
||||||
|
let (send, receive) = mpsc::sync_channel(4);
|
||||||
|
// bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
match reader.by_ref().take(1024 * 1024).read_line(&mut line) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) if line.ends_with('\n') => {
|
||||||
|
if send.send(Ok(line)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = send.send(Err("invalid or oversized browser IPC"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(120);
|
||||||
|
let mut authenticator = ResidentAuthenticator::new(true);
|
||||||
|
let mut completed = false;
|
||||||
|
loop {
|
||||||
|
let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||||
|
{
|
||||||
|
Ok(message) => message.map_err(std::io::Error::other)?,
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()),
|
||||||
|
};
|
||||||
|
let request: Value = serde_json::from_str(&message)?;
|
||||||
|
let credential = match request["operation"].as_str() {
|
||||||
|
Some("create") => serde_json::to_value(authenticator.do_registration(
|
||||||
|
origin.parse()?,
|
||||||
|
serde_json::from_value(request["options"].clone())?,
|
||||||
|
)?)?,
|
||||||
|
Some("get") => serde_json::to_value(authenticator.do_authentication(
|
||||||
|
origin.parse()?,
|
||||||
|
serde_json::from_value(request["options"].clone())?,
|
||||||
|
)?)?,
|
||||||
|
Some("expired-session") => expired_session_fixture(&environment)?,
|
||||||
|
Some("done") => {
|
||||||
|
completed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => return Err("unexpected browser IPC operation".into()),
|
||||||
|
};
|
||||||
|
serde_json::to_writer(&mut stdin, &credential)?;
|
||||||
|
writeln!(stdin)?;
|
||||||
|
stdin.flush()?;
|
||||||
|
}
|
||||||
|
reader.join().map_err(|_| "browser reader panicked")?;
|
||||||
|
assert!(
|
||||||
|
completed,
|
||||||
|
"browser did not complete; see browser.stderr.log"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
process.child().wait()?.success(),
|
||||||
|
"browser E2E failed; see browser.stderr.log"
|
||||||
|
);
|
||||||
|
drop(process);
|
||||||
|
let report = environment.cleanup()?;
|
||||||
|
assert!(report.cleanup_success);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s
|
|||||||
|
|
||||||
export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, };
|
export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, };
|
||||||
|
|
||||||
export type WhoamiResponse = { actor: RequestActor | null, };
|
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
|
||||||
|
|
||||||
|
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
|
||||||
|
|
||||||
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
|
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01)
|
// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
browserSession,
|
||||||
|
recheckAfterUnauthorized,
|
||||||
|
} from "#lib/workspace/auth/session-state.ts";
|
||||||
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
|
import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
|
||||||
import type {
|
import type {
|
||||||
SkillCatalogResponse,
|
SkillCatalogResponse,
|
||||||
@@ -102,7 +106,11 @@ export async function loadJson<T>(
|
|||||||
): Promise<ApiResult<T>> {
|
): Promise<ApiResult<T>> {
|
||||||
let stage: "request" | "decode" | "validate" = "request";
|
let stage: "request" | "decode" | "validate" = "request";
|
||||||
try {
|
try {
|
||||||
|
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||||
const response = await fetchFn(path, init);
|
const response = await fetchFn(path, init);
|
||||||
|
if (response.status === 401) {
|
||||||
|
recheckAfterUnauthorized(authenticationGeneration);
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (policy) {
|
if (policy) {
|
||||||
await response.body?.cancel();
|
await response.body?.cancel();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts';
|
import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts';
|
||||||
|
import { CLEAR_RETURN_EVENT } from './session-state.ts';
|
||||||
import { BrowserAuth } from './browser-session.svelte.ts';
|
import { BrowserAuth } from './browser-session.svelte.ts';
|
||||||
import { loadRegistrationPolicy } from './hosting.ts';
|
import { loadRegistrationPolicy } from './hosting.ts';
|
||||||
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
||||||
@@ -58,11 +59,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forget() {
|
||||||
|
await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||||
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); };
|
||||||
|
window.addEventListener(CLEAR_RETURN_EVENT, clearReturn);
|
||||||
void auth.refresh();
|
void auth.refresh();
|
||||||
void refreshPolicy();
|
void refreshPolicy();
|
||||||
window.addEventListener('focus', refreshPolicy);
|
window.addEventListener('focus', refreshPolicy);
|
||||||
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); };
|
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,11 +80,16 @@
|
|||||||
{:else if auth.session.kind === 'error'}
|
{:else if auth.session.kind === 'error'}
|
||||||
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
|
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
|
||||||
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
|
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
|
||||||
|
<button type="button" disabled={auth.busy} onclick={forget}>Forget this session</button>
|
||||||
{:else if auth.actor}
|
{:else if auth.actor}
|
||||||
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
|
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
|
||||||
<a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
|
<a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
|
||||||
<a href="/account">Open Account</a>
|
<a href="/account">Open Account</a>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if auth.session.session.kind === 'expired'}
|
||||||
|
<p role="status">Your browser session expired. Log in to return to the requested page.</p>
|
||||||
|
<button type="button" disabled={auth.busy} onclick={forget}>Forget this session</button>
|
||||||
|
{/if}
|
||||||
{#if mode === 'register'}
|
{#if mode === 'register'}
|
||||||
{#if policyLoading}
|
{#if policyLoading}
|
||||||
<p role="status" class="muted">Checking registration availability…</p>
|
<p role="status" class="muted">Checking registration availability…</p>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import { goto, invalidate } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { browserSession, observeBrowserSession, type SessionSnapshot } from './session-state.ts';
|
||||||
|
import { isPublicPage, loginUrl } from './routes.ts';
|
||||||
|
import './auth-page.css';
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
let session = $state<SessionSnapshot>(browserSession.snapshot);
|
||||||
|
let admitted = $state(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.session.kind === 'authenticated');
|
||||||
|
const actorKey = $derived(session.kind === 'ready' && session.value.actor
|
||||||
|
? `${session.value.actor.user_id}:${session.value.actor.account_id}` : null);
|
||||||
|
let admittedActor = $state<string | null>(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.actor
|
||||||
|
? `${browserSession.snapshot.value.actor.user_id}:${browserSession.snapshot.value.actor.account_id}` : null);
|
||||||
|
const sameActor = $derived(admittedActor === null || admittedActor === actorKey);
|
||||||
|
const protectedPage = $derived(!isPublicPage(page.url.pathname));
|
||||||
|
let navigating = false;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
observeBrowserSession();
|
||||||
|
return browserSession.subscribe((snapshot) => {
|
||||||
|
// 子onMountの再確認より前に入場を記録する。effect後回しだとloadingの度に
|
||||||
|
// childを再mountし、onMount→whoami→unmountの反復を作ってしまう。
|
||||||
|
if (snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated') admitted = true;
|
||||||
|
session = snapshot;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!protectedPage || session.kind !== 'ready') return;
|
||||||
|
const state = session.value.session.kind;
|
||||||
|
if (state === 'authenticated') {
|
||||||
|
if (!sameActor) {
|
||||||
|
// 別tabで主体が変わった場合、旧主体で取得したloader/subscriptionを再利用しない。
|
||||||
|
if (!navigating) { navigating = true; location.reload(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admittedActor = actorKey;
|
||||||
|
admitted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admitted = false;
|
||||||
|
if (navigating) return;
|
||||||
|
navigating = true;
|
||||||
|
const target = state === 'expired'
|
||||||
|
? loginUrl(location.pathname + location.search + location.hash) : '/';
|
||||||
|
void goto(target, { replaceState: true }).finally(() => { navigating = false; });
|
||||||
|
});
|
||||||
|
async function retry() {
|
||||||
|
await browserSession.refresh();
|
||||||
|
await invalidate('auth:session');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !protectedPage}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
{#if session.kind === 'loading'}
|
||||||
|
<div class="auth-page"><p role="status">Loading session…</p></div>
|
||||||
|
{:else if session.kind === 'error'}
|
||||||
|
<div class="auth-page">
|
||||||
|
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
|
||||||
|
<button type="button" onclick={retry}>Retry session</button>
|
||||||
|
<a href="/">Yoi home</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
|
||||||
|
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
|
||||||
|
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential {
|
|||||||
},
|
},
|
||||||
} as unknown as Credential;
|
} as unknown as Credential;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function whoamiPayload(actor: unknown = null) {
|
||||||
|
return {
|
||||||
|
actor,
|
||||||
|
session: actor === null
|
||||||
|
? { kind: "anonymous" }
|
||||||
|
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { AuthRequestError, loadWhoami } from "./api.ts";
|
import { AuthRequestError } from "./api.ts";
|
||||||
|
import {
|
||||||
|
browserSession,
|
||||||
|
observeBrowserSession,
|
||||||
|
sessionMutation,
|
||||||
|
} from "./session-state.ts";
|
||||||
import { notifySessionChanged } from "./hosting.ts";
|
import { notifySessionChanged } from "./hosting.ts";
|
||||||
import type { RequestActor } from "./model.ts";
|
import type { RequestActor, WhoamiResponse } from "./model.ts";
|
||||||
|
|
||||||
export type BrowserSession =
|
export type BrowserSession =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "error" }
|
| { kind: "error" }
|
||||||
| { kind: "ready"; actor: RequestActor | null };
|
| ({ kind: "ready" } & WhoamiResponse);
|
||||||
|
|
||||||
export function authErrorMessage(error: unknown): string {
|
export function authErrorMessage(error: unknown): string {
|
||||||
if (error instanceof AuthRequestError) {
|
if (error instanceof AuthRequestError) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
return "Your session or authentication challenge expired. Log in again or restart the Passkey operation.";
|
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
|
||||||
}
|
}
|
||||||
if (error.status === 400 || error.status === 410) {
|
if (error.status === 400 || error.status === 410) {
|
||||||
return "The request or authentication challenge is invalid or expired. Restart the operation.";
|
return "The request or authentication challenge is invalid or expired. Restart the operation.";
|
||||||
@@ -35,61 +40,77 @@ export class BrowserAuth {
|
|||||||
busy = $state(false);
|
busy = $state(false);
|
||||||
error = $state("");
|
error = $state("");
|
||||||
status = $state("");
|
status = $state("");
|
||||||
private request = 0;
|
private unsubscribe: () => void;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
private controller: AbortController | null = null;
|
private controller: AbortController | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
observeBrowserSession();
|
||||||
|
this.unsubscribe = browserSession.subscribe((snapshot) => {
|
||||||
|
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
|
||||||
|
if (this.busy && snapshot.kind === "loading") return;
|
||||||
|
if (!this.disposed) {
|
||||||
|
this.session = snapshot.kind === "ready"
|
||||||
|
? { kind: "ready", ...snapshot.value }
|
||||||
|
: snapshot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get actor(): RequestActor | null {
|
get actor(): RequestActor | null {
|
||||||
return this.session.kind === "ready" ? this.session.actor : null;
|
return this.session.kind === "ready" ? this.session.actor : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
if (this.busy || this.disposed) return;
|
if (this.busy || this.disposed) return;
|
||||||
await this.readSession();
|
await browserSession.refresh();
|
||||||
}
|
|
||||||
|
|
||||||
private async readSession(): Promise<void> {
|
|
||||||
const current = ++this.request;
|
|
||||||
this.session = { kind: "loading" };
|
|
||||||
try {
|
|
||||||
const result = await loadWhoami();
|
|
||||||
if (this.disposed || current !== this.request) return;
|
|
||||||
this.session = { kind: "ready", actor: result.actor };
|
|
||||||
} catch {
|
|
||||||
if (this.disposed || current !== this.request) return;
|
|
||||||
this.session = { kind: "error" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(
|
async run(
|
||||||
pending: string,
|
pending: string,
|
||||||
success: string,
|
success: string,
|
||||||
operation: (signal: AbortSignal) => Promise<unknown>,
|
operation: (signal: AbortSignal) => Promise<unknown>,
|
||||||
|
intent: "authentication" | "logout" = "authentication",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (this.busy || this.disposed || this.session.kind !== "ready") {
|
if (
|
||||||
|
this.busy || this.disposed ||
|
||||||
|
(intent !== "logout" && this.session.kind !== "ready")
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
++this.request;
|
sessionMutation(intent);
|
||||||
|
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.controller = controller;
|
this.controller = controller;
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.status = pending;
|
this.status = pending;
|
||||||
try {
|
try {
|
||||||
await operation(controller.signal);
|
await operation(controller.signal);
|
||||||
if (this.disposed) return false;
|
const superseded =
|
||||||
|
authenticationGeneration !== browserSession.authenticationGeneration;
|
||||||
|
sessionMutation(intent);
|
||||||
|
const confirmationGeneration = browserSession.authenticationGeneration;
|
||||||
|
await browserSession.refresh();
|
||||||
|
if (
|
||||||
|
this.disposed || superseded ||
|
||||||
|
confirmationGeneration !== browserSession.authenticationGeneration
|
||||||
|
) {
|
||||||
|
if (!this.disposed) this.status = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
notifySessionChanged();
|
notifySessionChanged();
|
||||||
await this.readSession();
|
|
||||||
if (this.disposed) return false;
|
|
||||||
const confirmed = this.session.kind === "ready";
|
const confirmed = this.session.kind === "ready";
|
||||||
this.status = confirmed ? success : "";
|
this.status = confirmed ? success : "";
|
||||||
return confirmed;
|
return confirmed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.disposed) return false;
|
if (!this.disposed) {
|
||||||
this.error = authErrorMessage(error);
|
this.error = authErrorMessage(error);
|
||||||
this.status = "";
|
this.status = "";
|
||||||
|
}
|
||||||
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
||||||
await this.readSession();
|
sessionMutation(intent);
|
||||||
|
await browserSession.refresh();
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (!this.disposed) {
|
if (!this.disposed) {
|
||||||
@@ -101,7 +122,7 @@ export class BrowserAuth {
|
|||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
++this.request;
|
this.unsubscribe();
|
||||||
this.controller?.abort();
|
this.controller?.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||||
assertEquals(parseWhoamiResponse({ actor: null }), { actor: null });
|
assertEquals(
|
||||||
|
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
|
||||||
|
{ actor: null, session: { kind: "anonymous" } },
|
||||||
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
parseWhoamiResponse({
|
parseWhoamiResponse({
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
actor: {
|
actor: {
|
||||||
user_id: "user-1",
|
user_id: "user-1",
|
||||||
account_id: "account-1",
|
account_id: "account-1",
|
||||||
@@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", ()
|
|||||||
"unknown credential type",
|
"unknown credential type",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => {
|
||||||
|
const actor = {
|
||||||
|
user_id: "u",
|
||||||
|
account_id: "a",
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
};
|
||||||
|
for (
|
||||||
|
const payload of [
|
||||||
|
{ actor, session: { kind: "anonymous" } },
|
||||||
|
{ actor, session: { kind: "expired" } },
|
||||||
|
{
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
},
|
||||||
|
{ actor, session: { kind: "authenticated", expires_at: null } },
|
||||||
|
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } },
|
||||||
|
{ actor: null, session: { kind: "future" } },
|
||||||
|
{
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
|
||||||
|
assertEquals(
|
||||||
|
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }),
|
||||||
|
{ actor: null, session: { kind: "expired" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor {
|
|||||||
|
|
||||||
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||||
const response = asRecord(value, "whoami");
|
const response = asRecord(value, "whoami");
|
||||||
requireExactKeys(response, "whoami", ["actor"]);
|
requireExactKeys(response, "whoami", ["actor", "session"]);
|
||||||
return {
|
const actor = response.actor === null
|
||||||
actor: response.actor === null
|
? null
|
||||||
|
: parseActor(response.actor, "whoami.actor");
|
||||||
|
const session = asRecord(response.session, "whoami.session");
|
||||||
|
if (session.kind === "authenticated") {
|
||||||
|
requireExactKeys(session, "whoami.session", ["kind", "expires_at"]);
|
||||||
|
if (actor === null) {
|
||||||
|
invalid("whoami", "authenticated session requires actor");
|
||||||
|
}
|
||||||
|
const expiry = session.expires_at === null
|
||||||
? null
|
? null
|
||||||
: parseActor(response.actor, "whoami.actor"),
|
: boundedString(session.expires_at, "whoami.session.expires_at");
|
||||||
};
|
if (
|
||||||
|
(expiry !== null &&
|
||||||
|
(!/^\d{4}-\d\d-\d\dT/.test(expiry) ||
|
||||||
|
!Number.isFinite(Date.parse(expiry)))) ||
|
||||||
|
(actor.auth_method === "browser_session" && expiry === null)
|
||||||
|
) {
|
||||||
|
invalid(
|
||||||
|
"whoami.session.expires_at",
|
||||||
|
"must identify the server session expiry",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { actor, session: { kind: "authenticated", expires_at: expiry } };
|
||||||
|
}
|
||||||
|
requireExactKeys(session, "whoami.session", ["kind"]);
|
||||||
|
if (
|
||||||
|
actor !== null ||
|
||||||
|
(session.kind !== "anonymous" && session.kind !== "expired")
|
||||||
|
) {
|
||||||
|
invalid("whoami", "inconsistent actor or session state");
|
||||||
|
}
|
||||||
|
return { actor: null, session: { kind: session.kind } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
import { browserSession, observeBrowserSession } from "./session-state.ts";
|
||||||
|
import { isPublicPage, loginUrl } from "./routes.ts";
|
||||||
|
|
||||||
|
// root loadとそのparentを待つ各loaderが同じ確認を共有する。
|
||||||
|
// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。
|
||||||
|
export async function requirePageSession(url: URL): Promise<void> {
|
||||||
|
observeBrowserSession();
|
||||||
|
if (isPublicPage(url.pathname)) return;
|
||||||
|
const snapshot = await browserSession.refresh();
|
||||||
|
if (snapshot !== browserSession.snapshot) return requirePageSession(url);
|
||||||
|
if (snapshot.kind !== "ready") {
|
||||||
|
error(
|
||||||
|
503,
|
||||||
|
"Your session could not be loaded. Login status is unknown. Retry this page.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.value.session.kind === "authenticated") return;
|
||||||
|
if (snapshot.value.session.kind === "anonymous") redirect(307, "/");
|
||||||
|
const target = new URL(url);
|
||||||
|
if (
|
||||||
|
typeof location !== "undefined" && location.pathname === url.pathname &&
|
||||||
|
location.search === url.search
|
||||||
|
) {
|
||||||
|
target.hash = location.hash;
|
||||||
|
}
|
||||||
|
redirect(307, loginUrl(target.pathname + target.search + target.hash));
|
||||||
|
}
|
||||||
@@ -12,16 +12,16 @@ function assertEquals(
|
|||||||
}
|
}
|
||||||
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
||||||
|
|
||||||
Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => {
|
Deno.test("return destination preserves query and fragment only on known app screens", () => {
|
||||||
for (
|
for (
|
||||||
const [input, expected] of [
|
const [input, expected] of [
|
||||||
["/", "/dashboard"],
|
["/", "/dashboard"],
|
||||||
["/dashboard?query=discard#fragment", "/dashboard"],
|
["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"],
|
||||||
["/account", "/account"],
|
["/account", "/account"],
|
||||||
["/account?returnTo=//evil.invalid#secret", "/account"],
|
["/account?returnTo=//evil.invalid#secret", "/account"],
|
||||||
[
|
[
|
||||||
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
||||||
"/w/workspace-id/tickets/T-123",
|
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"/login/device?user_code=ABCD-EFGH",
|
"/login/device?user_code=ABCD-EFGH",
|
||||||
@@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit
|
|||||||
"/login/device?code=abcd-1234&returnTo=//evil.invalid",
|
"/login/device?code=abcd-1234&returnTo=//evil.invalid",
|
||||||
"/login/device?user_code=abcd-1234",
|
"/login/device?user_code=abcd-1234",
|
||||||
],
|
],
|
||||||
["/login/device?user_code=%252f%252fevil.invalid", "/login/device"],
|
["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"],
|
||||||
]
|
]
|
||||||
) assertEquals(safeReturnTo(input), expected, input);
|
) assertEquals(safeReturnTo(input), expected, input);
|
||||||
});
|
});
|
||||||
@@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic
|
|||||||
"/account\t",
|
"/account\t",
|
||||||
"/account\u0000",
|
"/account\u0000",
|
||||||
"/account%00",
|
"/account%00",
|
||||||
|
"/account?q=%255c",
|
||||||
|
"/account?q=%0A",
|
||||||
|
"/account#%5c",
|
||||||
|
"/account?q=%ZZ",
|
||||||
|
"/account?q=%252f%252fevil.invalid",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/hosting/setup",
|
"/hosting/setup",
|
||||||
"/not-an-app-route",
|
"/not-an-app-route",
|
||||||
@@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth
|
|||||||
"/login?returnTo=%2Fdashboard",
|
"/login?returnTo=%2Fdashboard",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => {
|
||||||
|
const target = `/w/team/tickets/T-123-${
|
||||||
|
encodeURIComponent("未ログイン")
|
||||||
|
}?view=comments#entry-2`;
|
||||||
|
assertEquals(safeReturnTo(target), target);
|
||||||
|
for (
|
||||||
|
const [input, expected] of [
|
||||||
|
[
|
||||||
|
"/w/team/tickets?filter=100%25#entry",
|
||||||
|
"/w/team/tickets?filter=100%25#entry",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||||
|
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/w/team/tickets/未?query=a%2fb#entry%2f2",
|
||||||
|
"/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertEquals(safeReturnTo(input), expected);
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
const input of [
|
||||||
|
"/w/team/tickets/%54-123",
|
||||||
|
"/w/team/tickets/T-123%2fother",
|
||||||
|
"/w/team/tickets/%E0%80%AF",
|
||||||
|
"/w/team/tickets/%252f",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertEquals(safeReturnTo(input), "/dashboard");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string {
|
|||||||
}
|
}
|
||||||
const rawPath = value.split(/[?#]/, 1)[0];
|
const rawPath = value.split(/[?#]/, 1)[0];
|
||||||
if (
|
if (
|
||||||
rawPath.includes("%") || rawPath.includes("//") ||
|
/%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") ||
|
||||||
rawPath.split("/").some((part) => part === "." || part === "..")
|
rawPath.split("/").some((part) => part === "." || part === "..")
|
||||||
) {
|
) {
|
||||||
return AUTHENTICATED_HOME;
|
return AUTHENTICATED_HOME;
|
||||||
}
|
}
|
||||||
let url: URL;
|
let url: URL;
|
||||||
|
let decodedPath: string;
|
||||||
try {
|
try {
|
||||||
|
decodedPath = decodeURIComponent(rawPath);
|
||||||
|
// pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで
|
||||||
|
// origin/区切りは変わらないため、再encodeとの文字列一致は求めない。
|
||||||
|
// query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで
|
||||||
|
// 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。
|
||||||
|
const decoded = decodeURIComponent(value);
|
||||||
|
if (
|
||||||
|
/[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded)
|
||||||
|
) {
|
||||||
|
return AUTHENTICATED_HOME;
|
||||||
|
}
|
||||||
url = new URL(value, "https://app.invalid");
|
url = new URL(value, "https://app.invalid");
|
||||||
} catch {
|
} catch {
|
||||||
return AUTHENTICATED_HOME;
|
return AUTHENTICATED_HOME;
|
||||||
@@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" ||
|
url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" ||
|
||||||
/^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname)
|
["/hosting/settings", "/hosting/accounts"].includes(url.pathname) ||
|
||||||
|
/^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath)
|
||||||
) {
|
) {
|
||||||
return url.pathname;
|
// 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。
|
||||||
|
if (url.searchParams.has("returnTo")) return url.pathname;
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
}
|
}
|
||||||
return AUTHENTICATED_HOME;
|
return AUTHENTICATED_HOME;
|
||||||
}
|
}
|
||||||
@@ -53,7 +68,29 @@ export function authEntryUrl(
|
|||||||
})}`;
|
})}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPublicPage(path: string): boolean {
|
||||||
|
return [
|
||||||
|
"/",
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/activate",
|
||||||
|
"/login/device",
|
||||||
|
"/hosting/setup",
|
||||||
|
]
|
||||||
|
.includes(path);
|
||||||
|
}
|
||||||
|
|
||||||
export function isAuthPage(path: string): boolean {
|
export function isAuthPage(path: string): boolean {
|
||||||
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
||||||
.includes(path);
|
.includes(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。
|
||||||
|
export function browserUrlSuffix(url: URL): string {
|
||||||
|
const hash =
|
||||||
|
typeof location !== "undefined" && location.pathname === url.pathname &&
|
||||||
|
location.search === url.search
|
||||||
|
? location.hash
|
||||||
|
: url.hash;
|
||||||
|
return url.search + hash;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { createRawSnippet } from "svelte";
|
||||||
|
import SessionBoundary from "./SessionBoundary.svelte";
|
||||||
|
import { browserSession } from "./session-state.ts";
|
||||||
|
import { authUser, whoamiPayload } from "./api.test-fixtures.ts";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
const page = vi.hoisted(() => ({
|
||||||
|
url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"),
|
||||||
|
}));
|
||||||
|
vi.mock("$app/state", () => ({ page }));
|
||||||
|
vi.mock(
|
||||||
|
"$app/navigation",
|
||||||
|
() => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }),
|
||||||
|
);
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
const actor = { ...authUser, auth_method: "browser_session" };
|
||||||
|
const children = createRawSnippet(() => ({
|
||||||
|
render: () => "<p>Private fragment</p>",
|
||||||
|
}));
|
||||||
|
async function admitted() {
|
||||||
|
window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => Response.json(whoamiPayload(actor))),
|
||||||
|
);
|
||||||
|
await browserSession.refresh();
|
||||||
|
render(SessionBoundary, { children });
|
||||||
|
await screen.findByText("Private fragment");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("active expired state removes private content and preserves browser query and fragment", async () => {
|
||||||
|
await admitted();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
Response.json({ actor: null, session: { kind: "expired" } })
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await browserSession.refresh();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("Private fragment")).toBeNull()
|
||||||
|
);
|
||||||
|
expect(goto).toHaveBeenCalledWith(
|
||||||
|
"/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry",
|
||||||
|
{ replaceState: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => {
|
||||||
|
await admitted();
|
||||||
|
let release!: (response: Response) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const checking = browserSession.refresh();
|
||||||
|
await screen.findByText("Loading session…");
|
||||||
|
expect(screen.getByText("Private fragment").closest("[hidden][inert]"))
|
||||||
|
.toBeTruthy();
|
||||||
|
release(new Response(null, { status: 503 }));
|
||||||
|
await checking;
|
||||||
|
await screen.findByText(
|
||||||
|
"Your session could not be loaded. Login status is unknown.",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Private fragment").closest("[hidden][inert]"))
|
||||||
|
.toBeTruthy();
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another tab changing actor cannot reveal content loaded for the old actor", async () => {
|
||||||
|
await admitted();
|
||||||
|
const reload = vi.spyOn(location, "reload").mockImplementation(() => {});
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
Response.json(
|
||||||
|
whoamiPayload({
|
||||||
|
...actor,
|
||||||
|
user_id: "different-user",
|
||||||
|
account_id: "different-account",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await browserSession.refresh();
|
||||||
|
await waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||||
|
expect(screen.queryByText("Private fragment")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a child mount session recheck does not unmount and restart its own loader", async () => {
|
||||||
|
window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry");
|
||||||
|
const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor)));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
await browserSession.refresh();
|
||||||
|
let mounts = 0;
|
||||||
|
const checkingChild = createRawSnippet(() => ({
|
||||||
|
render: () => "<p>Mounted session reader</p>",
|
||||||
|
setup: () => {
|
||||||
|
++mounts;
|
||||||
|
void browserSession.refresh();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
render(SessionBoundary, { children: checkingChild });
|
||||||
|
await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready"));
|
||||||
|
expect(screen.getByText("Mounted session reader").closest("[hidden]"))
|
||||||
|
.toBeNull();
|
||||||
|
expect(mounts).toBe(1);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。
|
||||||
|
// cookieと実storeの一致はServer/browser結合suiteが所有する。
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { SessionState } from "./session-state.ts";
|
||||||
|
import type { WhoamiResponse } from "./model.ts";
|
||||||
|
import { authUser } from "./api.test-fixtures.ts";
|
||||||
|
|
||||||
|
const states: SessionState[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const state of states.splice(0)) state.dispose();
|
||||||
|
vi.useRealTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
const anonymous: WhoamiResponse = {
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
};
|
||||||
|
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
|
||||||
|
const authenticated = (
|
||||||
|
expires_at: string | null = "2099-01-01T00:00:00Z",
|
||||||
|
): WhoamiResponse => ({
|
||||||
|
actor: { ...authUser, auth_method: "browser_session" },
|
||||||
|
session: { kind: "authenticated", expires_at },
|
||||||
|
});
|
||||||
|
function gate<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
function state(read: () => Promise<WhoamiResponse>) {
|
||||||
|
const result = new SessionState(read);
|
||||||
|
states.push(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => {
|
||||||
|
const response = gate<WhoamiResponse>();
|
||||||
|
const read = vi.fn(() => response.promise);
|
||||||
|
const session = state(read);
|
||||||
|
const first = session.refresh();
|
||||||
|
const second = session.refresh();
|
||||||
|
expect(session.snapshot.kind).toBe("loading");
|
||||||
|
expect(read).toHaveBeenCalledTimes(1);
|
||||||
|
response.resolve(expired);
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(session.snapshot).toEqual({ kind: "ready", value: expired });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delayed expired response cannot replace a new login in the same tab", async () => {
|
||||||
|
const older = gate<WhoamiResponse>();
|
||||||
|
const read = vi.fn<() => Promise<WhoamiResponse>>().mockReturnValueOnce(
|
||||||
|
older.promise,
|
||||||
|
).mockResolvedValue(authenticated());
|
||||||
|
const session = state(read);
|
||||||
|
const first = session.refresh();
|
||||||
|
session.invalidate();
|
||||||
|
await session.refresh();
|
||||||
|
older.resolve(expired);
|
||||||
|
await first;
|
||||||
|
expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage generation fences responses even before the other tab storage event is delivered", async () => {
|
||||||
|
const older = gate<WhoamiResponse>();
|
||||||
|
const read = vi.fn<() => Promise<WhoamiResponse>>().mockReturnValueOnce(
|
||||||
|
older.promise,
|
||||||
|
).mockResolvedValue(authenticated());
|
||||||
|
const session = state(read);
|
||||||
|
const first = session.refresh();
|
||||||
|
localStorage.setItem(
|
||||||
|
"yoi.browser-auth-generation",
|
||||||
|
JSON.stringify({ id: "new-login", reason: "authentication" }),
|
||||||
|
);
|
||||||
|
older.resolve(expired);
|
||||||
|
await first;
|
||||||
|
expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const read = vi.fn<() => Promise<WhoamiResponse>>().mockResolvedValueOnce(
|
||||||
|
authenticated(new Date(Date.now() + 5_000).toISOString()),
|
||||||
|
).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired);
|
||||||
|
const session = state(read);
|
||||||
|
await session.refresh();
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(session.snapshot).toEqual({ kind: "error" });
|
||||||
|
await session.refresh();
|
||||||
|
expect(session.snapshot).toEqual({ kind: "ready", value: expired });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => {
|
||||||
|
for (const value of [authenticated(), anonymous]) {
|
||||||
|
const session = state(async () => value);
|
||||||
|
session.invalidate();
|
||||||
|
await session.refresh();
|
||||||
|
expect(session.snapshot).toEqual({ kind: "ready", value });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { loadWhoami } from "./api.ts";
|
||||||
|
import type { WhoamiResponse } from "./model.ts";
|
||||||
|
|
||||||
|
export type SessionSnapshot =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "error" }
|
||||||
|
| { kind: "ready"; value: WhoamiResponse };
|
||||||
|
|
||||||
|
// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。
|
||||||
|
// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。
|
||||||
|
const GENERATION_KEY = "yoi.browser-auth-generation";
|
||||||
|
export class SessionState {
|
||||||
|
snapshot: SessionSnapshot = { kind: "loading" };
|
||||||
|
private generation = 0;
|
||||||
|
private pending: Promise<SessionSnapshot> | null = null;
|
||||||
|
private listeners = new Set<(state: SessionSnapshot) => void>();
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
get authenticationGeneration(): number {
|
||||||
|
return this.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly read = loadWhoami) {}
|
||||||
|
|
||||||
|
subscribe(listener: (state: SessionSnapshot) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
listener(this.snapshot);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(snapshot: SessionSnapshot): void {
|
||||||
|
this.snapshot = snapshot;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (
|
||||||
|
snapshot.kind === "ready" &&
|
||||||
|
snapshot.value.session.kind === "authenticated"
|
||||||
|
) {
|
||||||
|
const expiry = snapshot.value.session.expires_at;
|
||||||
|
if (expiry !== null) {
|
||||||
|
// Timerは再照会の契機。local clockだけで期限切れと断定しない。
|
||||||
|
const remaining = Date.parse(expiry) - Date.now();
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
void this.refresh();
|
||||||
|
}, Math.min(2_147_483_647, Math.max(1_000, remaining)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const listener of this.listeners) listener(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
++this.generation;
|
||||||
|
this.pending = null;
|
||||||
|
this.publish({ kind: "loading" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<SessionSnapshot> {
|
||||||
|
if (this.pending) return this.pending;
|
||||||
|
const generation = this.generation;
|
||||||
|
const stamp = storageStamp();
|
||||||
|
this.publish({ kind: "loading" });
|
||||||
|
const pending = (async () => {
|
||||||
|
let result: SessionSnapshot;
|
||||||
|
try {
|
||||||
|
result = { kind: "ready", value: await this.read() };
|
||||||
|
} catch {
|
||||||
|
result = { kind: "error" };
|
||||||
|
}
|
||||||
|
if (generation !== this.generation || stamp !== storageStamp()) {
|
||||||
|
// 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。
|
||||||
|
if (generation !== this.generation) {
|
||||||
|
if (this.pending) return await this.pending;
|
||||||
|
if (this.snapshot.kind !== "loading") return this.snapshot;
|
||||||
|
}
|
||||||
|
// 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。
|
||||||
|
if (generation === this.generation) this.invalidate();
|
||||||
|
return await this.refresh();
|
||||||
|
}
|
||||||
|
this.pending = null;
|
||||||
|
this.publish(result);
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
this.pending = pending;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.listeners.clear();
|
||||||
|
++this.generation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageStamp(): string | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const browserSession = new SessionState();
|
||||||
|
|
||||||
|
export function sessionMutation(
|
||||||
|
reason: "authentication" | "logout" = "authentication",
|
||||||
|
): void {
|
||||||
|
browserSession.invalidate();
|
||||||
|
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||||
|
try {
|
||||||
|
localStorage.setItem(GENERATION_KEY, message);
|
||||||
|
} catch { /* 保存拒否でも同tab fenceは維持。 */ }
|
||||||
|
if (reason === "logout") clearReturnTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return";
|
||||||
|
function clearReturnTo(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new Event(CLEAR_RETURN_EVENT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
export function recheckAfterUnauthorized(
|
||||||
|
authenticationGeneration: number,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
installed &&
|
||||||
|
authenticationGeneration === browserSession.authenticationGeneration
|
||||||
|
) void browserSession.refresh();
|
||||||
|
}
|
||||||
|
export function observeBrowserSession(): void {
|
||||||
|
if (installed || typeof window === "undefined") return;
|
||||||
|
installed = true;
|
||||||
|
const refresh = () => {
|
||||||
|
void browserSession.refresh();
|
||||||
|
};
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
window.addEventListener("pageshow", refresh);
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
});
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key !== GENERATION_KEY && event.key !== null) return;
|
||||||
|
browserSession.invalidate();
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
event.key === null ||
|
||||||
|
JSON.parse(event.newValue ?? "null")?.reason === "logout"
|
||||||
|
) clearReturnTo();
|
||||||
|
} catch { /* 不明な通知も権限ではなく再照会だけ。 */ }
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { browserSession, sessionMutation } from "./session-state.ts";
|
||||||
import { BrowserAuth } from "./browser-session.svelte.ts";
|
import { BrowserAuth } from "./browser-session.svelte.ts";
|
||||||
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
||||||
import { SESSION_CHANGED_EVENT } from "./hosting.ts";
|
import { SESSION_CHANGED_EVENT } from "./hosting.ts";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
const sessions: BrowserAuth[] = [];
|
const sessions: BrowserAuth[] = [];
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const auth of sessions.splice(0)) auth.dispose();
|
for (const auth of sessions.splice(0)) auth.dispose();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -33,6 +35,9 @@ function session() {
|
|||||||
|
|
||||||
function whoami(authenticated: boolean) {
|
function whoami(authenticated: boolean) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: authenticated
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: authenticated
|
actor: authenticated
|
||||||
? { ...authUser, auth_method: "browser_session" }
|
? { ...authUser, auth_method: "browser_session" }
|
||||||
: null,
|
: null,
|
||||||
@@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
|
|||||||
expect(await auth.run("pending", "done", operation)).toBe(false);
|
expect(await auth.run("pending", "done", operation)).toBe(false);
|
||||||
expect(operation).not.toHaveBeenCalled();
|
expect(operation).not.toHaveBeenCalled();
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
expect(auth.session).toEqual({
|
||||||
|
kind: "ready",
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
for (
|
for (
|
||||||
@@ -117,6 +126,7 @@ for (
|
|||||||
);
|
);
|
||||||
const auth = session();
|
const auth = session();
|
||||||
const first = auth.refresh();
|
const first = auth.refresh();
|
||||||
|
browserSession.invalidate();
|
||||||
const second = auth.refresh();
|
const second = auth.refresh();
|
||||||
latest.resolve(
|
latest.resolve(
|
||||||
latestStatus === 200
|
latestStatus === 200
|
||||||
@@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
|||||||
const oldResponse = gate<Response>();
|
const oldResponse = gate<Response>();
|
||||||
fetcher.mockReturnValueOnce(oldResponse.promise);
|
fetcher.mockReturnValueOnce(oldResponse.promise);
|
||||||
const oldRefresh = auth.refresh();
|
const oldRefresh = auth.refresh();
|
||||||
await auth.refresh();
|
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
|
||||||
expect(auth.actor?.handle).toBe("alice");
|
true,
|
||||||
expect(await auth.run("Logging out", "Logged out", logout)).toBe(true);
|
);
|
||||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
expect(auth.session).toEqual({
|
||||||
|
kind: "ready",
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
});
|
||||||
oldResponse.resolve(whoami(true));
|
oldResponse.resolve(whoami(true));
|
||||||
await oldRefresh;
|
await oldRefresh;
|
||||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
expect(auth.session).toEqual({
|
||||||
|
kind: "ready",
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
});
|
||||||
expect(auth.status).toBe("Logged out");
|
expect(auth.status).toBe("Logged out");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
|||||||
options.resolve(Response.json(loginOptions));
|
options.resolve(Response.json(loginOptions));
|
||||||
await confirmingStarted.promise;
|
await confirmingStarted.promise;
|
||||||
expect(auth.busy).toBe(true);
|
expect(auth.busy).toBe(true);
|
||||||
expect(auth.session.kind).toBe("loading");
|
expect(auth.session.kind).toBe("ready");
|
||||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||||
@@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) {
|
|||||||
expect(auth.status).toBe("");
|
expect(auth.status).toBe("");
|
||||||
expect(auth.error).toMatch(
|
expect(auth.error).toMatch(
|
||||||
status === 401
|
status === 401
|
||||||
? /Your session or authentication challenge expired/
|
? /Authentication request was rejected/
|
||||||
: /The request or authentication challenge is invalid or expired/,
|
: /The request or authentication challenge is invalid or expired/,
|
||||||
);
|
);
|
||||||
expect(auth.session.kind).toBe("ready");
|
expect(auth.session.kind).toBe("ready");
|
||||||
@@ -270,7 +288,11 @@ for (
|
|||||||
expect(auth.error).not.toContain("secret credential detail");
|
expect(auth.error).not.toContain("secret credential detail");
|
||||||
expect(auth.status).toBe("");
|
expect(auth.status).toBe("");
|
||||||
expect(auth.busy).toBe(false);
|
expect(auth.busy).toBe(false);
|
||||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
expect(auth.session).toEqual({
|
||||||
|
kind: "ready",
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
});
|
||||||
expect(paths(fetcher)).toEqual([
|
expect(paths(fetcher)).toEqual([
|
||||||
"/api/auth/whoami",
|
"/api/auth/whoami",
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
@@ -338,7 +360,7 @@ for (const rejected of [false, true]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => {
|
test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => {
|
||||||
const { fetcher, get } = boundary();
|
const { fetcher, get } = boundary();
|
||||||
const auth = session();
|
const auth = session();
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
@@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion,
|
|||||||
expect(signal.aborted).toBe(true);
|
expect(signal.aborted).toBe(true);
|
||||||
credential.resolve(credentialFixture(true));
|
credential.resolve(credentialFixture(true));
|
||||||
expect(await run).toBe(false);
|
expect(await run).toBe(false);
|
||||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
expect(auth.session).toEqual({
|
||||||
|
kind: "ready",
|
||||||
|
actor: null,
|
||||||
|
session: { kind: "anonymous" },
|
||||||
|
});
|
||||||
expect(auth.status).toBe("Waiting");
|
expect(auth.status).toBe("Waiting");
|
||||||
expect(auth.error).toBe("");
|
expect(auth.error).toBe("");
|
||||||
expect(paths(fetcher)).toEqual([
|
expect(paths(fetcher)).toEqual([
|
||||||
"/api/auth/whoami",
|
"/api/auth/whoami",
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
|
"/api/auth/whoami",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
dispatch.mock.calls.filter(([event]) =>
|
dispatch.mock.calls.filter(([event]) =>
|
||||||
@@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
|||||||
"/api/auth/whoami",
|
"/api/auth/whoami",
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
"/api/auth/passkeys/login/complete",
|
"/api/auth/passkeys/login/complete",
|
||||||
|
"/api/auth/whoami",
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
dispatch.mock.calls.filter(([event]) =>
|
dispatch.mock.calls.filter(([event]) =>
|
||||||
@@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco
|
|||||||
(signal) => loginWithPasskey(signal),
|
(signal) => loginWithPasskey(signal),
|
||||||
);
|
);
|
||||||
await started.promise;
|
await started.promise;
|
||||||
expect(auth.session.kind).toBe("loading");
|
expect(auth.session.kind).toBe("ready");
|
||||||
expect(auth.busy).toBe(true);
|
expect(auth.busy).toBe(true);
|
||||||
auth.dispose();
|
auth.dispose();
|
||||||
response.resolve(whoami(true));
|
response.resolve(whoami(true));
|
||||||
expect(await run).toBe(false);
|
expect(await run).toBe(false);
|
||||||
expect(auth.session.kind).toBe("loading");
|
expect(auth.session.kind).toBe("ready");
|
||||||
expect(auth.actor).toBeNull();
|
expect(auth.actor).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const newer of ["logout", "another-actor"] as const) {
|
||||||
|
test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => {
|
||||||
|
const { fetcher } = boundary();
|
||||||
|
const auth = session();
|
||||||
|
await auth.refresh();
|
||||||
|
const confirming = gate<Response>();
|
||||||
|
const started = gate<void>();
|
||||||
|
const request = fetcher.getMockImplementation()!;
|
||||||
|
fetcher.mockImplementation((input, init) => {
|
||||||
|
if (String(input) === "/api/auth/whoami") {
|
||||||
|
started.resolve();
|
||||||
|
return confirming.promise;
|
||||||
|
}
|
||||||
|
return request(input, init);
|
||||||
|
});
|
||||||
|
const changed = vi.fn();
|
||||||
|
window.addEventListener(SESSION_CHANGED_EVENT, changed);
|
||||||
|
const navigate = vi.fn();
|
||||||
|
try {
|
||||||
|
const run = auth.run("Waiting", "Logged in", loginWithPasskey).then(
|
||||||
|
(confirmed) => {
|
||||||
|
if (confirmed && auth.actor) navigate("/account?old=return#entry");
|
||||||
|
return confirmed;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await started.promise;
|
||||||
|
sessionMutation(newer === "logout" ? "logout" : "authentication");
|
||||||
|
fetcher.mockResolvedValueOnce(
|
||||||
|
newer === "logout" ? whoami(false) : Response.json({
|
||||||
|
actor: {
|
||||||
|
...authUser,
|
||||||
|
user_id: "other-user",
|
||||||
|
account_id: "other-account",
|
||||||
|
handle: "bob",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
kind: "authenticated",
|
||||||
|
expires_at: "2099-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await browserSession.refresh();
|
||||||
|
confirming.resolve(whoami(true));
|
||||||
|
expect(await run).toBe(false);
|
||||||
|
expect(auth.actor?.handle ?? null).toBe(
|
||||||
|
newer === "logout" ? null : "bob",
|
||||||
|
);
|
||||||
|
expect(auth.status).toBe("");
|
||||||
|
expect(auth.busy).toBe(false);
|
||||||
|
expect(changed).not.toHaveBeenCalled();
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const purpose of ["login", "addition"] as const) {
|
for (const purpose of ["login", "addition"] as const) {
|
||||||
test(`${purpose} null credential reports cancellation and never sends completion`, async () => {
|
test(`${purpose} null credential reports cancellation and never sends completion`, async () => {
|
||||||
const { fetcher, get, create } = boundary(purpose === "addition");
|
const { fetcher, get, create } = boundary(purpose === "addition");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation
|
|||||||
const api = gate<string>();
|
const api = gate<string>();
|
||||||
const event = {
|
const event = {
|
||||||
params: { id: "first" },
|
params: { id: "first" },
|
||||||
|
parent: async () => ({}),
|
||||||
url: new URL("https://example.test/first?q=old"),
|
url: new URL("https://example.test/first?q=old"),
|
||||||
};
|
};
|
||||||
const load = deferPage(async ({ params, url }: typeof event) => ({
|
const load = deferPage(async ({ params, url }: typeof event) => ({
|
||||||
@@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl
|
|||||||
],
|
],
|
||||||
] as const
|
] as const
|
||||||
) {
|
) {
|
||||||
const load = deferPage((_event: { params: {}; url: URL }) => run());
|
const load = deferPage((
|
||||||
|
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
|
||||||
|
) => run());
|
||||||
const result = await load({
|
const result = await load({
|
||||||
params: {},
|
params: {},
|
||||||
|
parent: async () => ({}),
|
||||||
url: new URL("https://example.test/"),
|
url: new URL("https://example.test/"),
|
||||||
}).deferredPage;
|
}).deferredPage;
|
||||||
assert(
|
assert(
|
||||||
@@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global
|
|||||||
result.data.workspaceId === "team",
|
result.data.workspaceId === "team",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => {
|
||||||
|
for (const outcome of ["admit", "anonymous", "expired", "error"] as const) {
|
||||||
|
const admission = gate<void>();
|
||||||
|
let calls = 0;
|
||||||
|
const load = deferPage(
|
||||||
|
async (
|
||||||
|
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
|
||||||
|
) => {
|
||||||
|
++calls;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = load({
|
||||||
|
params: {},
|
||||||
|
url: new URL("https://app.invalid/w/a/tickets"),
|
||||||
|
parent: async () => {
|
||||||
|
await admission.promise;
|
||||||
|
if (outcome === "anonymous") redirect(307, "/");
|
||||||
|
if (outcome === "expired") {
|
||||||
|
redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets");
|
||||||
|
}
|
||||||
|
if (outcome === "error") {
|
||||||
|
error(503, "Unknown session");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
assert(calls === 0);
|
||||||
|
admission.release();
|
||||||
|
const settled = await result.deferredPage;
|
||||||
|
assert(calls === (outcome === "admit" ? 1 : 0));
|
||||||
|
assert(settled.ok === (outcome === "admit"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export async function settle<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deferPage<
|
export function deferPage<
|
||||||
E extends { params: Record<string, string | undefined>; url: URL },
|
E extends {
|
||||||
|
params: Record<string, string | undefined>;
|
||||||
|
url: URL;
|
||||||
|
parent: () => Promise<unknown>;
|
||||||
|
},
|
||||||
T extends object,
|
T extends object,
|
||||||
>(
|
>(
|
||||||
loader: (event: E) => T | Promise<T>,
|
loader: (event: E) => T | Promise<T>,
|
||||||
@@ -51,11 +55,13 @@ export function deferPage<
|
|||||||
params: { ...event.params },
|
params: { ...event.params },
|
||||||
url: new URL(event.url.href),
|
url: new URL(event.url.href),
|
||||||
};
|
};
|
||||||
try {
|
// SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。
|
||||||
return { deferredPage: settle(loader(snapshot)) };
|
return {
|
||||||
} catch (error) {
|
deferredPage: settle((async () => {
|
||||||
return { deferredPage: settle(Promise.reject(error)) };
|
await snapshot.parent();
|
||||||
}
|
return await loader(snapshot);
|
||||||
|
})()),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ for (
|
|||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||||
if (String(input) === "/api/auth/whoami") {
|
if (String(input) === "/api/auth/whoami") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: actor
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// navigationの構成はWorkspace identityやpermissionの根拠にはしない。
|
// navigationの構成はWorkspace identityやpermissionの根拠にはしない。
|
||||||
// Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。
|
// Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。
|
||||||
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
|
||||||
|
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { setContext } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
import { MediaQuery } from 'svelte/reactivity';
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<SessionBoundary>
|
||||||
{#if standalone}
|
{#if standalone}
|
||||||
<main class="standalone-main">
|
<main class="standalone-main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -147,6 +149,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</SessionBoundary>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.standalone-main {
|
.standalone-main {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requirePageSession } from "#lib/workspace/auth/page-session.ts";
|
||||||
import { isAuthPage } from "#lib/workspace/auth/routes.ts";
|
import { isAuthPage } from "#lib/workspace/auth/routes.ts";
|
||||||
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
|
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
|
||||||
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||||
@@ -5,28 +6,33 @@ import type { Load } from "@sveltejs/kit";
|
|||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
|
|
||||||
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
|
export const load = async ({ fetch, url, depends }: Parameters<Load>[0]) => {
|
||||||
deferredCatalog: settle((async () => {
|
depends("auth:session");
|
||||||
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
|
// href依存でquery変更を含むSPA遷移でもrootの認証gateを再実行する。
|
||||||
// dashboardはactor確認後に自身の一覧を取得する。
|
await requirePageSession(new URL(url.href));
|
||||||
if (
|
return {
|
||||||
url.pathname === "/" || url.pathname === "/dashboard" ||
|
deferredCatalog: settle((async () => {
|
||||||
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
|
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
|
||||||
) {
|
// dashboardはactor確認後に自身の一覧を取得する。
|
||||||
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
if (
|
||||||
}
|
url.pathname === "/" || url.pathname === "/dashboard" ||
|
||||||
try {
|
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
|
||||||
return {
|
) {
|
||||||
accessibleWorkspaces: await listWorkspaces(fetch),
|
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
||||||
workspaceCatalogError: null,
|
}
|
||||||
};
|
try {
|
||||||
} catch (error) {
|
return {
|
||||||
return {
|
accessibleWorkspaces: await listWorkspaces(fetch),
|
||||||
accessibleWorkspaces: [],
|
workspaceCatalogError: null,
|
||||||
workspaceCatalogError: error instanceof Error
|
};
|
||||||
? error.message
|
} catch (error) {
|
||||||
: "Unable to load Workspaces",
|
return {
|
||||||
};
|
accessibleWorkspaces: [],
|
||||||
}
|
workspaceCatalogError: error instanceof Error
|
||||||
})()),
|
? error.message
|
||||||
});
|
: "Unable to load Workspaces",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
const auth = new BrowserAuth();
|
const auth = new BrowserAuth();
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await auth.refresh();
|
await auth.refresh();
|
||||||
if (auth.session.kind === 'ready' && !auth.actor) {
|
|
||||||
await goto(loginUrl('/account'), { replaceState: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async function add() {
|
async function add() {
|
||||||
if (!auth.actor) return;
|
if (!auth.actor) return;
|
||||||
@@ -19,8 +16,8 @@
|
|||||||
}
|
}
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
if (!auth.actor) return;
|
if (!auth.actor) return;
|
||||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout);
|
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||||
if (confirmed && !auth.actor) await goto(loginUrl('/account'), { replaceState: true });
|
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
|
||||||
}
|
}
|
||||||
onMount(() => { void refresh(); return () => auth.dispose(); });
|
onMount(() => { void refresh(); return () => auth.dispose(); });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -26,6 +27,7 @@ vi.mock("$app/state", () => ({ page }));
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
page.url = new URL("https://example.test/login");
|
page.url = new URL("https://example.test/login");
|
||||||
@@ -61,6 +63,9 @@ function boundary({
|
|||||||
return new Response(null, { status: state.whoamiStatus });
|
return new Response(null, { status: state.whoamiStatus });
|
||||||
}
|
}
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: state.authenticated
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: state.authenticated
|
actor: state.authenticated
|
||||||
? { ...authUser, auth_method: "browser_session" }
|
? { ...authUser, auth_method: "browser_session" }
|
||||||
: null,
|
: null,
|
||||||
@@ -187,7 +192,10 @@ for (
|
|||||||
const [returnTo, expected] of [
|
const [returnTo, expected] of [
|
||||||
[null, "/dashboard"],
|
[null, "/dashboard"],
|
||||||
["/account", "/account"],
|
["/account", "/account"],
|
||||||
["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"],
|
[
|
||||||
|
"/w/project/tickets?ignored=1#fragment",
|
||||||
|
"/w/project/tickets?ignored=1#fragment",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"/login/device?code=abcd-EFGH&untrusted=1",
|
"/login/device?code=abcd-EFGH&untrusted=1",
|
||||||
"/login/device?user_code=abcd-EFGH",
|
"/login/device?user_code=abcd-EFGH",
|
||||||
@@ -396,14 +404,11 @@ test("account displays the current identity without editable target fields and a
|
|||||||
expect(goto).not.toHaveBeenCalled();
|
expect(goto).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("anonymous account redirects to login with an account return path and has no credential inputs", async () => {
|
test("account fragment leaves anonymous navigation to the root gate and has no credential inputs", async () => {
|
||||||
const { fetcher } = boundary();
|
const { fetcher } = boundary();
|
||||||
render(Account);
|
render(Account);
|
||||||
await waitFor(() =>
|
await screen.findByRole("link", { name: "Log in to your account" });
|
||||||
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
|
expect(goto).not.toHaveBeenCalled();
|
||||||
replaceState: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
|
screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
|
||||||
"href",
|
"href",
|
||||||
@@ -468,12 +473,12 @@ test("unknown account session shows retry instead of treating errors as anonymou
|
|||||||
expect(goto).not.toHaveBeenCalled();
|
expect(goto).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => {
|
test("logout confirms the server session is cleared and returns to the public home", async () => {
|
||||||
const { fetcher } = boundary({ authenticated: true });
|
const { fetcher } = boundary({ authenticated: true });
|
||||||
render(Account);
|
render(Account);
|
||||||
await fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
await fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
|
expect(goto).toHaveBeenCalledWith("/", {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -494,7 +499,7 @@ test("expired addition revokes stale identity without silently retrying as regis
|
|||||||
await fireEvent.click(
|
await fireEvent.click(
|
||||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||||
);
|
);
|
||||||
await screen.findByText(/Your session or authentication challenge expired/);
|
await screen.findByText(/Authentication request was rejected/);
|
||||||
await screen.findByRole("link", { name: "Log in to your account" });
|
await screen.findByRole("link", { name: "Log in to your account" });
|
||||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||||
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
|
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
|
||||||
@@ -676,7 +681,7 @@ for (const status of [401, 410]) {
|
|||||||
);
|
);
|
||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
status === 401
|
status === 401
|
||||||
? /Your session or authentication challenge expired/
|
? /Authentication request was rejected/
|
||||||
: /The request or authentication challenge is invalid or expired/,
|
: /The request or authentication challenge is invalid or expired/,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
import Home from "./+page.svelte";
|
import Home from "./+page.svelte";
|
||||||
import Dashboard from "./dashboard/+page.svelte";
|
import Dashboard from "./dashboard/+page.svelte";
|
||||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
@@ -20,6 +21,7 @@ import { goto } from "$app/navigation";
|
|||||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -51,6 +53,9 @@ function boundary() {
|
|||||||
if (state.sessionGate) return state.sessionGate.promise;
|
if (state.sessionGate) return state.sessionGate.promise;
|
||||||
return state.whoamiStatus === 200
|
return state.whoamiStatus === 200
|
||||||
? Response.json({
|
? Response.json({
|
||||||
|
session: state.signedIn
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: state.signedIn
|
actor: state.signedIn
|
||||||
? {
|
? {
|
||||||
...authUser,
|
...authUser,
|
||||||
@@ -288,7 +293,10 @@ test("same actor focus retains creation disclosure, repository fields, errors an
|
|||||||
await screen.findByText("Loading session…");
|
await screen.findByText("Loading session…");
|
||||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||||
state.sessionGate.resolve(
|
state.sessionGate.resolve(
|
||||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
Response.json({
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: { ...authUser, auth_method: "browser_session" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||||
@@ -323,7 +331,10 @@ test("pending create remains single across focus and a success during unknown se
|
|||||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||||
expect(goto).not.toHaveBeenCalled();
|
expect(goto).not.toHaveBeenCalled();
|
||||||
state.sessionGate.resolve(
|
state.sessionGate.resolve(
|
||||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
Response.json({
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: { ...authUser, auth_method: "browser_session" },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(await screen.findByRole("link", { name: "Open created Workspace" }))
|
(await screen.findByRole("link", { name: "Open created Workspace" }))
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import Accounts from "./+page.svelte";
|
import Accounts from "./+page.svelte";
|
||||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
function boundary(
|
function boundary(
|
||||||
@@ -20,6 +22,9 @@ function boundary(
|
|||||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||||
if (input === "/api/auth/whoami") {
|
if (input === "/api/auth/whoami") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: mode !== "anonymous"
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: mode === "anonymous"
|
actor: mode === "anonymous"
|
||||||
? null
|
? null
|
||||||
: { ...authUser, auth_method: "browser_session" },
|
: { ...authUser, auth_method: "browser_session" },
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import Settings from "./+page.svelte";
|
import Settings from "./+page.svelte";
|
||||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,6 +27,9 @@ function boundary(
|
|||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path === "/api/auth/whoami") {
|
if (path === "/api/auth/whoami") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: persona !== "anonymous"
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: persona === "anonymous"
|
actor: persona === "anonymous"
|
||||||
? null
|
? null
|
||||||
: { ...authUser, auth_method: "browser_session" },
|
: { ...authUser, auth_method: "browser_session" },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
||||||
|
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||||
@@ -13,13 +14,26 @@
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
let verifiedGeneration: number | null = null;
|
||||||
|
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
|
||||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||||
async function verifySession(expected: AuthUser): Promise<boolean> {
|
function matchesSession(expected: AuthUser): boolean {
|
||||||
const { actor } = await loadWhoami();
|
const snapshot = browserSession.snapshot;
|
||||||
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
|
if (snapshot.kind !== 'ready' || snapshot.value.session.kind !== 'authenticated') return false;
|
||||||
const access = await loadHostingAccess();
|
const actor = snapshot.value.actor;
|
||||||
return active && access.is_admin;
|
return actor?.user_id === expected.user_id && actor.account_id === expected.account_id && actor.auth_method === 'browser_session';
|
||||||
}
|
}
|
||||||
|
onMount(() => {
|
||||||
|
observeBrowserSession();
|
||||||
|
return browserSession.subscribe(() => {
|
||||||
|
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
||||||
|
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
||||||
|
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
|
||||||
|
verifiedLogin = false;
|
||||||
|
verifiedGeneration = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function enroll() {
|
async function enroll() {
|
||||||
if (busy || user || !token.trim()) return;
|
if (busy || user || !token.trim()) return;
|
||||||
@@ -45,17 +59,35 @@
|
|||||||
const expected = user;
|
const expected = user;
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
sessionMutation();
|
||||||
|
let generation = browserSession.authenticationGeneration;
|
||||||
try {
|
try {
|
||||||
const signedIn = await loginWithPasskey(controller.signal);
|
const signedIn = await loginWithPasskey(controller.signal);
|
||||||
if (!active) return;
|
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||||
throw new Error('Login or hosting access not verified');
|
throw new Error('Unexpected login actor');
|
||||||
}
|
}
|
||||||
if (!active) return;
|
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
|
||||||
|
sessionMutation();
|
||||||
|
generation = browserSession.authenticationGeneration;
|
||||||
|
await browserSession.refresh();
|
||||||
|
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
|
||||||
|
const access = await loadHostingAccess();
|
||||||
|
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
|
||||||
|
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||||
|
await browserSession.refresh();
|
||||||
|
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
|
||||||
|
verifiedGeneration = generation;
|
||||||
verifiedLogin = true;
|
verifiedLogin = true;
|
||||||
notifySessionChanged();
|
notifySessionChanged();
|
||||||
} catch {
|
} catch {
|
||||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||||
|
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||||
|
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||||
|
if (isCurrent(generation)) {
|
||||||
|
sessionMutation();
|
||||||
|
await browserSession.refresh();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (active) busy = false;
|
if (active) busy = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
} from "@testing-library/svelte";
|
} from "@testing-library/svelte";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import Setup from "./+page.svelte";
|
import Setup from "./+page.svelte";
|
||||||
|
import {
|
||||||
|
browserSession,
|
||||||
|
observeBrowserSession,
|
||||||
|
} from "#lib/workspace/auth/session-state.ts";
|
||||||
|
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||||
import {
|
import {
|
||||||
authUser,
|
authUser,
|
||||||
credentialFixture,
|
credentialFixture,
|
||||||
@@ -17,6 +22,7 @@ import {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
browserSession.invalidate();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
@@ -34,6 +40,9 @@ function boundary(
|
|||||||
const path = String(input);
|
const path = String(input);
|
||||||
if (path === "/api/auth/whoami") {
|
if (path === "/api/auth/whoami") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
session: actor
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,7 +130,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
|||||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||||
expect(get).toHaveBeenCalledTimes(1);
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||||
.toHaveLength(1);
|
.toHaveLength(2);
|
||||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||||
.toHaveLength(1);
|
.toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
@@ -172,6 +181,7 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
|
|||||||
fetcher.mockImplementation(async (input, init) =>
|
fetcher.mockImplementation(async (input, init) =>
|
||||||
String(input) === "/api/auth/whoami"
|
String(input) === "/api/auth/whoami"
|
||||||
? Response.json({
|
? Response.json({
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
actor: {
|
actor: {
|
||||||
...authUser,
|
...authUser,
|
||||||
user_id: "other-user",
|
user_id: "other-user",
|
||||||
@@ -248,7 +258,10 @@ test("API token identity is not mistaken for a verified browser session", async
|
|||||||
const original = fetcher.getMockImplementation()!;
|
const original = fetcher.getMockImplementation()!;
|
||||||
fetcher.mockImplementation(async (input, init) =>
|
fetcher.mockImplementation(async (input, init) =>
|
||||||
String(input) === "/api/auth/whoami"
|
String(input) === "/api/auth/whoami"
|
||||||
? Response.json({ actor: { ...authUser, auth_method: "api_token" } })
|
? Response.json({
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: { ...authUser, auth_method: "api_token" },
|
||||||
|
})
|
||||||
: original(input, init)
|
: original(input, init)
|
||||||
);
|
);
|
||||||
render(Setup);
|
render(Setup);
|
||||||
@@ -261,3 +274,150 @@ test("API token identity is not mistaken for a verified browser session", async
|
|||||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||||
.toBeNull();
|
.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function gate<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
const setupActor = { ...authUser, auth_method: "browser_session" };
|
||||||
|
const replacedActor = {
|
||||||
|
...setupActor,
|
||||||
|
user_id: "other-user",
|
||||||
|
account_id: "other-account",
|
||||||
|
handle: "bob",
|
||||||
|
};
|
||||||
|
function identityResponse(actor: typeof setupActor | null) {
|
||||||
|
return Response.json({
|
||||||
|
actor,
|
||||||
|
session: actor
|
||||||
|
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||||
|
: { kind: "anonymous" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function changedInAnotherTab(actor: typeof setupActor | null) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", {
|
||||||
|
key: "yoi.browser-auth-generation",
|
||||||
|
newValue: JSON.stringify({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
reason: actor ? "authentication" : "logout",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await browserSession.refresh();
|
||||||
|
}
|
||||||
|
for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||||
|
for (const replacement of [null, replacedActor]) {
|
||||||
|
test(`newer ${replacement ? "actor" : "logout"} while setup ${phase} is pending cannot publish old verified success`, async () => {
|
||||||
|
const { fetcher } = boundary();
|
||||||
|
const original = fetcher.getMockImplementation()!;
|
||||||
|
const started = gate<void>();
|
||||||
|
const pending = gate<Response>();
|
||||||
|
let current: typeof setupActor | null = setupActor;
|
||||||
|
let reads = 0;
|
||||||
|
fetcher.mockImplementation((input, init) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path === "/api/auth/whoami") {
|
||||||
|
++reads;
|
||||||
|
if (
|
||||||
|
(phase === "whoami" && reads === 1) ||
|
||||||
|
(phase === "confirmation" && reads === 2)
|
||||||
|
) {
|
||||||
|
started.resolve();
|
||||||
|
return pending.promise;
|
||||||
|
}
|
||||||
|
return Promise.resolve(identityResponse(current));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(phase === "access" && path === "/api/hosting/access") ||
|
||||||
|
(phase === "login" && path === "/api/auth/passkeys/login/complete")
|
||||||
|
) {
|
||||||
|
started.resolve();
|
||||||
|
return pending.promise;
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
});
|
||||||
|
observeBrowserSession();
|
||||||
|
render(Setup);
|
||||||
|
await enroll();
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||||
|
);
|
||||||
|
await started.promise;
|
||||||
|
const notify = vi.fn();
|
||||||
|
window.addEventListener(SESSION_CHANGED_EVENT, notify);
|
||||||
|
try {
|
||||||
|
current = replacement;
|
||||||
|
await changedInAnotherTab(current);
|
||||||
|
const acceptedGeneration = browserSession.authenticationGeneration;
|
||||||
|
pending.resolve(
|
||||||
|
phase === "access"
|
||||||
|
? Response.json({ is_admin: true })
|
||||||
|
: phase === "login"
|
||||||
|
? Response.json({ user: authUser })
|
||||||
|
: identityResponse(setupActor),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Sign in with new passkey",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false)
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||||
|
.toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
"Passkey login and hosting administrator access verified.",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
expect(browserSession.authenticationGeneration).toBe(
|
||||||
|
acceptedGeneration,
|
||||||
|
);
|
||||||
|
expect(browserSession.snapshot).toMatchObject({
|
||||||
|
kind: "ready",
|
||||||
|
value: { actor: current },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener(SESSION_CHANGED_EVENT, notify);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const replacement of [null, replacedActor]) {
|
||||||
|
test(`setup verified success is removed on subsequent ${replacement ? "actor replacement" : "logout"}`, async () => {
|
||||||
|
const { fetcher } = boundary();
|
||||||
|
observeBrowserSession();
|
||||||
|
render(Setup);
|
||||||
|
await enroll();
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||||
|
);
|
||||||
|
await screen.findByRole("link", { name: "Open hosting settings" });
|
||||||
|
const original = fetcher.getMockImplementation()!;
|
||||||
|
fetcher.mockImplementation((input, init) =>
|
||||||
|
String(input) === "/api/auth/whoami"
|
||||||
|
? Promise.resolve(identityResponse(replacement))
|
||||||
|
: original(input, init)
|
||||||
|
);
|
||||||
|
await changedInAnotherTab(replacement);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||||
|
.toBeNull()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
"Passkey login and hosting administrator access verified.",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Sign in with new passkey",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,34 +4,62 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
|||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { createRawSnippet, tick } from "svelte";
|
import { createRawSnippet, tick } from "svelte";
|
||||||
import { logout } from "#lib/workspace/auth/api.ts";
|
import { logout } from "#lib/workspace/auth/api.ts";
|
||||||
import { clearWorkspaceAlerts, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
import {
|
||||||
import { page } from '$app/state';
|
clearWorkspaceAlerts,
|
||||||
import { settle } from '#lib/workspace/navigation/deferred.ts';
|
pushWorkspaceAlert,
|
||||||
|
} from "#lib/workspace/alerts/store.ts";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||||
|
import { browserSession } from "#lib/workspace/auth/session-state.ts";
|
||||||
|
import {
|
||||||
|
authUser,
|
||||||
|
whoamiPayload,
|
||||||
|
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
import Layout from "./+layout.svelte";
|
import Layout from "./+layout.svelte";
|
||||||
|
|
||||||
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } }));
|
vi.mock(
|
||||||
|
"$app/state",
|
||||||
|
() => ({ page: { url: new URL("https://example.test/w/example") } }),
|
||||||
|
);
|
||||||
const storageKey = "yoi.sidebar.mode.v1";
|
const storageKey = "yoi.sidebar.mode.v1";
|
||||||
const legacyKey = "yoi.sidebar.folded.v1";
|
const legacyKey = "yoi.sidebar.folded.v1";
|
||||||
const props = {
|
const props = {
|
||||||
params: {},
|
params: {},
|
||||||
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
||||||
data: { deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: null }) },
|
data: {
|
||||||
|
deferredCatalog: settle({
|
||||||
|
accessibleWorkspaces: [],
|
||||||
|
workspaceCatalogError: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
|
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
|
||||||
const folded = () => frame().classList.contains("folded");
|
const folded = () => frame().classList.contains("folded");
|
||||||
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
|
const hoverRegion = () =>
|
||||||
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
|
frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
|
||||||
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
const enter = () =>
|
||||||
|
fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
|
||||||
|
const leave = () =>
|
||||||
|
fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||||
const delay = () => vi.advanceTimersByTimeAsync(200);
|
const delay = () => vi.advanceTimersByTimeAsync(200);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
clearWorkspaceAlerts();
|
clearWorkspaceAlerts();
|
||||||
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
browserSession.snapshot = {
|
||||||
if (String(input) === '/api/auth/whoami') return Response.json({ actor: null });
|
kind: "ready",
|
||||||
throw new Error(`Unexpected request ${input}`);
|
value: whoamiPayload({ ...authUser, auth_method: "browser_session" }),
|
||||||
}));
|
} as typeof browserSession.snapshot;
|
||||||
page.url = new URL('https://example.test/w/example');
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input) => {
|
||||||
|
if (String(input) === "/api/auth/whoami") {
|
||||||
|
return Response.json({ session: { kind: "anonymous" }, actor: null });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request ${input}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
page.url = new URL("https://example.test/w/example");
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -42,22 +70,36 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(['/', '/dashboard', '/login', '/register', '/account', '/hosting/settings'])(
|
test.each([
|
||||||
'standalone %s never displays retained private Workspace notifications',
|
"/",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/account",
|
||||||
|
"/hosting/settings",
|
||||||
|
])(
|
||||||
|
"standalone %s never displays retained private Workspace notifications",
|
||||||
(path) => {
|
(path) => {
|
||||||
pushWorkspaceAlert('error', 'Private Workspace Orion: worker deletion failed');
|
pushWorkspaceAlert(
|
||||||
page.url = new URL(path, 'https://example.test');
|
"error",
|
||||||
|
"Private Workspace Orion: worker deletion failed",
|
||||||
|
);
|
||||||
|
page.url = new URL(path, "https://example.test");
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByText('Page content')).toBeTruthy();
|
expect(screen.getByText("Page content")).toBeTruthy();
|
||||||
expect(screen.queryByText('Private Workspace Orion: worker deletion failed')).toBeNull();
|
expect(
|
||||||
expect(screen.queryByRole('alert')).toBeNull();
|
screen.queryByText("Private Workspace Orion: worker deletion failed"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test('Workspace shell continues displaying Workspace action notifications', () => {
|
test("Workspace shell continues displaying Workspace action notifications", () => {
|
||||||
pushWorkspaceAlert('error', 'Worker deletion failed');
|
pushWorkspaceAlert("error", "Worker deletion failed");
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByRole('alert').textContent).toContain('Worker deletion failed');
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
|
"Worker deletion failed",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
|
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
|
||||||
@@ -65,7 +107,10 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
|
|||||||
props: {
|
props: {
|
||||||
...props,
|
...props,
|
||||||
data: {
|
data: {
|
||||||
deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: "Workspace catalog request failed" }),
|
deferredCatalog: settle({
|
||||||
|
accessibleWorkspaces: [],
|
||||||
|
workspaceCatalogError: "Workspace catalog request failed",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -78,10 +123,16 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
|
|||||||
|
|
||||||
test("unpinning changes mode, and pointer departure closes the preview", async () => {
|
test("unpinning changes mode, and pointer departure closes the preview", async () => {
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
),
|
||||||
|
).toBe("true");
|
||||||
await enter();
|
await enter();
|
||||||
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
|
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
|
||||||
|
detail: 1,
|
||||||
|
});
|
||||||
expect(localStorage.getItem(storageKey)).toBe("hover");
|
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||||
expect(folded()).toBe(false);
|
expect(folded()).toBe(false);
|
||||||
await leave();
|
await leave();
|
||||||
@@ -107,7 +158,9 @@ test("footer pointer clicks release previous sidebar link focus before unpinning
|
|||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
frame().querySelector<HTMLAnchorElement>("a")!.focus();
|
frame().querySelector<HTMLAnchorElement>("a")!.focus();
|
||||||
await tick();
|
await tick();
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
|
||||||
|
detail: 1,
|
||||||
|
});
|
||||||
expect(folded()).toBe(true);
|
expect(folded()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,12 +174,15 @@ test("keyboard mode switching preserves the focused preview", async () => {
|
|||||||
expect(folded()).toBe(false);
|
expect(folded()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
|
test.each(["pinned", "hover"])(
|
||||||
localStorage.setItem(storageKey, mode);
|
"restores %s mode without persisting a transient open state",
|
||||||
render(Layout, { props });
|
(mode) => {
|
||||||
expect(folded()).toBe(mode === "hover");
|
localStorage.setItem(storageKey, mode);
|
||||||
expect(localStorage.getItem(storageKey)).toBe(mode);
|
render(Layout, { props });
|
||||||
});
|
expect(folded()).toBe(mode === "hover");
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe(mode);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
|
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
|
||||||
localStorage.setItem(legacyKey, String(value));
|
localStorage.setItem(legacyKey, String(value));
|
||||||
@@ -171,7 +227,8 @@ test("footer hover does not open or hold the preview, but its button still chang
|
|||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
|
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
|
||||||
const button = screen.getByRole("button", { name: "Pin sidebar" });
|
const button = screen.getByRole("button", { name: "Pin sidebar" });
|
||||||
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull();
|
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not
|
||||||
|
.toBeNull();
|
||||||
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
|
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
|
||||||
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
|
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
|
||||||
await delay();
|
await delay();
|
||||||
@@ -184,7 +241,8 @@ test("footer hover does not open or hold the preview, but its button still chang
|
|||||||
expect(folded()).toBe(true);
|
expect(folded()).toBe(true);
|
||||||
await fireEvent.click(button, { detail: 1 });
|
await fireEvent.click(button, { detail: 1 });
|
||||||
expect(folded()).toBe(false);
|
expect(folded()).toBe(false);
|
||||||
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull();
|
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not
|
||||||
|
.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
|
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
|
||||||
@@ -206,7 +264,9 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
|
|||||||
localStorage.setItem(storageKey, "hover");
|
localStorage.setItem(storageKey, "hover");
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
await enter();
|
await enter();
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 });
|
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), {
|
||||||
|
detail: 1,
|
||||||
|
});
|
||||||
await leave();
|
await leave();
|
||||||
await delay();
|
await delay();
|
||||||
expect(folded()).toBe(false);
|
expect(folded()).toBe(false);
|
||||||
@@ -214,7 +274,10 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("mode survives logout and remount, but an open preview does not", async () => {
|
test("mode survives logout and remount, but an open preview does not", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => Response.json({ status: "logged_out" })),
|
||||||
|
);
|
||||||
localStorage.setItem(storageKey, "hover");
|
localStorage.setItem(storageKey, "hover");
|
||||||
const view = render(Layout, { props });
|
const view = render(Layout, { props });
|
||||||
await enter();
|
await enter();
|
||||||
@@ -229,7 +292,9 @@ test("touch does not hover; mobile header opens temporarily without changing mod
|
|||||||
const matchMedia = window.matchMedia.bind(window);
|
const matchMedia = window.matchMedia.bind(window);
|
||||||
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
|
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
|
||||||
const result = matchMedia(query);
|
const result = matchMedia(query);
|
||||||
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" });
|
Object.defineProperty(result, "matches", {
|
||||||
|
value: query === "(max-width: 760px)",
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
localStorage.setItem(storageKey, "hover");
|
localStorage.setItem(storageKey, "hover");
|
||||||
@@ -255,7 +320,9 @@ test("invalid storage falls back to pinned", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("unavailable storage does not prevent mode switching", async () => {
|
test("unavailable storage does not prevent mode switching", async () => {
|
||||||
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
|
vi.spyOn(window, "localStorage", "get").mockImplementation(() => {
|
||||||
|
throw new DOMException("Blocked", "SecurityError");
|
||||||
|
});
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
|
||||||
await delay();
|
await delay();
|
||||||
@@ -266,7 +333,9 @@ test("unavailable storage does not prevent mode switching", async () => {
|
|||||||
|
|
||||||
test("full storage still restores the mode and allows changes", async () => {
|
test("full storage still restores the mode and allows changes", async () => {
|
||||||
localStorage.setItem(storageKey, "hover");
|
localStorage.setItem(storageKey, "hover");
|
||||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||||
|
throw new DOMException("Full", "QuotaExceededError");
|
||||||
|
});
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
|
||||||
expect(folded()).toBe(false);
|
expect(folded()).toBe(false);
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import { parseWorkspaceResponse } from "#lib/workspace/api/workspace-model.ts";
|
|||||||
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
|
|
||||||
export const load = ({ fetch, params }: Parameters<LayoutLoad>[0]) => {
|
export const load = async (
|
||||||
|
{ fetch, params, parent }: Parameters<LayoutLoad>[0],
|
||||||
|
) => {
|
||||||
|
await parent();
|
||||||
const workspaceId = params.workspaceId;
|
const workspaceId = params.workspaceId;
|
||||||
return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) };
|
return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) };
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspaceId: string) {
|
async function loadWorkspace(
|
||||||
|
fetch: Parameters<LayoutLoad>[0]["fetch"],
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
const [workspaceResult, repositoryResult] = await Promise.all([
|
const [workspaceResult, repositoryResult] = await Promise.all([
|
||||||
loadJson(
|
loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
@@ -43,4 +49,4 @@ async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspac
|
|||||||
repositories,
|
repositories,
|
||||||
repositoriesError,
|
repositoriesError,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { loadDashboard } from "#lib/workspace/home/dashboard.ts";
|
import { loadDashboard } from "#lib/workspace/home/dashboard.ts";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = ({ fetch, params, depends }) => {
|
export const load: PageLoad = async ({ fetch, params, depends, parent }) => {
|
||||||
|
await parent();
|
||||||
depends("workspace:home");
|
depends("workspace:home");
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
|||||||
const depends = vi.fn();
|
const depends = vi.fn();
|
||||||
const result = await load(
|
const result = await load(
|
||||||
{
|
{
|
||||||
|
parent: async () => ({}),
|
||||||
fetch,
|
fetch,
|
||||||
params: { workspaceId: "home-owner" },
|
params: { workspaceId: "home-owner" },
|
||||||
depends,
|
depends,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import {
|
import {
|
||||||
@@ -11,36 +12,39 @@ import {
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||||
|
|
||||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
export const load = deferPage(
|
||||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||||
const objectiveId = resourceKey(params.objectiveId);
|
const apiPath = (path: string) =>
|
||||||
const objective = await loadJson(
|
workspaceApiPath(params.workspaceId, path);
|
||||||
fetch,
|
const objectiveId = resourceKey(params.objectiveId);
|
||||||
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
const objective = await loadJson(
|
||||||
undefined,
|
fetch,
|
||||||
parseObjectiveDetail,
|
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
|
||||||
TICKET_BROWSER_API_LOAD_POLICY,
|
undefined,
|
||||||
);
|
parseObjectiveDetail,
|
||||||
|
TICKET_BROWSER_API_LOAD_POLICY,
|
||||||
if (objective.data) {
|
|
||||||
const canonical = canonicalResourceReference(
|
|
||||||
objective.data.resource_key,
|
|
||||||
objective.data.title,
|
|
||||||
);
|
);
|
||||||
if (params.objectiveId !== canonical) {
|
|
||||||
redirect(
|
|
||||||
308,
|
|
||||||
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
|
|
||||||
encodeURIComponent(canonical)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (objective.data) {
|
||||||
workspaceId: params.workspaceId,
|
const canonical = canonicalResourceReference(
|
||||||
objectiveId,
|
objective.data.resource_key,
|
||||||
objective: objective.data,
|
objective.data.title,
|
||||||
objectiveError: objective.error,
|
);
|
||||||
};
|
if (params.objectiveId !== canonical) {
|
||||||
});
|
redirect(
|
||||||
|
308,
|
||||||
|
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
|
||||||
|
encodeURIComponent(canonical)
|
||||||
|
}${browserUrlSuffix(url)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
objectiveId,
|
||||||
|
objective: objective.data,
|
||||||
|
objectiveError: objective.error,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||||
import { error, redirect } from "@sveltejs/kit";
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||||
@@ -14,57 +15,62 @@ import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||||
|
|
||||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
export const load = deferPage(
|
||||||
const reference = resourceKey(params.ticketId);
|
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||||
const ticketPath = workspaceApiPath(
|
const reference = resourceKey(params.ticketId);
|
||||||
params.workspaceId,
|
const ticketPath = workspaceApiPath(
|
||||||
`/tickets/${encodeURIComponent(reference)}`,
|
params.workspaceId,
|
||||||
);
|
`/tickets/${encodeURIComponent(reference)}`,
|
||||||
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
|
||||||
loadJson(
|
|
||||||
fetch,
|
|
||||||
ticketPath,
|
|
||||||
undefined,
|
|
||||||
parseTicketDetail,
|
|
||||||
TICKET_BROWSER_API_LOAD_POLICY,
|
|
||||||
),
|
|
||||||
loadWorkspaceRepositoryList(fetch, params.workspaceId),
|
|
||||||
loadJson<WorkspaceOrchestratorStatus>(
|
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
|
||||||
undefined,
|
|
||||||
parseBrowserWorkspaceOrchestratorResponse,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if (!ticket.data) {
|
|
||||||
const message = ticket.error ?? "Ticket details could not be loaded";
|
|
||||||
if (ticket.status !== undefined && ticket.status >= 400 && ticket.status <= 599) {
|
|
||||||
error(ticket.status, message);
|
|
||||||
}
|
|
||||||
// Expected read failures belong to the deferred loader, not component construction.
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
if (ticket.data) {
|
|
||||||
const canonical = canonicalResourceReference(
|
|
||||||
ticket.data.resource_key,
|
|
||||||
ticket.data.title,
|
|
||||||
);
|
);
|
||||||
if (params.ticketId !== canonical) {
|
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
||||||
redirect(
|
loadJson(
|
||||||
308,
|
fetch,
|
||||||
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
|
ticketPath,
|
||||||
encodeURIComponent(canonical)
|
undefined,
|
||||||
}`,
|
parseTicketDetail,
|
||||||
);
|
TICKET_BROWSER_API_LOAD_POLICY,
|
||||||
|
),
|
||||||
|
loadWorkspaceRepositoryList(fetch, params.workspaceId),
|
||||||
|
loadJson<WorkspaceOrchestratorStatus>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||||
|
undefined,
|
||||||
|
parseBrowserWorkspaceOrchestratorResponse,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (!ticket.data) {
|
||||||
|
const message = ticket.error ?? "Ticket details could not be loaded";
|
||||||
|
if (
|
||||||
|
ticket.status !== undefined && ticket.status >= 400 &&
|
||||||
|
ticket.status <= 599
|
||||||
|
) {
|
||||||
|
error(ticket.status, message);
|
||||||
|
}
|
||||||
|
// Expected read failures belong to the deferred loader, not component construction.
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
if (ticket.data) {
|
||||||
const repositories = repositoriesRaw;
|
const canonical = canonicalResourceReference(
|
||||||
|
ticket.data.resource_key,
|
||||||
|
ticket.data.title,
|
||||||
|
);
|
||||||
|
if (params.ticketId !== canonical) {
|
||||||
|
redirect(
|
||||||
|
308,
|
||||||
|
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
|
||||||
|
encodeURIComponent(canonical)
|
||||||
|
}${browserUrlSuffix(url)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const repositories = repositoriesRaw;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
ticketId: ticket.data?.id ?? reference,
|
ticketId: ticket.data?.id ?? reference,
|
||||||
ticket,
|
ticket,
|
||||||
repositories,
|
repositories,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async function loadPage(
|
|||||||
) {
|
) {
|
||||||
const result = await load(
|
const result = await load(
|
||||||
{
|
{
|
||||||
|
parent: async () => ({}),
|
||||||
fetch: fetchFn,
|
fetch: fetchFn,
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
url: new URL(
|
url: new URL(
|
||||||
@@ -141,7 +142,7 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
|
|||||||
"/api/w/workspace-a/repositories": () => repos.promise,
|
"/api/w/workspace-a/repositories": () => repos.promise,
|
||||||
});
|
});
|
||||||
const pending = loadPage(request);
|
const pending = loadPage(request);
|
||||||
expect(request).toHaveBeenCalledTimes(1);
|
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||||
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
|
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
|
||||||
expect(request.mock.calls[0][1]?.method).toBe("POST");
|
expect(request.mock.calls[0][1]?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
|
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
|
||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||||
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
|
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
|
||||||
@@ -8,33 +9,37 @@ import {
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||||
|
|
||||||
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
|
export const load = deferPage(
|
||||||
const reference = resourceKey(params.workerRef);
|
async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
|
||||||
const result = await loadJson(
|
const reference = resourceKey(params.workerRef);
|
||||||
fetch,
|
const result = await loadJson(
|
||||||
workspaceApiPath(
|
fetch,
|
||||||
params.workspaceId,
|
workspaceApiPath(
|
||||||
`/workers/${encodeURIComponent(reference)}`,
|
params.workspaceId,
|
||||||
),
|
`/workers/${encodeURIComponent(reference)}`,
|
||||||
undefined,
|
),
|
||||||
parseWorkerSummary,
|
undefined,
|
||||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
parseWorkerSummary,
|
||||||
);
|
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||||
if (result.data?.resource_key) {
|
|
||||||
const canonical = canonicalResourceReference(
|
|
||||||
result.data.resource_key,
|
|
||||||
result.data.display_name,
|
|
||||||
);
|
);
|
||||||
if (params.workerRef !== canonical) {
|
if (result.data?.resource_key) {
|
||||||
redirect(
|
const canonical = canonicalResourceReference(
|
||||||
308,
|
result.data.resource_key,
|
||||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`,
|
result.data.display_name,
|
||||||
);
|
);
|
||||||
|
if (params.workerRef !== canonical) {
|
||||||
|
redirect(
|
||||||
|
308,
|
||||||
|
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
|
||||||
|
encodeURIComponent(canonical)
|
||||||
|
}${browserUrlSuffix(url)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return {
|
||||||
return {
|
workspaceId: params.workspaceId,
|
||||||
workspaceId: params.workspaceId,
|
worker: result.data,
|
||||||
worker: result.data,
|
workerError: result.error,
|
||||||
workerError: result.error,
|
};
|
||||||
};
|
},
|
||||||
});
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user