mirror of
https://github.com/openai/codex.git
synced 2026-04-22 15:31:41 +03:00
Compare commits
8 Commits
cconger/sy
...
jif/artifa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6caf39833 | ||
|
|
276239eb96 | ||
|
|
e2c2d91161 | ||
|
|
08ce0f0030 | ||
|
|
b1b5603372 | ||
|
|
4a88d9020e | ||
|
|
e75d78b00c | ||
|
|
1240749413 |
@@ -1,6 +1,6 @@
|
||||
mod client;
|
||||
mod runtime;
|
||||
#[cfg(all(test, not(windows)))]
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use client::ArtifactBuildRequest;
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,11 +79,7 @@ fn load_cached_runtime_reads_installed_runtime() {
|
||||
.join(DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
write_installed_runtime(&install_dir, runtime_version, Some(node_relative_path()));
|
||||
|
||||
let runtime = load_cached_runtime(
|
||||
&codex_home.path().join(DEFAULT_CACHE_ROOT_RELATIVE),
|
||||
@@ -93,7 +89,7 @@ fn load_cached_runtime_reads_installed_runtime() {
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(runtime.node_path().ends_with(node_relative_path()));
|
||||
assert!(
|
||||
runtime
|
||||
.build_js_path()
|
||||
@@ -141,11 +137,7 @@ fn load_cached_runtime_requires_build_entrypoint() {
|
||||
.join(DEFAULT_CACHE_ROOT_RELATIVE)
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
write_installed_runtime(&install_dir, runtime_version, Some(node_relative_path()));
|
||||
fs::remove_file(install_dir.join("artifact-tool/dist/artifact_tool.mjs"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
@@ -225,7 +217,7 @@ async fn ensure_installed_downloads_and_extracts_zip_runtime() {
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(runtime.node_path().ends_with(node_relative_path()));
|
||||
assert_eq!(
|
||||
runtime.resolve_js_runtime().expect("resolve js runtime"),
|
||||
JsRuntime::node(runtime.node_path().to_path_buf())
|
||||
@@ -242,11 +234,7 @@ fn load_cached_runtime_uses_custom_cache_root() {
|
||||
let install_dir = custom_cache_root
|
||||
.join(runtime_version)
|
||||
.join(platform.as_str());
|
||||
write_installed_runtime(
|
||||
&install_dir,
|
||||
runtime_version,
|
||||
Some(PathBuf::from("node/bin/node")),
|
||||
);
|
||||
write_installed_runtime(&install_dir, runtime_version, Some(node_relative_path()));
|
||||
|
||||
let config = ArtifactRuntimeManagerConfig::with_default_release(
|
||||
codex_home.path().to_path_buf(),
|
||||
@@ -262,7 +250,6 @@ fn load_cached_runtime_uses_custom_cache_root() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn artifacts_client_execute_build_writes_wrapped_script_and_env() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let output_path = temp.path().join("build-output.txt");
|
||||
@@ -301,7 +288,6 @@ async fn artifacts_client_execute_build_writes_wrapped_script_and_env() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn artifacts_client_execute_render_passes_expected_args() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let output_path = temp.path().join("render-output.txt");
|
||||
@@ -353,11 +339,11 @@ fn spreadsheet_render_target_to_args_includes_optional_range() {
|
||||
"xlsx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
"/tmp/input.xlsx".to_string(),
|
||||
target_input_display(&target),
|
||||
"--sheet".to_string(),
|
||||
"Summary".to_string(),
|
||||
"--out".to_string(),
|
||||
"/tmp/output.png".to_string(),
|
||||
target_output_display(&target),
|
||||
"--range".to_string(),
|
||||
"A1:C8".to_string(),
|
||||
]
|
||||
@@ -369,16 +355,16 @@ fn assert_success(output: &ArtifactCommandOutput) {
|
||||
assert_eq!(output.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fake_installed_runtime(
|
||||
root: &Path,
|
||||
output_path: &Path,
|
||||
wrapped_script_path: &Path,
|
||||
) -> InstalledArtifactRuntime {
|
||||
let runtime_root = root.join("runtime");
|
||||
write_installed_runtime(&runtime_root, "0.1.0", Some(PathBuf::from("node/bin/node")));
|
||||
let node_relative = node_relative_path();
|
||||
write_installed_runtime(&runtime_root, "0.1.0", Some(node_relative.clone()));
|
||||
write_fake_node_script(
|
||||
&runtime_root.join("node/bin/node"),
|
||||
&runtime_root.join(&node_relative),
|
||||
output_path,
|
||||
wrapped_script_path,
|
||||
);
|
||||
@@ -394,18 +380,25 @@ fn write_installed_runtime(
|
||||
runtime_version: &str,
|
||||
node_relative: Option<PathBuf>,
|
||||
) {
|
||||
fs::create_dir_all(install_dir.join("node/bin")).unwrap_or_else(|error| panic!("{error}"));
|
||||
let node_relative = node_relative.unwrap_or_else(node_relative_path);
|
||||
let node_parent = node_relative
|
||||
.parent()
|
||||
.unwrap_or_else(|| panic!("node relative path should have a parent: {node_relative:?}"));
|
||||
fs::create_dir_all(install_dir.join(node_parent)).unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::create_dir_all(install_dir.join("artifact-tool/dist"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::create_dir_all(install_dir.join("granola-render/dist"))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let node_relative = node_relative.unwrap_or_else(|| PathBuf::from("node/bin/node"));
|
||||
fs::write(
|
||||
install_dir.join("manifest.json"),
|
||||
serde_json::json!(sample_extracted_manifest(runtime_version, node_relative)).to_string(),
|
||||
serde_json::json!(sample_extracted_manifest(
|
||||
runtime_version,
|
||||
node_relative.clone()
|
||||
))
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::write(install_dir.join("node/bin/node"), "#!/bin/sh\n")
|
||||
fs::write(install_dir.join(node_relative), placeholder_node_script())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
fs::write(
|
||||
install_dir.join("artifact-tool/dist/artifact_tool.mjs"),
|
||||
@@ -419,36 +412,62 @@ fn write_installed_runtime(
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_fake_node_script(script_path: &Path, output_path: &Path, wrapped_script_path: &Path) {
|
||||
fs::write(
|
||||
script_path,
|
||||
format!(
|
||||
concat!(
|
||||
"#!/bin/sh\n",
|
||||
"printf 'arg0=%s\\n' \"$1\" > \"{}\"\n",
|
||||
"cp \"$1\" \"{}\"\n",
|
||||
"shift\n",
|
||||
"i=1\n",
|
||||
"for arg in \"$@\"; do\n",
|
||||
" printf 'arg%s=%s\\n' \"$i\" \"$arg\" >> \"{}\"\n",
|
||||
" i=$((i + 1))\n",
|
||||
"done\n",
|
||||
"printf 'CODEX_ARTIFACT_BUILD_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CODEX_ARTIFACT_RENDER_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_RENDER_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CUSTOM_ENV=%s\\n' \"$CUSTOM_ENV\" >> \"{}\"\n",
|
||||
"echo stdout-ok\n",
|
||||
"echo stderr-ok >&2\n"
|
||||
),
|
||||
output_path.display(),
|
||||
wrapped_script_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
#[cfg(windows)]
|
||||
let script = format!(
|
||||
concat!(
|
||||
"@echo off\r\n",
|
||||
"setlocal EnableDelayedExpansion\r\n",
|
||||
"> \"{}\" echo arg0=%~1\r\n",
|
||||
"copy /Y \"%~1\" \"{}\" >NUL\r\n",
|
||||
"shift\r\n",
|
||||
"set i=1\r\n",
|
||||
":args\r\n",
|
||||
"if \"%~1\"==\"\" goto done_args\r\n",
|
||||
">> \"{}\" echo arg!i!=%~1\r\n",
|
||||
"shift\r\n",
|
||||
"set /a i+=1\r\n",
|
||||
"goto args\r\n",
|
||||
":done_args\r\n",
|
||||
">> \"{}\" echo CODEX_ARTIFACT_BUILD_ENTRYPOINT=%CODEX_ARTIFACT_BUILD_ENTRYPOINT%\r\n",
|
||||
">> \"{}\" echo CODEX_ARTIFACT_RENDER_ENTRYPOINT=%CODEX_ARTIFACT_RENDER_ENTRYPOINT%\r\n",
|
||||
">> \"{}\" echo CUSTOM_ENV=%CUSTOM_ENV%\r\n",
|
||||
"echo stdout-ok\r\n",
|
||||
"echo stderr-ok 1>&2\r\n"
|
||||
),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
output_path.display(),
|
||||
wrapped_script_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
let script = format!(
|
||||
concat!(
|
||||
"#!/bin/sh\n",
|
||||
"printf 'arg0=%s\\n' \"$1\" > \"{}\"\n",
|
||||
"cp \"$1\" \"{}\"\n",
|
||||
"shift\n",
|
||||
"i=1\n",
|
||||
"for arg in \"$@\"; do\n",
|
||||
" printf 'arg%s=%s\\n' \"$i\" \"$arg\" >> \"{}\"\n",
|
||||
" i=$((i + 1))\n",
|
||||
"done\n",
|
||||
"printf 'CODEX_ARTIFACT_BUILD_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CODEX_ARTIFACT_RENDER_ENTRYPOINT=%s\\n' \"$CODEX_ARTIFACT_RENDER_ENTRYPOINT\" >> \"{}\"\n",
|
||||
"printf 'CUSTOM_ENV=%s\\n' \"$CUSTOM_ENV\" >> \"{}\"\n",
|
||||
"echo stdout-ok\n",
|
||||
"echo stderr-ok >&2\n"
|
||||
),
|
||||
output_path.display(),
|
||||
wrapped_script_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
output_path.display(),
|
||||
);
|
||||
fs::write(script_path, script).unwrap_or_else(|error| panic!("{error}"));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut permissions = fs::metadata(script_path)
|
||||
@@ -464,9 +483,10 @@ fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
let node_relative = node_relative_path();
|
||||
let manifest = serde_json::to_vec(&sample_extracted_manifest(
|
||||
runtime_version,
|
||||
PathBuf::from("node/bin/node"),
|
||||
node_relative.clone(),
|
||||
))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/manifest.json", options)
|
||||
@@ -474,11 +494,11 @@ fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
zip.write_all(&manifest)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/node/bin/node",
|
||||
format!("artifact-runtime/{}", node_relative.display()),
|
||||
options.unix_permissions(0o755),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
zip.write_all(placeholder_node_script().as_bytes())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/artifact-tool/dist/artifact_tool.mjs",
|
||||
@@ -519,3 +539,33 @@ fn sample_extracted_manifest(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn node_relative_path() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from("node/bin/node.cmd")
|
||||
} else {
|
||||
PathBuf::from("node/bin/node")
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_node_script() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"@echo off\r\n"
|
||||
} else {
|
||||
"#!/bin/sh\n"
|
||||
}
|
||||
}
|
||||
|
||||
fn target_input_display(target: &ArtifactRenderTarget) -> String {
|
||||
match target {
|
||||
ArtifactRenderTarget::Spreadsheet(target) => target.input_path.display().to_string(),
|
||||
ArtifactRenderTarget::Presentation(_) => panic!("expected spreadsheet target"),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_output_display(target: &ArtifactRenderTarget) -> String {
|
||||
match target {
|
||||
ArtifactRenderTarget::Spreadsheet(target) => target.output_path.display().to_string(),
|
||||
ArtifactRenderTarget::Presentation(_) => panic!("expected spreadsheet target"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,83 +261,298 @@ 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(),
|
||||
staged_script: source_path.clone(),
|
||||
};
|
||||
let initial_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::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
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: None,
|
||||
})
|
||||
.await;
|
||||
let initial_approval_requirement =
|
||||
clear_proposed_execpolicy_amendment(initial_approval_requirement);
|
||||
let escalation_approval_requirement =
|
||||
clear_proposed_execpolicy_amendment(escalation_approval_requirement);
|
||||
|
||||
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,
|
||||
initial_approval_requirement,
|
||||
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;
|
||||
fn clear_proposed_execpolicy_amendment(
|
||||
requirement: crate::tools::sandboxing::ExecApprovalRequirement,
|
||||
) -> crate::tools::sandboxing::ExecApprovalRequirement {
|
||||
match requirement {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Skip { bypass_sandbox, .. } => {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval { reason, .. } => {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { reason } => {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { reason }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
)];
|
||||
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 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::codex::make_session_and_context;
|
||||
use crate::config::Constrained;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use codex_artifacts::RuntimeEntrypoints;
|
||||
use codex_artifacts::RuntimePathEntry;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -411,11 +668,245 @@ 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);"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_artifact_build_uses_script_specific_approval_key_without_execpolicy_rule() {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
turn.sandbox_policy = Constrained::allow_any(SandboxPolicy::new_read_only_policy());
|
||||
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 prepared = prepare_artifact_build(
|
||||
&session,
|
||||
&turn,
|
||||
runtime,
|
||||
"console.log('ok');".to_string(),
|
||||
5_000,
|
||||
)
|
||||
.await
|
||||
.expect("prepare artifact build");
|
||||
|
||||
assert_eq!(
|
||||
prepared.request.approval_key.command_prefix,
|
||||
vec![
|
||||
prepared.request.command[0].clone(),
|
||||
turn.config
|
||||
.codex_home
|
||||
.join(ARTIFACT_BUILD_LAUNCHER_RELATIVE)
|
||||
.display()
|
||||
.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.request.approval_key.staged_script,
|
||||
PathBuf::from(&prepared.request.command[2])
|
||||
);
|
||||
assert!(
|
||||
prepared
|
||||
.request
|
||||
.escalation_approval_requirement
|
||||
.proposed_execpolicy_amendment()
|
||||
.is_none()
|
||||
);
|
||||
match prepared.request.initial_approval_requirement {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
} => {
|
||||
assert_eq!(reason, None);
|
||||
assert_eq!(proposed_execpolicy_amendment, None);
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment,
|
||||
} => {
|
||||
assert!(!bypass_sandbox);
|
||||
assert_eq!(proposed_execpolicy_amendment, None);
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { reason } => {
|
||||
panic!("unexpected forbidden initial requirement: {reason}");
|
||||
}
|
||||
}
|
||||
match prepared.request.escalation_approval_requirement {
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval {
|
||||
reason,
|
||||
proposed_execpolicy_amendment,
|
||||
} => {
|
||||
assert_eq!(reason, None);
|
||||
assert_eq!(proposed_execpolicy_amendment, None);
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox,
|
||||
proposed_execpolicy_amendment,
|
||||
} => {
|
||||
assert!(!bypass_sandbox);
|
||||
assert_eq!(proposed_execpolicy_amendment, None);
|
||||
}
|
||||
crate::tools::sandboxing::ExecApprovalRequirement::Forbidden { reason } => {
|
||||
panic!("unexpected forbidden escalation requirement: {reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
391
codex-rs/core/src/tools/runtimes/artifacts.rs
Normal file
391
codex-rs/core/src/tools/runtimes/artifacts.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
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::SandboxOverride;
|
||||
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::sandbox_override_for_first_attempt;
|
||||
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,
|
||||
pub(crate) staged_script: 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) initial_approval_requirement: ExecApprovalRequirement,
|
||||
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 approval_requirement = if retry_reason.is_some() {
|
||||
req.escalation_approval_requirement.clone()
|
||||
} else {
|
||||
req.initial_approval_requirement.clone()
|
||||
};
|
||||
Box::pin(async move {
|
||||
if matches!(
|
||||
approval_requirement,
|
||||
ExecApprovalRequirement::Forbidden { .. }
|
||||
) {
|
||||
return ReviewDecision::Denied;
|
||||
}
|
||||
|
||||
with_cached_approval(
|
||||
&session.services,
|
||||
"artifacts",
|
||||
approval_keys,
|
||||
|| async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id,
|
||||
None,
|
||||
command,
|
||||
cwd,
|
||||
retry_reason,
|
||||
ctx.network_approval_context.clone(),
|
||||
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(req.initial_approval_requirement.clone())
|
||||
}
|
||||
|
||||
fn sandbox_mode_for_first_attempt(&self, req: &ArtifactExecRequest) -> SandboxOverride {
|
||||
sandbox_override_for_first_attempt(
|
||||
SandboxPermissions::UseDefault,
|
||||
&req.initial_approval_requirement,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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_with_rx;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::tools::sandboxing::SandboxOverride;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_with_skip_requirement_requests_approval() {
|
||||
let (session, turn, rx_event) = make_session_and_context_with_rx().await;
|
||||
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
|
||||
let mut runtime = ArtifactRuntime;
|
||||
let network_approval_context = NetworkApprovalContext {
|
||||
host: "example.com".to_string(),
|
||||
protocol: NetworkApprovalProtocol::Http,
|
||||
};
|
||||
let network_approval_context_for_watcher = network_approval_context.clone();
|
||||
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"),
|
||||
staged_script: PathBuf::from("/tmp/source.mjs"),
|
||||
},
|
||||
initial_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
escalation_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
|
||||
let session_for_response = session.clone();
|
||||
let approval_watcher = async move {
|
||||
loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv())
|
||||
.await
|
||||
.expect("wait for approval event")
|
||||
.expect("receive approval event");
|
||||
if let EventMsg::ExecApprovalRequest(request) = event.msg {
|
||||
assert_eq!(request.call_id, "call_artifact");
|
||||
assert_eq!(
|
||||
request.network_approval_context,
|
||||
Some(network_approval_context_for_watcher.clone())
|
||||
);
|
||||
session_for_response
|
||||
.notify_approval(&request.call_id, ReviewDecision::Approved)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let decision = tokio::join!(
|
||||
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: Some(network_approval_context),
|
||||
},
|
||||
),
|
||||
approval_watcher,
|
||||
)
|
||||
.0;
|
||||
|
||||
assert_eq!(decision, ReviewDecision::Approved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_keys_differ_for_different_staged_scripts() {
|
||||
let runtime = ArtifactRuntime;
|
||||
let req_one = ArtifactExecRequest {
|
||||
command: vec![
|
||||
"/path/to/node".to_string(),
|
||||
"/path/to/launcher.mjs".to_string(),
|
||||
"/tmp/source-one.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"),
|
||||
staged_script: PathBuf::from("/tmp/source-one.mjs"),
|
||||
},
|
||||
initial_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
escalation_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
let req_two = ArtifactExecRequest {
|
||||
command: vec![
|
||||
"/path/to/node".to_string(),
|
||||
"/path/to/launcher.mjs".to_string(),
|
||||
"/tmp/source-two.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"),
|
||||
staged_script: PathBuf::from("/tmp/source-two.mjs"),
|
||||
},
|
||||
initial_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
escalation_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
runtime.approval_keys(&req_one),
|
||||
runtime.approval_keys(&req_two)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_approval_requirement_uses_initial_requirement() {
|
||||
let 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"),
|
||||
staged_script: PathBuf::from("/tmp/source.mjs"),
|
||||
},
|
||||
initial_approval_requirement: ExecApprovalRequirement::Forbidden {
|
||||
reason: "blocked before first attempt".to_string(),
|
||||
},
|
||||
escalation_approval_requirement: ExecApprovalRequirement::Forbidden {
|
||||
reason: "blocked on retry".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
runtime.exec_approval_requirement(&req),
|
||||
Some(ExecApprovalRequirement::Forbidden {
|
||||
reason: "blocked before first attempt".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_for_first_attempt_uses_initial_requirement() {
|
||||
let 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"),
|
||||
staged_script: PathBuf::from("/tmp/source.mjs"),
|
||||
},
|
||||
initial_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
escalation_approval_requirement: ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
runtime.sandbox_mode_for_first_attempt(&req),
|
||||
SandboxOverride::NoOverride
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`.
|
||||
- `await PresentationFile.exportPptx(presentation)` should produce a real PowerPoint file with the current runtime.
|
||||
- Render PNG previews for visual QA before handoff.
|
||||
- When validating a new runtime build or debugging export issues, verify the exported `.pptx` signature with a local check such as `file artifacts/name.pptx` or `xxd -l 8 artifacts/name.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,13 +122,19 @@ 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 rendered PNG previews as the primary visual QA step, and use file-signature checks when validating runtime behavior.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Start with the smallest script that creates or imports the deck.
|
||||
- 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 first `artifacts` run fails before your slide code executes, treat it as runtime setup failure.
|
||||
- Ask for approval to install Node or the required artifact runtime, explaining that a JavaScript runtime is required to build presentation artifacts.
|
||||
- After approval, install it and retry once. Do not loop on the same failed script.
|
||||
- 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 when you are validating a runtime build or investigating an export issue.
|
||||
- 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 +145,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 can also include verifying that the nominal `.pptx` is a PowerPoint container when you are testing runtime correctness rather than only deck layout.
|
||||
- When editing an existing file, load it first, mutate only the requested slides or elements, then export a new `.pptx`.
|
||||
|
||||
## Reference Map
|
||||
|
||||
@@ -12,8 +12,9 @@ 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.
|
||||
- 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.
|
||||
- `await PresentationFile.exportPptx(presentation)` exports the deck as a saveable PowerPoint blob with the current runtime.
|
||||
- When using this skill operationally, start by authoring with these APIs rather than checking local runtime package directories first.
|
||||
- If the first `artifacts` run fails before deck code executes, ask for approval to install Node or the required artifact runtime, then retry once.
|
||||
|
||||
## 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, render PNG previews for visual QA. When validating a new runtime build or debugging export issues, also verify the exported `.pptx` signature with a local tool such as `file` or `xxd`.
|
||||
|
||||
@@ -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
|
||||
@@ -55,8 +62,13 @@ await xlsxBlob.save("artifacts/revenue-model.xlsx");
|
||||
## Workflow
|
||||
|
||||
- Model the workbook structure first: sheets, headers, and key formulas.
|
||||
- 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 workbook code runs.
|
||||
- If the first `artifacts` run fails before your workbook code executes, treat it as runtime setup failure.
|
||||
- Ask for approval to install Node or the required artifact runtime, explaining that a JavaScript runtime is required to build spreadsheet artifacts.
|
||||
- After approval, install it and retry once. Do not loop on the same failed script.
|
||||
- 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.
|
||||
|
||||
@@ -13,6 +13,8 @@ const sheet = workbook.worksheets.add("Sheet1");
|
||||
- `await SpreadsheetFile.importXlsx(await FileBlob.load("book.xlsx"))` imports an existing workbook.
|
||||
- `workbook.recalculate()` evaluates formulas.
|
||||
- `await SpreadsheetFile.exportXlsx(workbook)` exports a saveable `.xlsx` blob.
|
||||
- When using this skill operationally, start by authoring with these APIs rather than checking local runtime package directories first.
|
||||
- If the first `artifacts` run fails before workbook code executes, ask for approval to install Node or the required artifact runtime, then retry once.
|
||||
|
||||
## Worksheets
|
||||
|
||||
|
||||
Reference in New Issue
Block a user