This commit is contained in:
jif-oai
2026-03-05 15:25:13 +00:00
parent e75d78b00c
commit 4a88d9020e
5 changed files with 195 additions and 74 deletions

View File

@@ -1,6 +1,6 @@
mod client;
mod runtime;
#[cfg(all(test, not(windows)))]
#[cfg(test)]
mod tests;
pub use client::ArtifactBuildRequest;

View File

@@ -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"),
}
}

View File

@@ -7,11 +7,13 @@ 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;
@@ -132,12 +134,16 @@ impl Approvable<ArtifactExecRequest> for ArtifactRuntime {
fn exec_approval_requirement(
&self,
_req: &ArtifactExecRequest,
req: &ArtifactExecRequest,
) -> Option<ExecApprovalRequirement> {
Some(ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
})
Some(req.escalation_approval_requirement.clone())
}
fn sandbox_mode_for_first_attempt(&self, req: &ArtifactExecRequest) -> SandboxOverride {
sandbox_override_for_first_attempt(
SandboxPermissions::UseDefault,
&req.escalation_approval_requirement,
)
}
}
@@ -170,6 +176,7 @@ impl ToolRuntime<ArtifactExecRequest, ExecToolCallOutput> for ArtifactRuntime {
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use crate::tools::sandboxing::SandboxOverride;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -268,4 +275,69 @@ mod tests {
runtime.approval_keys(&req_two)
);
}
#[test]
fn exec_approval_requirement_uses_request_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"),
},
escalation_approval_requirement: ExecApprovalRequirement::Forbidden {
reason: "blocked by policy".to_string(),
},
};
assert_eq!(
runtime.exec_approval_requirement(&req),
Some(ExecApprovalRequirement::Forbidden {
reason: "blocked by policy".to_string(),
})
);
}
#[test]
fn sandbox_mode_for_first_attempt_honors_bypass_sandbox_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"),
},
escalation_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
},
};
assert_eq!(
runtime.sandbox_mode_for_first_attempt(&req),
SandboxOverride::BypassSandboxFirstAttempt
);
}
}

View File

@@ -19,9 +19,9 @@ 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`.
- `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
@@ -122,7 +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.
- Treat rendered PNG previews as the primary visual QA step, and use file-signature checks when validating runtime behavior.
## Workflow
@@ -133,7 +133,7 @@ for (const slide of presentation.slides.items) {
- 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 before assuming export succeeded. If it is actually an image blob, keep the PNG previews and rebuild a valid deck from them.
- 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.
@@ -145,7 +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 also includes verifying that the nominal `.pptx` is actually a PowerPoint container rather than mislabeled PNG output from the runtime.
- 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

View File

@@ -12,8 +12,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.
- `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.
@@ -153,4 +152,4 @@ await fs.writeFile("artifacts/slide-1.png", previewBytes);
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.
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`.