Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/converter-sandbox-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@deckflow/deckuse': patch
---

Inherit host .NET extraction settings instead of forcing a fresh temporary cache. Share converter execution across render and monitor, preserve license arguments and native failure details, and report browser probe cleanup failures independently from write/launch availability.
29 changes: 29 additions & 0 deletions docs/verification-converter-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Converter startup in restricted hosts

## Changes

Deckuse no longer injects a temporary `DOTNET_BUNDLE_EXTRACT_BASE_DIR`. Explicit settings are inherited unchanged; otherwise the runtime chooses its default. `extractionBase: null` with `extractionSource: runtime-default` means the actual runtime path was not observed. Render and monitor share binary selection, arguments, bounded execution and native error classification. Errors preserve exit code/signal and identify directory, write or commit extraction failures when the runtime message permits it. `retryable: false` disallows unchanged automatic retries, not recovery after a host configuration change.

Browser preflight reports cleanup failures in `cleanupWarnings`, with the retained probe path. A failed write remains the primary failure even when cleanup also fails. A successful write plus failed cleanup does not establish that launching a browser is impossible. Actual launch remains separately tested, and launch failure caching excludes varying cleanup paths.

The companion deck2html change ships complete self-contained multi-file runtimes. All platform wrappers, packaging whitelists, postinstall, local installation, build and edition-scan scripts use that layout. No installation hook runs the converter or warms caches. CLI and API signatures remain unchanged; consumers should obtain the executable through `getBinaryPath()`.

## Verification performed

- Deckuse build, TypeScript no-emit check, changed-source ESLint and diff whitespace checks passed.
- Six targeted test files passed (45 tests), then the added real-process monitor environment regression passed with its suite (17 tests; 46 distinct targeted cases overall).
- Built the macOS arm64 multi-file runtime and passed the edition scan.
- Packed the actual platform and main npm packages, installed the tarballs into an isolated job, and tested help/version plus PPTX and DOCX conversion. A final package-smoke run also passed both installed JavaScript APIs (`convert` and `convertSync`).
- The package smoke also ran native conversions in a macOS sandbox denying all writes to the selected extraction path. No extraction directory was created. This tests bundle independence, not every WorkBuddy policy.
- Deckuse preflight with a real Playwright browser passed. The installed runtime produced PPTX (1280×720) and DOCX (816×1056) PNGs through deckuse.
- Against the old installed 0.6.2 single-file converter using its existing cache, both sample page screenshots were pixel-identical. The PPTX fixture reported existing unresolved-font warnings in the test environment; this is not native Office fidelity certification.

Local artifacts: `../deck2html/artifacts/runtime-package-GS2zsc/` contains package-test results, installed packages, per-format command JSON and screenshots. The final CLI/API package-smoke artifacts are in `../deck2html/artifacts/runtime-package-fu3oXZ/`. Artifacts are ignored by Git and retained for inspection.

## Release gates still outstanding

The four-platform `runtime-package.yml` workflow has been added but was not run remotely. Windows x64, Linux x64 and macOS x64 execution, plus the original WorkBuddy policy, remain unverified. The local .NET build also reported existing ImageSharp 3.1.12 NuGet advisory warnings; dependency remediation is outside this change.

Do not publish the currently versioned 0.6.2 payload over the existing release. Assign a new deck2html version using its version script, run the four-platform package tests and release that version first. Then raise deckuse's `@deckflow/deck2html` minimum to the published version and regenerate the package-manager lockfile. The current dependency/lockfile deliberately remains installable against the existing registry; the new runtime was exercised via `DECKUSE_DECK2HTML_BINARY`. No registry publishing was performed.

For target-host verification, fix the user, policy, input, binary hash and environment; compare CLI and deckuse with distinct test outputs. Preserve user caches. Do not cycle directories or repeat equivalent denied operations. Report converter startup, browser launch and cleanup independently.
9 changes: 8 additions & 1 deletion scripts/assert-no-commercial-leak.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,14 @@ const underSource = (p) => p === sourceRoot || p.startsWith(`${sourceRoot}${sep}
const sourceFiles = allFiles.filter((p) => sourceExt.test(p) && underSource(p));

for (const file of sourceFiles) {
const text = readFileSync(file, 'utf8');
const originalText = readFileSync(file, 'utf8');
// The public deck2html API already accepts a license path. Permit exactly one
// argument-map entry forwarding it to that dependency, not Deckuse activation,
// verification, CLI parsing, or any other license implementation.
const text =
relative(root, file).split(sep).join('/') === 'src/deck2html-exec.ts'
? originalText.replace("['license', '--license'],", '')
: originalText;
for (const marker of FORBIDDEN_LICENSE_MARKERS) {
if (text.includes(marker)) {
errors.push(`Forbidden license/certificate marker "${marker}" in ${relative(root, file)}`);
Expand Down
29 changes: 17 additions & 12 deletions scripts/check-agent-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,23 @@ await writeFile(
);
const timeToOperationsMs = performance.now() - start;
await writeFile(generatedNames, JSON.stringify(inventory));
for (const [fault, code] of [
['create', 'BROWSER_CACHE_UNWRITABLE'],
['cleanup', 'BROWSER_PREFLIGHT_CLEANUP_FAILED'],
]) {
const result = probeCli(['render', '--preflight'], fault);
if (
result.status !== 1 ||
result.envelope.error.code !== code ||
(fault === 'cleanup' && result.envelope.error.details.previewSupported !== false)
)
throw new Error('Incorrect browser preflight diagnostic: ' + JSON.stringify(result));
}
const createProbe = probeCli(['render', '--preflight'], 'create');
if (createProbe.status !== 1 || createProbe.envelope.error?.code !== 'BROWSER_CACHE_UNWRITABLE')
throw new Error('Incorrect browser create diagnostic: ' + JSON.stringify(createProbe));
const cleanupProbe = probeCli(['render', '--preflight'], 'cleanup');
const cleanupWarning = cleanupProbe.envelope.data?.cleanupWarnings?.[0];
if (
cleanupProbe.status !== 0 ||
cleanupProbe.envelope.ok !== true ||
cleanupProbe.envelope.data?.cacheWritable !== true ||
cleanupProbe.envelope.data?.launch !== 'not-probed' ||
cleanupWarning?.code !== 'BROWSER_PREFLIGHT_CLEANUP_FAILED' ||
cleanupWarning.details.stage !== 'profile-cleanup' ||
cleanupWarning.details.systemCode !== 'EACCES' ||
cleanupWarning.details.retryable !== false ||
cleanupWarning.details.retainedPath !== cleanupWarning.details.path
)
throw new Error('Incorrect browser cleanup warning: ' + JSON.stringify(cleanupProbe));
const compactDeck = JSON.parse(
execFileSync(
process.execPath,
Expand Down
2 changes: 1 addition & 1 deletion skills/deckuse/modules/verification-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Finish with the output file, completed checks, known limitations and any unresol

Give each logical apply a stable `--request-id` and the inspected `--expect-revision`. Save the result to a fresh report file. If the process times out, stdout closes, or a shell command loses the response, run `deckuse status --workspace ./ws --request-id <id> --json`. A committed receipt reports its original revision, and `current` reports whether it still matches the current write history. Reuse the ID and the same input for a retry only when continuing that request. Treat stale receipts and conflicting inputs as a reason to inspect the current state before making another write.

If a render logs `RENDER_CLEANUP_FAILED`, check the screenshot result and the reported temporary directory separately. Preserve successful PNGs. A denied cleanup path does not require another apply or conversion. If the converter itself reports `RENDER_CONVERTER_EXTRACTION_FAILED`, use `render --preflight` with an allowed writable extraction directory, then resume rendering.
If a render logs `RENDER_CLEANUP_FAILED`, check the screenshot result and the reported temporary directory separately. Preserve successful PNGs. A denied cleanup path does not require another apply or conversion. If the converter itself reports `RENDER_CONVERTER_EXTRACTION_FAILED`, do not retry unchanged or cycle through writable directories: bundle startup also needs host-approved commit/rename and sometimes delete operations. Prefer a self-contained multi-file deck2html package, or have the host correct its extraction configuration before running `render --preflight` again. `retryable: false` prevents unchanged automatic retries; it does not prohibit recovery after configuration changes. Browser preflight `cleanupWarnings` describe retained probe paths separately from write and launch availability.

A `no-op` receipt records a successful request with no content commit or revision advance.
It can be replayed only while its base commit, revision and source content still match.
Expand Down
158 changes: 77 additions & 81 deletions src/deck2html-exec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RenderFailure } from './render-browser.js';
import { access, chmod, constants, lstat, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { access, chmod, constants } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { execFile } from 'node:child_process';
import deck2html from '@deckflow/deck2html';

/** npm may install platform binaries without +x; restore before convert. */
Expand All @@ -24,41 +24,62 @@ export const ensureDeck2HtmlExecutable = async (): Promise<void> => {
}
};

const defaultExtractionBase = () =>
join(tmpdir(), `deckuse-dotnet-${String(process.getuid?.() ?? 'user')}`);
const extractionBase = () =>
process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] ?? defaultExtractionBase();
const selectedBinary = () => process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath();

async function converterEnvironment() {
const base = extractionBase();
if (!process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR']) {
try {
await mkdir(base, { recursive: true, mode: 0o700 });
const info = await lstat(base);
const uid = process.getuid?.();
if (
!info.isDirectory() ||
(uid !== undefined && (info.uid !== uid || (info.mode & 0o022) !== 0))
)
throw new Error(
'Default extraction cache must be a directory owned by the current user and protected from other writers',
function extractionConfiguration() {
const base = process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR'];
return {
extractionBase: base === '' ? null : (base ?? null),
extractionSource: base ? 'explicit' : 'runtime-default',
};
}

/** Inherit host policy and runtime defaults; never relocate or warm extraction caches. */
async function runConverter(args: string[], preflight = false) {
const binary = resolve(selectedBinary());
await ensureDeck2HtmlExecutable();
const env = { ...process.env };
return new Promise<{ stdout: string; stderr: string }>((done, reject) => {
execFile(
binary,
args,
{
env,
timeout: preflight ? 5000 : 120_000,
maxBuffer: preflight ? 65536 : 64 * 1024 * 1024,
killSignal: 'SIGKILL',
},
(error, stdout, stderr) => {
if (!error) {
done({ stdout, stderr });
return;
}
const failure = converterFailure(stderr.trim() || stdout.trim() || error.message, {
binary,
exitCode: typeof error.code === 'number' ? error.code : null,
systemCode: typeof error.code === 'string' ? error.code : undefined,
signal: error.signal ?? null,
killed: error.killed ?? false,
});
reject(
preflight && failure.code !== 'RENDER_CONVERTER_EXTRACTION_FAILED'
? new RenderFailure('RENDER_CONVERTER_STARTUP_FAILED', failure.message, {
...failure.details,
stage: 'converter-startup',
})
: failure,
);
} catch (error) {
throw converterFailure(`Failed to create extraction directory ${base}: ${String(error)}`);
}
}
return { ...process.env, DOTNET_BUNDLE_EXTRACT_BASE_DIR: base };
},
);
});
}

/** Explicit native binary selection for testing or deploying a companion engine build. */
export const convertWithConfiguredBinary: typeof deck2html.convert = async (input, options) => {
const binary = process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath();
const env = await converterEnvironment();
const { execFile } = await import('node:child_process');
const { resolve, join } = await import('node:path');
const args = [resolve(input), '-o', resolve(options.output)];
for (const [key, flag] of [
['mode', '--mode'],
['license', '--license'],
['fontService', '--font-service'],
['pages', '--pages'],
] as const) {
Expand All @@ -75,43 +96,43 @@ export const convertWithConfiguredBinary: typeof deck2html.convert = async (inpu
['noLazySlides', '--no-lazy-slides'],
] as const)
if (options[key]) args.push(flag);
return new Promise((done, reject) =>
execFile(
resolve(binary),
args,
{ env, maxBuffer: 64 * 1024 * 1024, timeout: 120_000 },
(error, stdout, stderr) => {
if (error && typeof error.code !== 'number') {
reject(new Error(error.message, { cause: error }));
return;
}
done({
indexHtmlPath: join(resolve(options.output), 'index.html'),
exitCode: typeof error?.code === 'number' ? error.code : 0,
stdout,
stderr,
});
},
),
);
const { stdout, stderr } = await runConverter(args);
return {
indexHtmlPath: join(resolve(options.output), 'index.html'),
exitCode: 0,
stdout,
stderr,
};
};

/** Identify a shared .NET bundle setup failure before treating it as a page error. */
export function converterFailure(message: string): RenderFailure {
export function converterFailure(
message: string,
context: Record<string, unknown> = {},
): RenderFailure {
const extraction =
/bundle.{0,80}extract|extract.{0,80}bundle|DOTNET_BUNDLE_EXTRACT_BASE_DIR|failed to (?:commit extracted files|create extraction directory)/i.test(
/failure processing application bundle|bundle.{0,80}extract|extract.{0,80}bundle|DOTNET_BUNDLE_EXTRACT_BASE_DIR|failed to (?:commit extracted files|create extraction directory)/i.test(
message,
);
const extractionStage = /failed to commit extracted files/i.test(message)
? 'commit'
: /failed to (?:create|determine).*dir|failed to determine location/i.test(message)
? 'directory'
: /failed to open file|I\/O failure.*writ/i.test(message)
? 'write'
: 'unknown';
return new RenderFailure(
extraction ? 'RENDER_CONVERTER_EXTRACTION_FAILED' : 'RENDER_CONVERSION_FAILED',
message,
{
...extractionConfiguration(),
...context,
stage: extraction ? 'converter-extraction' : 'convert',
...(extraction
? {
retryable: false,
extractionBase: extractionBase(),
hint: 'Verify the converter can create and commit files in its extraction directory under the host policy before retrying preview.',
extractionStage,
hint: 'Do not retry unchanged or cycle through extraction directories. Bundle startup needs host-approved write and commit operations (including rename and possibly delete), not just a writable directory. Use a non-bundled runtime distribution or have the host correct its configuration before retrying.',
}
: {}),
},
Expand All @@ -120,35 +141,10 @@ export function converterFailure(message: string): RenderFailure {

/** Run the selected binary through runtime startup without converting user content. */
export async function converterPreflight() {
const binary = process.env['DECKUSE_DECK2HTML_BINARY'] ?? deck2html.getBinaryPath();
const { execFile } = await import('node:child_process');
const { resolve } = await import('node:path');
await ensureDeck2HtmlExecutable();
const env = await converterEnvironment();
const version = await new Promise<string>((done, reject) => {
execFile(
resolve(binary),
['--version'],
{ env, timeout: 5000, maxBuffer: 65536, killSignal: 'SIGKILL' },
(error, stdout, stderr) => {
if (error) {
const message = stderr.trim() || error.message;
const failure = converterFailure(message);
reject(
failure.code === 'RENDER_CONVERTER_EXTRACTION_FAILED'
? failure
: new RenderFailure('RENDER_CONVERTER_STARTUP_FAILED', message, {
stage: 'converter-startup',
}),
);
} else done(stdout.trim());
},
);
});
const { stdout } = await runConverter(['--version'], true);
return {
binary,
version,
extractionBase: extractionBase(),
extractionSource: process.env['DOTNET_BUNDLE_EXTRACT_BASE_DIR'] ? 'explicit' : 'deckuse-temp',
binary: resolve(selectedBinary()),
version: stdout.trim(),
...extractionConfiguration(),
};
}
4 changes: 2 additions & 2 deletions src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
operationsPath,
previewDir,
} from './workspace/index.js';
import { ensureDeck2HtmlExecutable } from './deck2html-exec.js';
import { convertWithConfiguredBinary, ensureDeck2HtmlExecutable } from './deck2html-exec.js';

const KEEPALIVE_MS = 15_000;
const DEBOUNCE_MS = 80;
Expand Down Expand Up @@ -503,7 +503,7 @@ export const startMonitor = async (
const fullPreviewRoot = previewDir(absoluteWorkspace);
await mkdir(fullPreviewRoot, { recursive: true });
let realFullPreviewRoot = await realpath(fullPreviewRoot);
const converter = options.dependencies?.convert ?? deck2html.convert;
const converter = options.dependencies?.convert ?? convertWithConfiguredBinary;
const createWatcher = options.dependencies?.watch ?? watch;
const debounceMs = options.dependencies?.debounceMs ?? DEBOUNCE_MS;
const keepaliveMs = options.dependencies?.keepaliveMs ?? KEEPALIVE_MS;
Expand Down
27 changes: 20 additions & 7 deletions src/render-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,21 +98,30 @@ async function checkBrowserPreflight() {
details('profile-write', probeFile, cause),
);
}
const cleanupWarnings: { code: string; message: string; details: Record<string, unknown> }[] = [];
try {
await timed('cleanup-browser-preflight', () => rm(probe, { recursive: true }));
} catch (cause) {
throw new RenderFailure('BROWSER_PREFLIGHT_CLEANUP_FAILED', String(cause), {
...details('profile-cleanup', probe, cause),
retryable: false,
previewSupported: false,
...(writeFailure ? { writeFailure: writeFailure.details } : {}),
cleanupWarnings.push({
code: 'BROWSER_PREFLIGHT_CLEANUP_FAILED',
message: String(cause),
details: {
...details('profile-cleanup', probe, cause),
retryable: false,
retainedPath: probe,
},
});
}
if (writeFailure) throw writeFailure;
if (writeFailure)
throw new RenderFailure(writeFailure.code, writeFailure.message, {
...writeFailure.details,
cleanupWarnings,
});
return {
...config,
executableStatus,
cacheWritable: true,
cleanupWarnings,
dependencyCompatibility:
config.provider === 'headless-shell' ? 'playwright-managed' : 'unverified',
launch: 'not-probed',
Expand All @@ -125,7 +134,11 @@ export async function launchRenderBrowser(
timeout?: number,
): Promise<Browser> {
const config = await browserPreflight();
const key = JSON.stringify(config);
for (const warning of config.cleanupWarnings)
process.stderr.write(
`deckuse: ${warning.code} (${String(warning.details['retainedPath'])}): ${warning.message}\n`,
);
const key = JSON.stringify({ ...config, cleanupWarnings: undefined });
const previous = failed.get(key);
if (previous && process.env['DECKUSE_BROWSER_RETRY'] !== '1') throw previous;
try {
Expand Down
Loading
Loading