feat: artifact sandboxing

This commit is contained in:
jif-oai
2026-03-05 14:41:07 +00:00
parent 5e92f4af12
commit 1240749413
9 changed files with 692 additions and 98 deletions

View File

@@ -29,3 +29,4 @@ pub use runtime::RuntimePathEntry;
pub use runtime::can_manage_artifact_runtime;
pub use runtime::is_js_runtime_available;
pub use runtime::load_cached_runtime;
pub use runtime::system_node_path;

View File

@@ -63,6 +63,11 @@ pub fn is_js_runtime_available(codex_home: &Path, runtime_version: &str) -> bool
.is_some()
}
/// Returns the absolute path to a machine Node executable if one is available.
pub fn system_node_path() -> Option<PathBuf> {
system_node_runtime().map(|runtime| runtime.executable_path().to_path_buf())
}
/// Returns `true` when this machine can use the managed artifact runtime flow.
///
/// This is a platform capability check, not a cache or binary availability check.

View File

@@ -12,6 +12,7 @@ pub use js_runtime::JsRuntime;
pub use js_runtime::JsRuntimeKind;
pub use js_runtime::can_manage_artifact_runtime;
pub use js_runtime::is_js_runtime_available;
pub use js_runtime::system_node_path;
pub use manager::ArtifactRuntimeManager;
pub use manager::ArtifactRuntimeManagerConfig;
pub use manager::ArtifactRuntimeReleaseLocator;

View File

@@ -1,21 +1,20 @@
use async_trait::async_trait;
use codex_artifacts::ArtifactBuildRequest;
use codex_artifacts::ArtifactCommandOutput;
use codex_artifacts::ArtifactRuntimeManager;
use codex_artifacts::ArtifactRuntimeManagerConfig;
use codex_artifacts::ArtifactsClient;
use codex_artifacts::ArtifactsError;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
use tokio::fs;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::exec_policy::ExecApprovalRequest;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::protocol::ExecCommandSource;
use crate::sandboxing::SandboxPermissions;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -23,14 +22,38 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventFailure;
use crate::tools::events::ToolEventStage;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::runtimes::artifacts::ArtifactApprovalKey;
use crate::tools::runtimes::artifacts::ArtifactExecRequest;
use crate::tools::runtimes::artifacts::ArtifactRuntime;
use crate::tools::sandboxing::ToolError;
use codex_protocol::models::FunctionCallOutputBody;
const ARTIFACTS_TOOL_NAME: &str = "artifacts";
const ARTIFACTS_PRAGMA_PREFIXES: [&str; 2] = ["// codex-artifacts:", "// codex-artifact-tool:"];
pub(crate) const PINNED_ARTIFACT_RUNTIME_VERSION: &str = "2.4.0";
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
const ARTIFACT_BUILD_LAUNCHER_RELATIVE: &str = "runtime-scripts/artifacts/build-launcher.mjs";
const ARTIFACT_BUILD_LAUNCHER_SOURCE: &str = concat!(
"import { pathToFileURL } from \"node:url\";\n",
"const [sourcePath] = process.argv.slice(2);\n",
"if (!sourcePath) {\n",
" throw new Error(\"missing artifact source path\");\n",
"}\n",
"const artifactTool = await import(pathToFileURL(process.env.CODEX_ARTIFACT_BUILD_ENTRYPOINT).href);\n",
"globalThis.artifactTool = artifactTool;\n",
"globalThis.artifacts = artifactTool;\n",
"globalThis.codexArtifacts = artifactTool;\n",
"for (const [name, value] of Object.entries(artifactTool)) {\n",
" if (name === \"default\" || Object.prototype.hasOwnProperty.call(globalThis, name)) {\n",
" continue;\n",
" }\n",
" globalThis[name] = value;\n",
"}\n",
"await import(pathToFileURL(sourcePath).href);\n",
);
pub struct ArtifactsHandler;
@@ -40,6 +63,11 @@ struct ArtifactsToolArgs {
timeout_ms: Option<u64>,
}
struct PreparedArtifactBuild {
request: ArtifactExecRequest,
_source_dir: TempDir,
}
#[async_trait]
impl ToolHandler for ArtifactsHandler {
fn kind(&self) -> ToolKind {
@@ -78,44 +106,58 @@ impl ToolHandler for ArtifactsHandler {
}
};
let client = ArtifactsClient::from_runtime_manager(default_runtime_manager(
turn.config.codex_home.clone(),
));
let started_at = Instant::now();
emit_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
let result = client
.execute_build(ArtifactBuildRequest {
source: args.source,
cwd: turn.cwd.clone(),
timeout: Some(Duration::from_millis(
args.timeout_ms
.unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64),
)),
env: Default::default(),
})
let runtime = default_runtime_manager(turn.config.codex_home.clone())
.ensure_installed()
.await;
let (success, output) = match result {
Ok(output) => (output.success(), output),
Err(error) => (false, error_output(&error)),
let runtime = match runtime {
Ok(runtime) => runtime,
Err(error) => {
return Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text(error.to_string()),
success: Some(false),
});
}
};
emit_exec_end(
let prepared = prepare_artifact_build(
session.as_ref(),
turn.as_ref(),
&call_id,
&output,
started_at.elapsed(),
success,
runtime,
args.source,
args.timeout_ms
.unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64),
)
.await;
.await?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text(format_artifact_output(&output)),
success: Some(success),
})
let emitter = ToolEmitter::shell(
artifact_display_command(),
prepared.request.cwd.clone(),
ExecCommandSource::Agent,
true,
);
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
emitter.begin(event_ctx).await;
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ArtifactRuntime;
let tool_ctx = crate::tools::sandboxing::ToolCtx {
session: session.clone(),
turn: turn.clone(),
call_id: call_id.clone(),
tool_name: ARTIFACTS_TOOL_NAME.to_string(),
};
let result = orchestrator
.run(
&mut runtime,
&prepared.request,
&tool_ctx,
&turn,
turn.approval_policy.value(),
)
.await
.map(|result| result.output);
Ok(finish_artifact_execution(&emitter, event_ctx, result).await)
}
}
@@ -219,81 +261,252 @@ fn default_runtime_manager(codex_home: std::path::PathBuf) -> ArtifactRuntimeMan
))
}
async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) {
let emitter = ToolEmitter::shell(
vec![ARTIFACTS_TOOL_NAME.to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
true,
async fn prepare_artifact_build(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
installed_runtime: codex_artifacts::InstalledArtifactRuntime,
source: String,
timeout_ms: u64,
) -> Result<PreparedArtifactBuild, FunctionCallError> {
let launcher_path = ensure_artifact_build_launcher(turn.config.codex_home.as_path()).await?;
let source_dir = TempDir::new().map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to create artifact source staging directory: {error}"
))
})?;
let source_path = source_dir.path().join("artifact-source.mjs");
fs::write(&source_path, source).await.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to write artifact source at `{}`: {error}",
source_path.display()
))
})?;
let js_runtime = installed_runtime
.resolve_js_runtime()
.map_err(|error| FunctionCallError::RespondToModel(error.to_string()))?;
let command =
build_artifact_build_command(js_runtime.executable_path(), &launcher_path, &source_path);
let approval_key = ArtifactApprovalKey {
command_prefix: artifact_prefix_rule(&command),
cwd: turn.cwd.clone(),
};
let escalation_approval_requirement = session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: turn.approval_policy.value(),
sandbox_policy: turn.sandbox_policy.get(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(approval_key.command_prefix.clone()),
})
.await;
let env = build_artifact_env(
&installed_runtime,
js_runtime.executable_path(),
js_runtime.requires_electron_run_as_node(),
codex_artifacts::system_node_path().as_deref(),
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
emitter.emit(ctx, ToolEventStage::Begin).await;
Ok(PreparedArtifactBuild {
request: ArtifactExecRequest {
command,
cwd: turn.cwd.clone(),
timeout_ms: Some(timeout_ms),
env,
approval_key,
escalation_approval_requirement,
},
_source_dir: source_dir,
})
}
async fn emit_exec_end(
session: &Session,
turn: &TurnContext,
call_id: &str,
output: &ArtifactCommandOutput,
duration: Duration,
success: bool,
) {
let exec_output = ExecToolCallOutput {
exit_code: output.exit_code.unwrap_or(1),
stdout: StreamOutput::new(output.stdout.clone()),
stderr: StreamOutput::new(output.stderr.clone()),
aggregated_output: StreamOutput::new(format_artifact_output(output)),
duration,
timed_out: false,
};
let emitter = ToolEmitter::shell(
vec![ARTIFACTS_TOOL_NAME.to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
true,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
let stage = if success {
ToolEventStage::Success(exec_output)
} else {
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
};
emitter.emit(ctx, stage).await;
async fn ensure_artifact_build_launcher(codex_home: &Path) -> Result<PathBuf, FunctionCallError> {
let launcher_path = artifact_build_launcher_path(codex_home);
match fs::read_to_string(&launcher_path).await {
Ok(existing) if existing == ARTIFACT_BUILD_LAUNCHER_SOURCE => return Ok(launcher_path),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(FunctionCallError::RespondToModel(format!(
"failed to read artifact launcher `{}`: {error}",
launcher_path.display()
)));
}
}
if let Some(parent) = launcher_path.parent() {
fs::create_dir_all(parent).await.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to create artifact launcher directory `{}`: {error}",
parent.display()
))
})?;
}
fs::write(&launcher_path, ARTIFACT_BUILD_LAUNCHER_SOURCE)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"failed to write artifact launcher `{}`: {error}",
launcher_path.display()
))
})?;
Ok(launcher_path)
}
fn format_artifact_output(output: &ArtifactCommandOutput) -> String {
let stdout = output.stdout.trim();
let stderr = output.stderr.trim();
let mut sections = vec![format!(
"exit_code: {}",
output
.exit_code
.map(|code| code.to_string())
.unwrap_or_else(|| "null".to_string())
)];
fn artifact_build_launcher_path(codex_home: &Path) -> PathBuf {
codex_home.join(ARTIFACT_BUILD_LAUNCHER_RELATIVE)
}
fn build_artifact_build_command(
executable_path: &Path,
launcher_path: &Path,
source_path: &Path,
) -> Vec<String> {
vec![
executable_path.display().to_string(),
launcher_path.display().to_string(),
source_path.display().to_string(),
]
}
fn artifact_prefix_rule(command: &[String]) -> Vec<String> {
command.iter().take(2).cloned().collect()
}
fn artifact_display_command() -> Vec<String> {
vec![ARTIFACTS_TOOL_NAME.to_string()]
}
fn build_artifact_env(
installed_runtime: &codex_artifacts::InstalledArtifactRuntime,
selected_runtime_path: &Path,
requires_electron_run_as_node: bool,
host_node_path: Option<&Path>,
) -> HashMap<String, String> {
let mut env = HashMap::from([
(
"CODEX_ARTIFACT_BUILD_ENTRYPOINT".to_string(),
installed_runtime.build_js_path().display().to_string(),
),
(
"CODEX_ARTIFACT_RENDER_ENTRYPOINT".to_string(),
installed_runtime.render_cli_path().display().to_string(),
),
]);
if requires_electron_run_as_node {
env.insert("ELECTRON_RUN_AS_NODE".to_string(), "1".to_string());
}
if selected_runtime_path == installed_runtime.node_path()
&& let Some(host_node_path) = host_node_path
{
env.insert(
"CODEX_ARTIFACT_NODE_PATH".to_string(),
host_node_path.display().to_string(),
);
}
env
}
async fn finish_artifact_execution(
emitter: &ToolEmitter,
event_ctx: ToolEventCtx<'_>,
result: Result<ExecToolCallOutput, ToolError>,
) -> ToolOutput {
let (body, success, stage) = match result {
Ok(output) => {
let success = output.exit_code == 0;
let body = format_artifact_output(&output);
let stage = if success {
ToolEventStage::Success(output)
} else {
ToolEventStage::Failure(ToolEventFailure::Output(output))
};
(body, success, stage)
}
Err(ToolError::Codex(crate::error::CodexErr::Sandbox(
crate::error::SandboxErr::Timeout { output },
)))
| Err(ToolError::Codex(crate::error::CodexErr::Sandbox(
crate::error::SandboxErr::Denied { output, .. },
))) => {
let output = *output;
let body = format_artifact_output(&output);
(
body,
false,
ToolEventStage::Failure(ToolEventFailure::Output(output)),
)
}
Err(ToolError::Codex(error)) => {
let message = format!("execution error: {error:?}");
(
message.clone(),
false,
ToolEventStage::Failure(ToolEventFailure::Message(message)),
)
}
Err(ToolError::Rejected(message)) => {
let normalized = if message == "rejected by user" {
"artifact command rejected by user".to_string()
} else {
message
};
(
normalized.clone(),
false,
ToolEventStage::Failure(ToolEventFailure::Rejected(normalized)),
)
}
};
emitter.emit(event_ctx, stage).await;
ToolOutput::Function {
body: FunctionCallOutputBody::Text(body),
success: Some(success),
}
}
fn format_artifact_output(output: &ExecToolCallOutput) -> String {
let stdout = output.stdout.text.trim();
let stderr = format_artifact_stderr(output);
let mut sections = vec![format!("exit_code: {}", output.exit_code)];
if !stdout.is_empty() {
sections.push(format!("stdout:\n{stdout}"));
}
if !stderr.is_empty() {
sections.push(format!("stderr:\n{stderr}"));
}
if stdout.is_empty() && stderr.is_empty() && output.success() {
if stdout.is_empty() && stderr.is_empty() && output.exit_code == 0 {
sections.push("artifact JS completed successfully.".to_string());
}
sections.join("\n\n")
}
fn error_output(error: &ArtifactsError) -> ArtifactCommandOutput {
ArtifactCommandOutput {
exit_code: Some(1),
stdout: String::new(),
stderr: error.to_string(),
fn format_artifact_stderr(output: &ExecToolCallOutput) -> String {
let stderr = output.stderr.text.trim();
if output.timed_out {
let timeout_message = format!(
"command timed out after {} milliseconds",
output.duration.as_millis()
);
if stderr.is_empty() {
timeout_message
} else {
format!("{timeout_message}\n{stderr}")
}
} else {
stderr.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::StreamOutput;
use codex_artifacts::RuntimeEntrypoints;
use codex_artifacts::RuntimePathEntry;
use tempfile::TempDir;
@@ -411,11 +624,144 @@ mod tests {
#[test]
fn format_artifact_output_includes_success_message_when_silent() {
let formatted = format_artifact_output(&ArtifactCommandOutput {
exit_code: Some(0),
stdout: String::new(),
stderr: String::new(),
let formatted = format_artifact_output(&ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(String::new()),
duration: Duration::ZERO,
timed_out: false,
});
assert!(formatted.contains("artifact JS completed successfully."));
}
#[test]
fn format_artifact_output_includes_timeout_message() {
let formatted = format_artifact_output(&ExecToolCallOutput {
exit_code: 124,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new("render hung".to_string()),
aggregated_output: StreamOutput::new("render hung".to_string()),
duration: Duration::from_millis(1_500),
timed_out: true,
});
assert!(formatted.contains("command timed out after 1500 milliseconds"));
assert!(formatted.contains("render hung"));
}
#[test]
fn artifact_prefix_rule_uses_stable_launcher_prefix() {
let command = build_artifact_build_command(
Path::new("/runtime/node"),
Path::new("/codex/home/runtime-scripts/artifacts/build-launcher.mjs"),
Path::new("/tmp/artifact-source.mjs"),
);
assert_eq!(
artifact_prefix_rule(&command),
vec![
"/runtime/node".to_string(),
"/codex/home/runtime-scripts/artifacts/build-launcher.mjs".to_string(),
]
);
}
#[test]
fn artifact_display_command_is_user_facing() {
assert_eq!(artifact_display_command(), vec!["artifacts".to_string()]);
}
#[test]
fn build_artifact_env_includes_host_node_override_for_bundled_wrapper() {
let runtime = codex_artifacts::InstalledArtifactRuntime::new(
PathBuf::from("/runtime"),
PINNED_ARTIFACT_RUNTIME_VERSION.to_string(),
codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform"),
codex_artifacts::ExtractedRuntimeManifest {
schema_version: 1,
runtime_version: PINNED_ARTIFACT_RUNTIME_VERSION.to_string(),
node: RuntimePathEntry {
relative_path: "node/bin/node".to_string(),
},
entrypoints: RuntimeEntrypoints {
build_js: RuntimePathEntry {
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
},
render_cli: RuntimePathEntry {
relative_path: "granola-render/dist/render_cli.mjs".to_string(),
},
},
},
PathBuf::from("/runtime/node/bin/node"),
PathBuf::from("/runtime/artifact-tool/dist/artifact_tool.mjs"),
PathBuf::from("/runtime/granola-render/dist/render_cli.mjs"),
);
let env = build_artifact_env(
&runtime,
Path::new("/runtime/node/bin/node"),
false,
Some(Path::new("/opt/homebrew/bin/node")),
);
assert_eq!(
env.get("CODEX_ARTIFACT_NODE_PATH"),
Some(&"/opt/homebrew/bin/node".to_string())
);
}
#[test]
fn build_artifact_env_skips_host_node_override_for_machine_runtime() {
let runtime = codex_artifacts::InstalledArtifactRuntime::new(
PathBuf::from("/runtime"),
PINNED_ARTIFACT_RUNTIME_VERSION.to_string(),
codex_artifacts::ArtifactRuntimePlatform::detect_current().expect("detect platform"),
codex_artifacts::ExtractedRuntimeManifest {
schema_version: 1,
runtime_version: PINNED_ARTIFACT_RUNTIME_VERSION.to_string(),
node: RuntimePathEntry {
relative_path: "node/bin/node".to_string(),
},
entrypoints: RuntimeEntrypoints {
build_js: RuntimePathEntry {
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
},
render_cli: RuntimePathEntry {
relative_path: "granola-render/dist/render_cli.mjs".to_string(),
},
},
},
PathBuf::from("/runtime/node/bin/node"),
PathBuf::from("/runtime/artifact-tool/dist/artifact_tool.mjs"),
PathBuf::from("/runtime/granola-render/dist/render_cli.mjs"),
);
let env = build_artifact_env(
&runtime,
Path::new("/opt/homebrew/bin/node"),
false,
Some(Path::new("/opt/homebrew/bin/node")),
);
assert!(!env.contains_key("CODEX_ARTIFACT_NODE_PATH"));
}
#[tokio::test]
async fn ensure_artifact_build_launcher_writes_expected_source() {
let codex_home = TempDir::new().expect("create temp codex home");
let launcher_path = ensure_artifact_build_launcher(codex_home.path())
.await
.expect("write launcher");
assert_eq!(
launcher_path,
codex_home.path().join(ARTIFACT_BUILD_LAUNCHER_RELATIVE)
);
let launcher_source =
std::fs::read_to_string(&launcher_path).expect("read artifact launcher source");
assert!(launcher_source.contains("globalThis.artifacts = artifactTool;"));
assert!(launcher_source.contains("await import(pathToFileURL(sourcePath).href);"));
}
}

View File

@@ -0,0 +1,215 @@
use crate::codex::Session;
use crate::exec::ExecToolCallOutput;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub(crate) struct ArtifactApprovalKey {
pub(crate) command_prefix: Vec<String>,
pub(crate) cwd: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct ArtifactExecRequest {
pub(crate) command: Vec<String>,
pub(crate) cwd: PathBuf,
pub(crate) timeout_ms: Option<u64>,
pub(crate) env: HashMap<String, String>,
pub(crate) approval_key: ArtifactApprovalKey,
pub(crate) escalation_approval_requirement: ExecApprovalRequirement,
}
#[derive(Default)]
pub(crate) struct ArtifactRuntime;
impl ArtifactRuntime {
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
Some(crate::exec::StdoutStream {
sub_id: ctx.turn.sub_id.clone(),
call_id: ctx.call_id.clone(),
tx_event: ctx.session.get_tx_event(),
})
}
}
impl Sandboxable for ArtifactRuntime {
fn sandbox_preference(&self) -> SandboxablePreference {
SandboxablePreference::Auto
}
}
impl Approvable<ArtifactExecRequest> for ArtifactRuntime {
type ApprovalKey = ArtifactApprovalKey;
fn approval_keys(&self, req: &ArtifactExecRequest) -> Vec<Self::ApprovalKey> {
vec![req.approval_key.clone()]
}
fn start_approval_async<'a>(
&'a mut self,
req: &'a ArtifactExecRequest,
ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
let session: &'a Session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
let retry_reason = ctx.retry_reason.clone();
let command = req.command.clone();
let cwd = req.cwd.clone();
let approval_keys = self.approval_keys(req);
let escalation_approval_requirement = req.escalation_approval_requirement.clone();
Box::pin(async move {
if matches!(
escalation_approval_requirement,
ExecApprovalRequirement::Forbidden { .. }
) {
return ReviewDecision::Denied;
}
if retry_reason.is_some()
&& matches!(
escalation_approval_requirement,
ExecApprovalRequirement::Skip { .. }
)
{
return ReviewDecision::Approved;
}
with_cached_approval(
&session.services,
"artifacts",
approval_keys,
|| async move {
session
.request_command_approval(
turn,
call_id,
None,
command,
cwd,
retry_reason,
None,
escalation_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
None,
None,
)
.await
},
)
.await
})
}
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {
AskForApproval::Never => false,
AskForApproval::Reject(reject_config) => !reject_config.rejects_sandbox_approval(),
AskForApproval::OnFailure => true,
AskForApproval::OnRequest => true,
AskForApproval::UnlessTrusted => true,
}
}
fn exec_approval_requirement(
&self,
_req: &ArtifactExecRequest,
) -> Option<ExecApprovalRequirement> {
Some(ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
})
}
}
impl ToolRuntime<ArtifactExecRequest, ExecToolCallOutput> for ArtifactRuntime {
async fn run(
&mut self,
req: &ArtifactExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let spec = build_command_spec(
&req.command,
&req.cwd,
&req.env,
req.timeout_ms.into(),
SandboxPermissions::UseDefault,
None,
None,
)?;
let env = attempt
.env_for(spec, None)
.map_err(|err| ToolError::Codex(err.into()))?;
execute_env(env, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn auto_approves_retry_when_exec_policy_already_allows_launcher() {
let (session, turn) = make_session_and_context().await;
let mut runtime = ArtifactRuntime;
let req = ArtifactExecRequest {
command: vec![
"/path/to/node".to_string(),
"/path/to/launcher.mjs".to_string(),
"/tmp/source.mjs".to_string(),
],
cwd: PathBuf::from("/tmp"),
timeout_ms: Some(5_000),
env: HashMap::new(),
approval_key: ArtifactApprovalKey {
command_prefix: vec![
"/path/to/node".to_string(),
"/path/to/launcher.mjs".to_string(),
],
cwd: PathBuf::from("/tmp"),
},
escalation_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
};
let decision = runtime
.start_approval_async(
&req,
ApprovalCtx {
session: &session,
turn: &turn,
call_id: "call_artifact",
retry_reason: Some("command failed; retry without sandbox?".to_string()),
network_approval_context: None,
},
)
.await;
assert_eq!(decision, ReviewDecision::Approved);
}
}

View File

@@ -16,6 +16,7 @@ use std::collections::HashMap;
use std::path::Path;
pub mod apply_patch;
pub mod artifacts;
pub mod shell;
pub mod unified_exec;

View File

@@ -19,6 +19,10 @@ Use this skill when the user wants to create or modify presentation decks with t
- The full module is also available as `artifactTool`, `artifacts`, and `codexArtifacts`.
- You may still import Node built-ins such as `node:fs/promises` when you need to write preview bytes to disk.
- Save outputs under a user-visible path such as `artifacts/quarterly-update.pptx` or `artifacts/slide-1.png`.
- Do not assume `await PresentationFile.exportPptx(presentation)` writes a valid PowerPoint file. On March 5, 2026 the runtime returned PNG bytes while labeling them as `.pptx` for fresh exports.
- Before handoff, verify the exported `.pptx` signature with a local check such as `file artifacts/name.pptx` or `xxd -l 8 artifacts/name.pptx`.
- If the exported `.pptx` is not a real PowerPoint container, render PNG previews for every slide and package those PNGs into a valid `.pptx` with `python-pptx`.
- End every artifact run with a concise user-facing log that lists every file the script created or updated. Keep the message short and formatted for direct display, for example `Saved files` followed by one path per line.
## Quick Start
@@ -44,7 +48,13 @@ const subtitle = slide.shapes.add({
subtitle.text = "Launch status, reliability, and next milestones";
const pptxBlob = await PresentationFile.exportPptx(presentation);
await pptxBlob.save("artifacts/q2-product-update.pptx");
const pptxPath = "artifacts/q2-product-update.pptx";
await pptxBlob.save(pptxPath);
console.log([
"Saved files",
`- ${pptxPath}`,
].join("\n"));
```
## Runtime Guardrails
@@ -112,6 +122,7 @@ for (const slide of presentation.slides.items) {
- Add content with `slide.shapes.add(...)`, `slide.tables.add(...)`, `slide.elements.charts.add(...)`, and `slide.elements.images.add(...)` when you need preview-safe embedded images.
- Render a preview with `await presentation.export({ slide, format: "png", scale: 2 })`, then write `new Uint8Array(await blob.arrayBuffer())` with `node:fs/promises`.
- Export a `.pptx` with `await PresentationFile.exportPptx(presentation)`.
- Treat `.pptx` export as untrusted until the saved file signature is verified locally.
## Workflow
@@ -119,6 +130,8 @@ for (const slide of presentation.slides.items) {
- Do not begin by checking whether the local artifacts runtime package or cache exists. Assume the `artifacts` tool is ready and start authoring immediately; only investigate runtime installation or packaging if the tool fails before your slide code runs.
- If the API surface is unclear, do a tiny probe first: create one slide, add one shape, set `text` or `textStyle`, export one PNG, and inspect the result before scaling up to the full deck.
- Save the `.pptx` after meaningful milestones so the user can inspect output.
- After saving a `.pptx`, verify the on-disk file type before assuming export succeeded. If it is actually an image blob, keep the PNG previews and rebuild a valid deck from them.
- End the script with a final `console.log(...)` summary that names every file the run touched, using a compact user-facing format with one path per line.
- Prefer short copy and a reusable component system over text-heavy layouts; the preview loop is much faster than rescuing a dense slide after export.
- Text boxes do not reliably auto-fit. If copy might wrap, give the shape extra height up front, then shorten the copy or enlarge the box until the rendered PNG shows clear padding on every edge.
- Deliberately check text contrast against the actual fill or image behind it. Do not leave dark text on dark fills, light text on light fills, or any pairing that is hard to read at presentation distance.
@@ -129,6 +142,7 @@ for (const slide of presentation.slides.items) {
- If layout is repetitive, use `slide.autoLayout(...)` rather than hand-tuning every coordinate.
- QA with rendered PNG previews before handoff. In practice this is a more reliable quick check than importing the generated `.pptx` back into the runtime and inspecting round-tripped objects.
- Final QA means checking every rendered slide for contrast, intentional alignment, text superposition, clipped text, overflowing text, and inherited placeholder boxes. If text is hard to read against its background, if one text box overlaps another, if stacked text becomes hard to read, if any line touches a box edge, if text looks misaligned inside its box, or if PowerPoint shows `Click to add ...` placeholders, fix the layout or delete the inherited placeholder shapes and re-export before handoff.
- Final export QA also includes verifying that the nominal `.pptx` is actually a PowerPoint container rather than mislabeled PNG output from the runtime.
- When editing an existing file, load it first, mutate only the requested slides or elements, then export a new `.pptx`.
## Reference Map

View File

@@ -13,6 +13,7 @@ const presentation = Presentation.create({
- `Presentation.create()` creates a new empty deck.
- `await PresentationFile.importPptx(await FileBlob.load("deck.pptx"))` imports an existing deck.
- `await PresentationFile.exportPptx(presentation)` exports the deck as a saveable blob.
- Do not assume that saving the blob always yields a real PowerPoint container. On March 5, 2026 a fresh export path returned PNG bytes while keeping the `.pptx` extension.
- When using this skill operationally, start by authoring with these APIs rather than checking local runtime package directories first. Runtime or package-cache inspection is a fallback for cases where the `artifacts` tool itself fails before deck code executes.
## Slides
@@ -150,3 +151,5 @@ await fs.writeFile("artifacts/slide-1.png", previewBytes);
## Output
Prefer saving artifacts into an `artifacts/` directory in the current working tree so the user can inspect outputs easily.
Before handoff, verify the exported `.pptx` signature with a local tool such as `file` or `xxd`. If the output is not a real PowerPoint container, keep the rendered PNG previews and package them into a valid `.pptx` with `python-pptx` so the user still receives an opening deck.

View File

@@ -18,6 +18,7 @@ Use this skill when the user wants to create or modify workbooks with the `artif
- Named exports such as `Workbook`, `SpreadsheetFile`, and `FileBlob` are available directly.
- The full module is also available as `artifactTool`, `artifacts`, and `codexArtifacts`.
- Save outputs under a user-visible path such as `artifacts/revenue-model.xlsx`.
- End every artifact run with a concise user-facing log that lists every file the script created or updated. Keep the message short and formatted for direct display, for example `Saved files` followed by one path per line.
## Quick Start
@@ -38,7 +39,13 @@ sheet.getRange("E2").formulas = [["=SUM(C2:C4)"]];
workbook.recalculate();
const xlsxBlob = await SpreadsheetFile.exportXlsx(workbook);
await xlsxBlob.save("artifacts/revenue-model.xlsx");
const xlsxPath = "artifacts/revenue-model.xlsx";
await xlsxBlob.save(xlsxPath);
console.log([
"Saved files",
`- ${xlsxPath}`,
].join("\n"));
```
## Common Patterns
@@ -57,6 +64,7 @@ await xlsxBlob.save("artifacts/revenue-model.xlsx");
- Model the workbook structure first: sheets, headers, and key formulas.
- Use formulas instead of copying computed values when the sheet should remain editable.
- Recalculate before exporting or reading formula results.
- End the script with a final `console.log(...)` summary that names every file the run touched, using a compact user-facing format with one path per line.
- If the workbook includes charts or images, verify layout after export, not just in memory. A sheet-level render pass such as `await workbook.render({ sheet: index, format: "png" })` is a good QA step before handoff.
- Check where drawings land on the actual sheet. Merged cells and very tall autofit rows can push visible content far below the fold, so QA should confirm not only that a chart exists, but that it appears in an obvious on-sheet location.
- When editing an existing workbook, load it first and preserve unaffected sheets.