From 527917ff54118d172555c8c2c2f724e173b12fce Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:05:07 +0530 Subject: [PATCH] fix(plugin-runtime): write desktop command output to the OS temp dir `makeScreenshotCommand` and `makeDumpCommand` hardcoded `/tmp/-*` as their output destination. `/tmp` is not a temp directory on Windows: Node resolves it against the current drive root, so ` screenshot` and ` dump` create a stray `\tmp` folder at the root of whichever drive the process runs from, and fail outright with EPERM when that root is not writable for the current user. Use `os.tmpdir()` for both defaults and for the `--output` help text, so the shipped `@agentrhq/webcmd/plugin-runtime` factories behave the same on every platform. An explicit `output` argument is still honoured unchanged. --- src/plugin-runtime.test.ts | 71 +++++++++++++++++++++++++++++++++----- src/plugin-runtime.ts | 9 ++--- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/plugin-runtime.test.ts b/src/plugin-runtime.test.ts index 1472e7efa..dde72b55d 100644 --- a/src/plugin-runtime.test.ts +++ b/src/plugin-runtime.test.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; @@ -89,7 +90,10 @@ describe('plugin runtime search helpers', () => { describe('plugin runtime desktop command factories', () => { it('creates screenshot, status, new, and dump commands with their existing behavior', async () => { const site = `runtime-test-${process.pid}`; - const output = `/tmp/${site}.txt`; + const output = path.join(os.tmpdir(), `${site}.txt`); + const domFile = path.join(os.tmpdir(), `${site}-dom.html`); + const a11yFile = path.join(os.tmpdir(), `${site}-a11y.txt`); + const snapshotFile = path.join(os.tmpdir(), `${site}-snapshot.json`); const page = { evaluate: vi.fn() .mockResolvedValueOnce('snapshot') @@ -109,8 +113,8 @@ describe('plugin runtime desktop command factories', () => { try { await expect(runBrowserCommand(screenshot, page, { output })).resolves.toEqual([ - { Status: 'Success', File: `/tmp/${site}-dom.html` }, - { Status: 'Success', File: `/tmp/${site}-a11y.txt` }, + { Status: 'Success', File: domFile }, + { Status: 'Success', File: a11yFile }, ]); await expect(runBrowserCommand(status, page)).resolves.toEqual([ { Status: 'Connected', Url: 'https://example.com', Title: 'Example' }, @@ -118,20 +122,71 @@ describe('plugin runtime desktop command factories', () => { await expect(runBrowserCommand(create, page)).resolves.toEqual([{ Status: 'Success' }]); await expect(runBrowserCommand(dump, page)).resolves.toEqual([{ action: 'Dom extraction finished', - files: `/tmp/${site}-dom.html, /tmp/${site}-snapshot.json`, + files: `${domFile}, ${snapshotFile}`, }]); - expect(fs.readFileSync(`/tmp/${site}-a11y.txt`, 'utf8')).toBe('{\n "tree": "snapshot"\n}'); - expect(fs.readFileSync(`/tmp/${site}-dom.html`, 'utf8')).toBe('dump'); - expect(fs.readFileSync(`/tmp/${site}-snapshot.json`, 'utf8')).toBe('{\n "tree": "dump"\n}'); + expect(fs.readFileSync(a11yFile, 'utf8')).toBe('{\n "tree": "snapshot"\n}'); + expect(fs.readFileSync(domFile, 'utf8')).toBe('dump'); + expect(fs.readFileSync(snapshotFile, 'utf8')).toBe('{\n "tree": "dump"\n}'); expect(page.pressKey).toHaveBeenCalledWith(process.platform === 'darwin' ? 'Meta+N' : 'Control+N'); expect(page.wait).toHaveBeenCalledWith(1); } finally { - for (const file of [`/tmp/${site}-dom.html`, `/tmp/${site}-a11y.txt`, `/tmp/${site}-snapshot.json`]) { + for (const file of [domFile, a11yFile, snapshotFile]) { fs.rmSync(file, { force: true }); } } }); + + it('defaults screenshot and dump output to the OS temp dir', async () => { + const site = `runtime-tmpdir-${process.pid}`; + // os.tmpdir() reads these on every call, so this relocates the default for + // the command factories without mocking a builtin module. + const tmpEnvKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : ['TMPDIR']; + const previous = tmpEnvKeys.map((key) => [key, process.env[key]] as const); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-runtime-tmpdir-')); + for (const key of tmpEnvKeys) process.env[key] = tmp; + + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce('snapshot') + .mockResolvedValueOnce('dump'), + snapshot: vi.fn() + .mockResolvedValueOnce({ tree: 'snapshot' }) + .mockResolvedValueOnce({ tree: 'dump' }), + }; + + try { + expect(os.tmpdir()).toBe(tmp); + const screenshot = makeScreenshotCommand(site, 'Runtime Test'); + const dump = makeDumpCommand(site); + + expect(screenshot.args?.[0]?.help).toContain(path.join(tmp, `${site}-snapshot.txt`)); + + const shot = await runBrowserCommand(screenshot, page) as Array<{ File: string }>; + expect(shot.map((row) => row.File)).toEqual([ + path.join(tmp, `${site}-snapshot-dom.html`), + path.join(tmp, `${site}-snapshot-a11y.txt`), + ]); + + const dumped = await runBrowserCommand(dump, page) as Array<{ files: string }>; + expect(dumped[0].files).toBe( + `${path.join(tmp, `${site}-dom.html`)}, ${path.join(tmp, `${site}-snapshot.json`)}`, + ); + + expect(fs.readdirSync(tmp).sort()).toEqual([ + `${site}-dom.html`, + `${site}-snapshot-a11y.txt`, + `${site}-snapshot-dom.html`, + `${site}-snapshot.json`, + ]); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); function pageMock() { diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 8d8885784..7d0337f1e 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from './errors.js'; import { cli, Strategy, type CliCommand, type CommandArgs, type CliOptions } from './registry-api.js'; @@ -101,10 +102,10 @@ export function makeScreenshotCommand(site: string, displayName?: string, extra: domain: 'localhost', strategy: Strategy.UI, browser: true, - args: [{ name: 'output', required: false, help: `Output file path (default: /tmp/${site}-snapshot.txt)` }], + args: [{ name: 'output', required: false, help: `Output file path (default: ${path.join(os.tmpdir(), `${site}-snapshot.txt`)})` }], columns: ['Status', 'File'], func: async (page: IPage, kwargs: CommandArgs) => { - const outputPath = kwargs.output || `/tmp/${site}-snapshot.txt`; + const outputPath = kwargs.output || path.join(os.tmpdir(), `${site}-snapshot.txt`); const snap = await page.snapshot({ compact: true }); const html = await page.evaluate('document.documentElement.outerHTML'); const htmlPath = String(outputPath).replace(/\.\w+$/, '') + '-dom.html'; @@ -172,8 +173,8 @@ export function makeDumpCommand(site: string) { args: [], columns: ['action', 'files'], func: async (page: IPage) => { - const domPath = `/tmp/${site}-dom.html`; - const snapPath = `/tmp/${site}-snapshot.json`; + const domPath = path.join(os.tmpdir(), `${site}-dom.html`); + const snapPath = path.join(os.tmpdir(), `${site}-snapshot.json`); fs.mkdirSync(path.dirname(domPath), { recursive: true }); fs.mkdirSync(path.dirname(snapPath), { recursive: true }); fs.writeFileSync(domPath, await page.evaluate('document.body.innerHTML'));