From 9f8958c4a21b6160784e61c84590ff5f51fe4512 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Wed, 10 Jun 2026 03:52:37 +0000 Subject: [PATCH] sync: update from private repo (ce93095) --- config.yaml.example | 6 +- docs/tools/readusertemplate.md | 85 --- docs/tools/renderusertemplate.md | 108 ---- docs/tools/runuserscript.md | 86 ++- docs/tools/writeuserscript.md | 65 +-- docs/tools/writeusertemplate.md | 133 ----- docs/user-folder-layout.md | 45 +- src/bridge/tools-api.ts | 3 - src/bridge/user-folder-api.test.ts | 76 +-- src/bridge/user-folder-api.ts | 29 +- src/config.ts | 9 +- src/engine/tools/user-folder.test.ts | 516 ++---------------- src/engine/tools/user-folder.ts | 457 ++-------------- src/metrics/tool-name-allowlist.ts | 4 +- src/scheduler.test.ts | 6 +- src/scheduler.ts | 2 +- src/user-folder/paths.test.ts | 10 +- src/user-folder/paths.ts | 5 +- src/user-folder/script-orchestrator.ts | 62 +-- src/user-folder/template-renderer.ts | 32 -- .../components/settings/ToolsExternalForm.tsx | 4 +- ui/src/components/settings/ToolsForm.tsx | 4 +- ui/src/components/userfolder/FileTree.tsx | 9 +- ui/src/components/userfolder/NewFileForm.tsx | 34 +- .../components/userfolder/UserFolderTab.tsx | 24 +- ui/src/content/help/09-userfolder.md | 27 +- ui/src/content/help/16-tools.md | 2 +- 27 files changed, 261 insertions(+), 1582 deletions(-) delete mode 100644 docs/tools/readusertemplate.md delete mode 100644 docs/tools/renderusertemplate.md delete mode 100644 docs/tools/writeusertemplate.md delete mode 100644 src/user-folder/template-renderer.ts diff --git a/config.yaml.example b/config.yaml.example index fc6b41a..1bd61f2 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -160,7 +160,7 @@ llm: storage: worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース # custom_pieces_dir: ./custom-pieces # リポジトリ内の pieces/ に加えて読みに行く Piece dir (任意) - user_folder_root: ./data/users # {root}/{userId}/ 配下に AGENTS.md/scripts/notes 等を保存 + user_folder_root: ./data/users # {root}/{userId}/ 配下に AGENTS.md/browser-macros/notes 等を保存 task_upload_max_size_mb: 50 # /api/local/tasks と /comments の body 上限 (MB) # base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000] trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期) @@ -285,8 +285,8 @@ tools: # amazon_affiliate_tag: "your-tag-22" # keepa_api_key: "..." - # User scripts (RunUserScript) - # user_scripts_enabled: false # true で許可。plain runtime は Node --permission で sandbox 化 + # User browser-macros (RunUserScript) + # user_scripts_enabled: false # true で browser-macros の実行を許可 # user_scripts_allow_userids: # 未指定 = 全ユーザー許可 (user_scripts_enabled に従う) # - alice-id # - bob-id diff --git a/docs/tools/readusertemplate.md b/docs/tools/readusertemplate.md deleted file mode 100644 index e248bcb..0000000 --- a/docs/tools/readusertemplate.md +++ /dev/null @@ -1,85 +0,0 @@ -# ReadUserTemplate - -Loads a template file from the caller's `templates/` subdir (`data/users/{userId}/templates/`). - -## Overview - -Templates are plain Markdown files the user stores in their `templates/` folder via the UI. -Unlike `memory/` entries, frontmatter is **optional** — a template can be pure Markdown prose -with no YAML header at all. - -Use this tool when the user says "use the weekly-report template" or "follow the api-error-email -boilerplate" — call `ReadUserTemplate`, read the shape, then adapt it to the task at hand. - ---- - -## Usage - -```json -{ "name": "weekly-report" } -``` - -Or with the `.md` extension (both forms work): - -```json -{ "name": "weekly-report.md" } -``` - -**Response example (no frontmatter):** - -``` -# Template: weekly-report - -## Body -# Weekly Report - -Fill in this week's highlights here. -``` - -**Response example (with frontmatter):** - -``` -# Template: api-error-email - -## Frontmatter -title: "API Error Email" -audience: "external" - -## Body -Dear customer, - -We apologize for the inconvenience. -``` - ---- - -## Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Template filename, with or without `.md` extension (max 128 chars) | - ---- - -## Error cases - -- Returns an error if the template does not exist in `templates/`. -- Returns an error if no user is authenticated. -- Returns an error if `name` contains path traversal characters or slashes. - ---- - -## Use cases - -- Weekly / monthly report boilerplate: `ReadUserTemplate({ name: "weekly-report" })` → fill in stats. -- Email canned responses: `ReadUserTemplate({ name: "api-error-email" })` → personalise and send. -- Code boilerplate: `ReadUserTemplate({ name: "react-component" })` → generate a new component. - ---- - -## Related tools - -- `ListUserAssets({ kind: "templates" })` — see what templates exist before reading one. -- `ReadUserMemory` — for structured facts/preferences (requires frontmatter). -- `RunUserScript` — for executable Node/Playwright scripts stored in `scripts/` / `browser-macros/`. -- This tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML. diff --git a/docs/tools/renderusertemplate.md b/docs/tools/renderusertemplate.md deleted file mode 100644 index e3446c3..0000000 --- a/docs/tools/renderusertemplate.md +++ /dev/null @@ -1,108 +0,0 @@ -# RenderUserTemplate - -Renders a template from `templates/` by substituting `{{var}}` placeholders with caller-supplied params. - -## Overview - -Companion to `ReadUserTemplate`. Instead of returning the raw body, this tool: - -1. Parses the template's frontmatter `params` spec (same shape as scripts / browser-macros). -2. Validates caller-supplied `params` against the spec (type-check + defaults applied). -3. Replaces every `{{name}}` placeholder in the body with the resolved value. -4. Returns the rendered body (no `# Template:` header, no frontmatter — just the substituted text). - -Placeholders **not declared** in `frontmatter.params` are left literal — so prose like -`use {{column}} as the key` survives unchanged when no `column` param exists. - ---- - -## Usage - -Template file `templates/weekly-report.md`: - -```markdown ---- -description: Weekly status report -params: - - name: date - type: string - - name: summary - type: string - default: "(no summary)" ---- -# Status — {{date}} - -{{summary}} -``` - -Call: - -```json -{ "name": "weekly-report", "params": { "date": "2026-05-11", "summary": "shipped 3 PRs" } } -``` - -Response: - -``` -# Status — 2026-05-11 - -shipped 3 PRs -``` - ---- - -## Parameters - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Template filename, with or without `.md` extension (max 128 chars) | -| `params` | object | No | Key-value params matching the template's `frontmatter.params` spec | - ---- - -## Frontmatter `params` schema - -Identical to scripts / browser-macros: - -```yaml -params: - - name: identifier # must match /^[a-zA-Z_$][a-zA-Z0-9_$]*$/ - type: string | number | boolean - description: optional - default: optional # if omitted, the param is required -``` - -Param values are coerced to string via `String(value)` when substituted. - ---- - -## Error cases - -- `name` missing or invalid characters → error. -- Template file does not exist in `templates/` → error. -- Frontmatter is malformed (bad YAML, bad `params` shape) → error. -- A required param (no default) is missing from the call → error: `param X: required but not provided`. -- A param has the wrong type → error: `param X: expected number, got string`. - -Templates without any frontmatter render as pure pass-through — any `{{var}}` stays literal. - ---- - -## Use cases - -- Weekly / monthly reports: `RenderUserTemplate({ name: "weekly-report", params: { date, summary } })`. -- Email canned responses with variable substitution. -- Code boilerplate with a few configurable parts (component name, props). - -For more dynamic logic (conditionals, loops), open `ReadUserTemplate` and do the substitution -inline in your output — there is no Handlebars / Liquid / etc. engine, by design. - ---- - -## Related tools - -- `ReadUserTemplate` — returns the raw body + frontmatter. Use this when you want to inspect - the template structure or do substitution yourself. -- `ListUserAssets({ kind: "templates" })` — list available templates. -- `RunUserScript` — for executable scripts (not just text substitution). -- This tool is a META_TOOL — no need to add it to `allowed_tools` in piece YAML. diff --git a/docs/tools/runuserscript.md b/docs/tools/runuserscript.md index ad4eeb8..7774529 100644 --- a/docs/tools/runuserscript.md +++ b/docs/tools/runuserscript.md @@ -1,27 +1,29 @@ # RunUserScript -Executes a user-authored script from the caller's user folder. +Executes a user-authored Playwright browser-macro from the caller's +`browser-macros/` folder. -Two kinds of scripts are supported: +> **Retired (2026-06):** plain-Node `scripts/` (the old `kind: 'script'`) and +> `templates/` were removed. Keep reusable procedures/boilerplate in **Skills**, +> and run ad-hoc code (Node, Python, …) with the **Bash** tool. Passing +> `kind: 'script'` now returns an error pointing at those replacements. -| kind | directory | runtime | signature | use case | -|------|-----------|---------|-----------|----------| -| `'script'` (default) | `scripts/` | plain Node.js — no Chromium | `main({ params })` | Data processing, API calls, computation, file conversion | -| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` | Web automation with a live browser session | +| directory | runtime | signature | use case | +|-----------|---------|-----------|----------| +| `browser-macros/` | Playwright — Chromium | `main({ context, params })` | Web automation with a live browser session | ## Input ```ts { name: string, // filename — '.js' is appended if absent - params?: Record, // runtime values matching the script's param spec - kind?: 'script' | 'browser-macro' // default: 'script' + params?: Record, // runtime values matching the macro's param spec } ``` ## Param validation -Params are validated against the `params:` block in the script's YAML frontmatter: +Params are validated against the `params:` block in the macro's YAML frontmatter: - Extra params not listed in the spec → error containing "param" - Wrong type for a declared param → error containing "param" - Missing required param (no default) → error containing "param" @@ -29,9 +31,9 @@ Params are validated against the `params:` block in the script's YAML frontmatte On any param error the tool returns `isError: true` immediately — no subprocess is spawned. -## Session integration (browser-macro only) +## Session integration -If a `browser-macro` script's frontmatter declares `session_profile_id: `, the tool: +If the macro's frontmatter declares `session_profile_id: `, the tool: 1. Loads the profile from the DB (owner-gated — must belong to `ctx.userId`). 2. Decrypts the user's envelope-encrypted DEK using the master key. @@ -40,13 +42,12 @@ If a `browser-macro` script's frontmatter declares `session_profile_id: `, th If any step fails the tool returns `isError: true` with a descriptive message. -For `kind: 'script'` (plain runtime), `session_profile_id` in the frontmatter is ignored — no session is loaded. +## Self-healing recorder -## Self-healing recorder (browser-macro only) - -When a `browser-macro` fails at runtime, the tool automatically enables the BrowseWeb recorder for the current task (if not already enabled). On task completion, `recording-flush` stages a candidate patch as `browser-macros/{name}.next.js` for diff review. - -Plain scripts (`kind: 'script'`) do **not** auto-enable the recorder. +When a macro fails at runtime, the tool automatically enables the BrowseWeb +recorder for the current task (if not already enabled). On task completion, +`recording-flush` stages a candidate patch as `browser-macros/{name}.next.js` +for diff review. ## Output format @@ -58,14 +59,9 @@ On success: ``` -The result is JSON-stringified if it is an object or array; `String(result)` otherwise. The `[script logs]` section is only appended when the script produced logs. +The result is JSON-stringified if it is an object or array; `String(result)` otherwise. The `[script logs]` section is only appended when the macro produced logs. -On failure (plain): -``` -RunUserScript "{name}" failed: -``` - -On failure (browser-macro): +On failure: ``` RunUserScript "{name}" failed: @@ -78,36 +74,22 @@ On task complete, a candidate patch will be saved as browser-macros/{name}.next. | Situation | `isError` | message contains | |-----------|-----------|-----------------| | No authenticated user | true | "authenticated" | -| Script file not found | true | "not found" | +| Macro file not found | true | "not found" | +| Retired `kind: 'script'` passed | true | "retired" | | Frontmatter parse error | true | "frontmatter" | | Param type / missing error | true | "param" | | Session profile not found / not owned | true | "not found or does not belong" | | Profile not active | true | "not active" | | DEK / blob decryption failure | true | "decrypt" | -| Script timeout (60 s) | true | "timeout" | -| Script exits non-zero | true | "exited code" | -| Plain script denied child_process (e.g. spawning python) | true | "exited code" + "use the Bash tool" | +| Macro timeout (60 s) | true | "timeout" | +| Macro exits non-zero | true | "exited code" | ## Notes - The tool is a META_TOOL — it is available in every movement without listing it in `allowed_tools`. -- Use `kind: 'browser-macro'` for any script that needs a browser (`context`). -- Use `ListUserAssets` first to discover available scripts and their param specs. -- On browser-macro failure, use `BrowseWeb` as a manual fallback. - -## Running Python (don't — use Bash) - -`RunUserScript` runs **Node only**. There is no Python interpreter path. A -common footgun is to write a Node script that does -`child_process.spawn('python3', ...)` and run it here — that **cannot work**: -plain scripts run under Node's `--permission` model, which denies -`child_process` entirely (you get `ERR_ACCESS_DENIED`). Even if it were -allowed, the child's env is scrubbed, so it would not see the orchestrator's -provisioned Python environment. - -To run Python, use the **`Bash` tool** instead: `python3 your_script.py`. The -Bash sandbox has the pip packages pre-baked (pypdf, pdfplumber, python-docx, -python-pptx, openpyxl, pandas, numpy, …). That is the supported, working path. +- Use `ListUserAssets` first to discover available macros and their param specs. +- On macro failure, use `BrowseWeb` as a manual fallback. +- To run Python or other ad-hoc code, use the **Bash** tool (pip packages pre-baked). ## Security and trust model @@ -118,13 +100,13 @@ tools: user_scripts_enabled: true ``` -**Only enable for trusted users.** User scripts run in a restricted child process: -- Env is scrubbed — only `PATH`, `HOME`, `TMPDIR/TMP`, `LANG`, `NODE_ENV`, and `PLAYWRIGHT_BROWSERS_PATH` are forwarded. API keys, database passwords, and other secrets in the orchestrator's environment are not visible to the script. +**Only enable for trusted users.** Macros run in a restricted child process: +- Env is scrubbed — only `PATH`, `HOME`, `TMPDIR/TMP`, `LANG`, `NODE_ENV`, and `PLAYWRIGHT_BROWSERS_PATH` are forwarded. API keys, database passwords, and other secrets in the orchestrator's environment are not visible to the macro. - CWD is set to the system tmpdir, not the orchestrator workspace. - Stdout is capped at 1 MB and stderr at 200 KB; exceeding either limit kills the child. -- On timeout, the entire process group (including Playwright's Chromium for browser-macros) is killed. +- On timeout, the entire process group (including Playwright's Chromium) is killed. -**The two runtimes have different capability levels:** - -- **Plain scripts (`kind: 'script'`)** run under Node's Permissions Model (`--permission`): `--allow-fs-read` is limited to the child-runner dir and tmpdir, `--allow-fs-write` to tmpdir, and `child_process`, worker threads, and native addons are **denied**. A plain script that tries to spawn a subprocess (e.g. python) fails with `ERR_ACCESS_DENIED`. See "Running Python" above. -- **Browser-macros (`kind: 'browser-macro'`)** cannot use `--permission` — Chromium launch, native bindings, and outbound HTTPS all need unrestricted `child_process`/addons/network. They run with full Node.js capability (env-scrubbed only) and rely on container-level isolation. Treat them as trusted code. +Browser-macros cannot use Node's `--permission` model — Chromium launch, native +bindings, and outbound HTTPS all need unrestricted `child_process`/addons/network. +They run with full Node.js capability (env-scrubbed only) and rely on +container-level isolation. Treat them as trusted code. diff --git a/docs/tools/writeuserscript.md b/docs/tools/writeuserscript.md index ffedabb..b26b462 100644 --- a/docs/tools/writeuserscript.md +++ b/docs/tools/writeuserscript.md @@ -1,13 +1,16 @@ # WriteUserScript -Creates or overwrites a script in the caller's user folder. +Creates or overwrites a Playwright browser-macro in the caller's +`browser-macros/` folder. -Two destinations are supported: +> **Retired (2026-06):** plain-Node `scripts/` (the old `kind: 'script'`) and +> `templates/` were removed. Keep reusable procedures/boilerplate in **Skills**, +> and run ad-hoc code with the **Bash** tool. Passing `kind: 'script'` now +> returns an error pointing at those replacements. -| kind | directory | runtime | signature | -|------|-----------|---------|-----------| -| `'script'` (default) | `scripts/` | plain Node.js | `main({ params })` | -| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` | +| directory | runtime | signature | +|-----------|---------|-----------| +| `browser-macros/` | Playwright — Chromium | `main({ context, params })` | ## Input @@ -15,7 +18,6 @@ Two destinations are supported: { name: string, // slug — '.js' appended if absent content: string, // full file text (frontmatter + main()) - kind?: 'script' | 'browser-macro', // default: 'script' overwrite?: boolean // default: false — error if file exists } ``` @@ -26,14 +28,14 @@ The content must define a `main` function. The following forms are all accepted: ```js // ES function declaration -async function main({ params }) { … } +async function main({ context, params }) { … } // Arrow / assigned function -const main = async ({ params }) => { … }; +const main = async ({ context, params }) => { … }; // CommonJS export -module.exports = async function main({ params }) { … }; -exports.main = async function({ params }) { … }; +module.exports = async function main({ context, params }) { … }; +exports.main = async function({ context, params }) { … }; ``` If none of these patterns is found the tool returns `isError: true` with a @@ -53,10 +55,10 @@ params: --- ``` -Frontmatter is parsed by `RunUserScript` for param validation. Scripts without +Frontmatter is parsed by `RunUserScript` for param validation. Macros without frontmatter still run, but param validation is skipped. -Browser macros may additionally declare `session_profile_id: ` to auto-load +Macros may additionally declare `session_profile_id: ` to auto-load a saved login session (see `RunUserScript` docs). ## Size limit @@ -70,41 +72,15 @@ Pass `overwrite: true` to replace the existing file atomically. ## When to use -- You discovered a useful reusable pattern during a task — save it for next time. -- The user asks you to create or update a script they can run later via `RunUserScript`. +- You discovered a useful browser-automation pattern during a task — save it for next time. +- The user asks you to create or update a macro they can run later via `RunUserScript`. - You want to prototype a browser automation without going through the UI. -## Examples - -### Plain Node script - -```js -WriteUserScript({ - name: "fetch-and-clean", - kind: "script", - content: `--- -description: Fetch a URL and return cleaned JSON -params: - - name: url - type: string ---- -const https = require('https'); - -async function main({ params }) { - const res = await fetch(params.url); - const json = await res.json(); - return { items: json.items ?? [] }; -} -` -}) -``` - -### Browser macro +## Example ```js WriteUserScript({ name: "screenshot-dashboard", - kind: "browser-macro", content: `--- description: Take a screenshot of the dashboard params: @@ -126,6 +102,7 @@ async function main({ context, params }) { | Situation | `isError` | message contains | |-----------|-----------|-----------------| | No authenticated user | true | "authenticated" | +| Retired `kind: 'script'` passed | true | "retired" | | `name` missing / empty | true | `"name"` | | `name` contains `/`, space, etc. | true | "alphanumeric" | | `content` missing `main` | true | "main" | @@ -135,5 +112,5 @@ async function main({ context, params }) { ## Notes - `WriteUserScript` is a META_TOOL — available in every movement without listing it in `allowed_tools`. -- After writing, use `RunUserScript` to immediately execute and verify the script. -- Use `ListUserAssets` to see all scripts currently in the folder. +- After writing, use `RunUserScript` to immediately execute and verify the macro. +- Use `ListUserAssets` to see all macros currently in the folder. diff --git a/docs/tools/writeusertemplate.md b/docs/tools/writeusertemplate.md deleted file mode 100644 index 68d0e88..0000000 --- a/docs/tools/writeusertemplate.md +++ /dev/null @@ -1,133 +0,0 @@ -# WriteUserTemplate - -Creates or overwrites a Markdown template in the caller's `templates/` folder. - -Templates written here are immediately usable by `ReadUserTemplate` (to inspect them) and `RenderUserTemplate` (to substitute `{{var}}` placeholders and apply defaults). - -## Input - -```ts -{ - name: string, // slug — '.md' appended if absent - content: string, // full file text (optional frontmatter + Markdown body) - overwrite?: boolean // default: false — error if file exists -} -``` - -## File structure - -The content is plain Markdown with an optional YAML frontmatter block: - -```markdown ---- -description: One-line description shown in ListUserAssets -params: - - name: date - type: string - - name: author - type: string - default: "Team" ---- -# Report — {{date}} - -Prepared by: {{author}} - -## Highlights - -... -``` - -Frontmatter is not required. Templates without `params:` render as-is (no substitution). - -## Frontmatter params spec - -Each param entry in `params:` supports: - -| field | required | description | -|-------|----------|-------------| -| `name` | yes | placeholder name used as `{{name}}` in the body | -| `type` | yes | `string` \| `number` \| `boolean` | -| `default` | no | value applied when the param is omitted | - -Required params (no default) must be supplied by the caller of `RenderUserTemplate`. Missing required params produce an error at render time. - -## Size limit - -128 KB (UTF-8 encoded). Exceeding the limit returns `isError: true`. - -## Overwrite semantics - -By default (`overwrite: false`) writing to an existing file is an error. -Pass `overwrite: true` to replace the file atomically. - -## When to use - -- You noticed a recurring structure (email skeleton, weekly report, issue template) — persist it for reuse. -- The user asks you to create or update a template so they can render it later. -- You want to encode a multi-step prompt skeleton with named slots. - -## Examples - -### Email template - -```js -WriteUserTemplate({ - name: "api-error-email", - content: `--- -description: Customer-facing API error notification email -params: - - name: incident_id - type: string - - name: service - type: string - - name: eta - type: string - default: "unknown" ---- -Dear Customer, - -We have detected an issue with **{{service}}** (incident {{incident_id}}). - -Our team is actively working on a resolution. Estimated resolution time: **{{eta}}**. - -We apologize for the inconvenience. - -— The Platform Team -` -}) -``` - -### Report skeleton (no params) - -```js -WriteUserTemplate({ - name: "weekly-retro", - content: `# Weekly Retro - -## What went well -- - -## What to improve -- - -## Action items -- [ ] -` -}) -``` - -## Error cases - -| Situation | `isError` | message contains | -|-----------|-----------|-----------------| -| No authenticated user | true | "authenticated" | -| `name` missing / empty | true | `"name"` | -| `name` contains `/`, space, etc. | true | "alphanumeric" | -| Content exceeds 128 KB | true | "bytes" | -| File exists, `overwrite` not set | true | "overwrite" | - -## Notes - -- `WriteUserTemplate` is a META_TOOL — available in every movement without listing it in `allowed_tools`. -- After writing, use `ReadUserTemplate` to verify the content and `RenderUserTemplate` to test substitution. -- Use `ListUserAssets` to see all templates currently in the folder. diff --git a/docs/user-folder-layout.md b/docs/user-folder-layout.md index ae46f4c..b23b573 100644 --- a/docs/user-folder-layout.md +++ b/docs/user-folder-layout.md @@ -9,7 +9,7 @@ tied to a single run), the user folder **persists indefinitely** across tasks, sessions, and server restarts. The primary use-cases are: -- Storing reusable scripts (`scripts/`) and browser macros (`browser-macros/`) that any of your tasks can invoke via `RunUserScript`. +- Storing reusable browser macros (`browser-macros/`) that any of your tasks can invoke via `RunUserScript`. - Keeping template files and reference documents you want agents to access without uploading them every time. - Holding auto-generated recordings of browser sessions so you can review or convert them later. - Managing saved browser login sessions (`browser-sessions/`) that macros can use. @@ -22,32 +22,21 @@ created on first login and is never shared between accounts. ## Subdirectories -### `scripts/` - -**AI-generated plain Node.js programs.** No Chromium. Signature: `main({ params })`. - -Best for: data processing, API calls, computation, file conversion, scheduled task helpers — anything that does not need a browser. - -Files are edited directly in the **User Folder → scripts/** panel. The agent writes and runs these via `RunUserScript({ name, kind: 'script' })` (the default `kind`). - -See [docs/tools/runuserscript.md](tools/runuserscript.md) for the exact file format and invocation details. - ### `browser-macros/` **Playwright-based browser automation scripts.** Launches Chromium. Signature: `main({ context, params })`. -Generated automatically by the **Save as Script** button in the recordings panel (previously these went to `scripts/`). Can also be written manually in the UI. The agent runs them via `RunUserScript({ name, kind: 'browser-macro' })`. +> **Retired (2026-06):** the former `scripts/` (plain Node) and `templates/` +> subdirectories were removed. Reusable procedures/boilerplate belong in +> **Skills**; ad-hoc code runs via the agent's **Bash** tool. Existing files +> remain on disk but are no longer listed or runnable. + +Generated automatically by the **Save as Script** button in the recordings panel. Can also be written manually in the UI. The agent runs them via `RunUserScript({ name })`. If a `session_profile_id` is declared in the frontmatter, the corresponding saved browser session (from `browser-sessions/`) is loaded automatically. **Self-healing patches**: when a macro fails, the agent auto-enables the BrowseWeb recorder; on task completion a candidate patch is staged as `browser-macros/{name}.next.js`. The Diff review pane lets you accept or reject it. See [Self-Healing Patches](#self-healing-script-patches) below. -### `templates/` - -Static files — Markdown snippets, HTML skeletons, CSV headers, prompt -fragments — that you want to reuse across tasks. Agents can read these with -the standard `Read` tool by referencing the path the API returns. - ### `recordings/` Browser-session recordings produced by `BrowseWeb` when the `record_to` @@ -126,25 +115,7 @@ editor — the agent picks up the latest version at the start of each task. --- -## Script vs Browser-Macro Format - -### Plain scripts (`scripts/`) - -```js ---- -description: "Fetch and summarise data" -params: - - name: url - type: string ---- -async function main({ params }) { - const data = await fetch(params.url).then(r => r.json()); - return data.summary; -} -module.exports = main; -``` - -Invocation: `RunUserScript({ name: 'my-script', kind: 'script', params: { url: '...' } })` +## Browser-Macro Format ### Browser macros (`browser-macros/`) diff --git a/src/bridge/tools-api.ts b/src/bridge/tools-api.ts index 1f6ee3f..b34c94c 100644 --- a/src/bridge/tools-api.ts +++ b/src/bridge/tools-api.ts @@ -55,10 +55,7 @@ const META_TOOLS = new Set([ 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', - 'ReadUserTemplate', - 'RenderUserTemplate', 'WriteUserScript', - 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', diff --git a/src/bridge/user-folder-api.test.ts b/src/bridge/user-folder-api.test.ts index e17bc35..b8e1812 100644 --- a/src/bridge/user-folder-api.test.ts +++ b/src/bridge/user-folder-api.test.ts @@ -80,12 +80,12 @@ describe('User Folder API', () => { describe('GET /folder/list', () => { it('returns files in the requested subdir', async () => { // Write 2 files directly into the subdir - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")'); writeFileSync(join(scriptsDir, 'world.ts'), 'export {}'); - const res = await request(app).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(200); const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort(); expect(names).toEqual(['hello.js', 'world.ts']); @@ -101,12 +101,12 @@ describe('User Folder API', () => { }); it('does not return hidden files (starting with .)', async () => { - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'visible.js'), 'ok'); writeFileSync(join(scriptsDir, '.hidden'), 'secret'); - const res = await request(app).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(200); const names = (res.body.files as Array<{ name: string }>).map(f => f.name); expect(names).toContain('visible.js'); @@ -115,7 +115,7 @@ describe('User Folder API', () => { it('returns 401 when req.user is missing', async () => { const unauthApp = makeUnauthApp(tmpRoot); - const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(401); }); @@ -370,41 +370,41 @@ describe('User Folder API', () => { describe('GET /folder/file', () => { it('returns file contents as text', async () => { - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'test.js'), 'console.log("hello")'); - const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=test.js'); + const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=test.js'); expect(res.status).toBe(200); expect(res.text).toBe('console.log("hello")'); }); it('returns 400 for path traversal attempt', async () => { const res = await request(app).get( - '/api/users/me/folder/file?subdir=scripts&path=../../etc/passwd', + '/api/users/me/folder/file?subdir=browser-macros&path=../../etc/passwd', ); expect(res.status).toBe(400); }); it('returns 404 for a missing file', async () => { - const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=nope.js'); + const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=nope.js'); expect(res.status).toBe(404); }); it('returns 413 for a file larger than 1 MB', async () => { - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); // Write a 1.1 MB file const big = Buffer.alloc(1024 * 1024 + 100, 'x'); writeFileSync(join(scriptsDir, 'big.txt'), big); - const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=big.txt'); + const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=big.txt'); expect(res.status).toBe(413); }); it('returns 401 when req.user is missing', async () => { const unauthApp = makeUnauthApp(tmpRoot); - const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=scripts&path=x.js'); + const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=browser-macros&path=x.js'); expect(res.status).toBe(401); }); }); @@ -417,7 +417,7 @@ describe('User Folder API', () => { it('writes the file (verifiable via direct fs read)', async () => { const content = 'export const x = 1;'; const res = await request(app) - .put('/api/users/me/folder/file?subdir=scripts&path=new.js') + .put('/api/users/me/folder/file?subdir=browser-macros&path=new.js') .set('Content-Type', 'text/plain') .send(content); @@ -426,18 +426,18 @@ describe('User Folder API', () => { expect(typeof res.body.size).toBe('number'); expect(typeof res.body.mtime).toBe('string'); - const written = readFileSync(join(tmpRoot, USER_A, 'scripts', 'new.js'), 'utf-8'); + const written = readFileSync(join(tmpRoot, USER_A, 'browser-macros', 'new.js'), 'utf-8'); expect(written).toBe(content); }); it('is atomic: a follow-up GET sees the new content', async () => { const content = 'const y = 42;'; await request(app) - .put('/api/users/me/folder/file?subdir=scripts&path=atomic.js') + .put('/api/users/me/folder/file?subdir=browser-macros&path=atomic.js') .set('Content-Type', 'text/plain') .send(content); - const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=atomic.js'); + const res = await request(app).get('/api/users/me/folder/file?subdir=browser-macros&path=atomic.js'); expect(res.status).toBe(200); expect(res.text).toBe(content); }); @@ -445,7 +445,7 @@ describe('User Folder API', () => { it('returns 413 when body exceeds 1 MB', async () => { const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString(); const res = await request(app) - .put('/api/users/me/folder/file?subdir=scripts&path=big.js') + .put('/api/users/me/folder/file?subdir=browser-macros&path=big.js') .set('Content-Type', 'text/plain') .send(big); expect(res.status).toBe(413); @@ -463,7 +463,7 @@ describe('User Folder API', () => { it('returns 401 when req.user is missing', async () => { const unauthApp = makeUnauthApp(tmpRoot); const res = await request(unauthApp) - .put('/api/users/me/folder/file?subdir=scripts&path=x.js') + .put('/api/users/me/folder/file?subdir=browser-macros&path=x.js') .set('Content-Type', 'text/plain') .send('hi'); expect(res.status).toBe(401); @@ -476,12 +476,12 @@ describe('User Folder API', () => { describe('DELETE /folder/file', () => { it('moves the file into trash/ with a timestamp prefix', async () => { - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye'); const res = await request(app).delete( - '/api/users/me/folder/file?subdir=scripts&path=to-delete.js', + '/api/users/me/folder/file?subdir=browser-macros&path=to-delete.js', ); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); @@ -500,7 +500,7 @@ describe('User Folder API', () => { it('returns 401 when req.user is missing', async () => { const unauthApp = makeUnauthApp(tmpRoot); const res = await request(unauthApp).delete( - '/api/users/me/folder/file?subdir=scripts&path=x.js', + '/api/users/me/folder/file?subdir=browser-macros&path=x.js', ); expect(res.status).toBe(401); }); @@ -514,19 +514,19 @@ describe('User Folder API', () => { it('returns 404 when DELETE targets a missing file', async () => { const res = await request(app).delete( - '/api/users/me/folder/file?subdir=scripts&path=ghost.js', + '/api/users/me/folder/file?subdir=browser-macros&path=ghost.js', ); expect(res.status).toBe(404); }); it('handles two same-name deletes in quick succession without data loss', async () => { - const scriptsDir = join(tmpRoot, USER_A, 'scripts'); + const scriptsDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptsDir, { recursive: true }); // First file writeFileSync(join(scriptsDir, 'dup.js'), 'first'); const res1 = await request(app).delete( - '/api/users/me/folder/file?subdir=scripts&path=dup.js', + '/api/users/me/folder/file?subdir=browser-macros&path=dup.js', ); expect(res1.status).toBe(200); const trashedAs1 = res1.body.trashedAs as string; @@ -534,7 +534,7 @@ describe('User Folder API', () => { // Second file with same name writeFileSync(join(scriptsDir, 'dup.js'), 'second'); const res2 = await request(app).delete( - '/api/users/me/folder/file?subdir=scripts&path=dup.js', + '/api/users/me/folder/file?subdir=browser-macros&path=dup.js', ); expect(res2.status).toBe(200); const trashedAs2 = res2.body.trashedAs as string; @@ -556,13 +556,13 @@ describe('User Folder API', () => { describe('Cross-user isolation', () => { it('user A cannot read files belonging to user B', async () => { // Write a file under user B's folder directly - const bScriptsDir = join(tmpRoot, USER_B, 'scripts'); + const bScriptsDir = join(tmpRoot, USER_B, 'browser-macros'); mkdirSync(bScriptsDir, { recursive: true }); writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret'); // app is authed as USER_A; try to reach USER_B's file via traversal const res = await request(app).get( - `/api/users/me/folder/file?subdir=scripts&path=../../${USER_B}/scripts/secret.js`, + `/api/users/me/folder/file?subdir=browser-macros&path=../../${USER_B}/scripts/secret.js`, ); // Must be 400 (traversal blocked) — NOT 200 expect(res.status).toBe(400); @@ -570,14 +570,14 @@ describe('User Folder API', () => { it('user A list only sees their own files, not user B files', async () => { // Create scripts for both users - const aDir = join(tmpRoot, USER_A, 'scripts'); - const bDir = join(tmpRoot, USER_B, 'scripts'); + const aDir = join(tmpRoot, USER_A, 'browser-macros'); + const bDir = join(tmpRoot, USER_B, 'browser-macros'); mkdirSync(aDir, { recursive: true }); mkdirSync(bDir, { recursive: true }); writeFileSync(join(aDir, 'a-only.js'), 'a'); writeFileSync(join(bDir, 'b-only.js'), 'b'); - const res = await request(app).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(app).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(200); const names = (res.body.files as Array<{ name: string }>).map(f => f.name); expect(names).toContain('a-only.js'); @@ -728,7 +728,7 @@ describe('User Folder API', () => { describe('POST /scripts/:name/run', () => { it('runs a script that returns 42', async () => { - const scriptDir = join(tmpRoot, USER_A, 'scripts'); + const scriptDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptDir, { recursive: true }); writeFileSync(join(scriptDir, 'simple.js'), SIMPLE_SCRIPT_BODY); @@ -750,7 +750,7 @@ describe('User Folder API', () => { }); it('returns 500 with "param" in error when params are bad', async () => { - const scriptDir = join(tmpRoot, USER_A, 'scripts'); + const scriptDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptDir, { recursive: true }); // Script with a declared param of type string const scriptWithParam = `\ @@ -773,7 +773,7 @@ module.exports = async function main({ params }) { return params.username; }; }); it('clamps timeoutMs to 5 minutes max', async () => { - const scriptDir = join(tmpRoot, USER_A, 'scripts'); + const scriptDir = join(tmpRoot, USER_A, 'browser-macros'); mkdirSync(scriptDir, { recursive: true }); writeFileSync(join(scriptDir, 'fast.js'), SIMPLE_SCRIPT_BODY); @@ -1055,18 +1055,18 @@ module.exports = async function main({ params }) { return params.username; }; it('returns 401 when authActive=true (default) and no user', async () => { // makeUnauthApp uses the default (authActive not passed → defaults to true) const unauthApp = makeUnauthApp(tmpRoot); - const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(401); expect(res.body.error).toMatch(/Unauthenticated/i); }); it('falls back to synthetic local user when authActive=false', async () => { const noAuthApp = makeNoAuthModeApp(tmpRoot); - // Pre-create the 'local' user scripts dir so list returns 200 rather than 500 - const localScriptsDir = join(tmpRoot, 'local', 'scripts'); - mkdirSync(localScriptsDir, { recursive: true }); + // Pre-create the 'local' user macros dir so list returns 200 rather than 500 + const localMacrosDir = join(tmpRoot, 'local', 'browser-macros'); + mkdirSync(localMacrosDir, { recursive: true }); - const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=scripts'); + const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=browser-macros'); expect(res.status).toBe(200); expect(Array.isArray(res.body.files)).toBe(true); }); diff --git a/src/bridge/user-folder-api.ts b/src/bridge/user-folder-api.ts index 221f9fa..a8e6a97 100644 --- a/src/bridge/user-folder-api.ts +++ b/src/bridge/user-folder-api.ts @@ -57,7 +57,7 @@ function isUserSubdir(s: string): s is UserSubdir { // Subdirs that users may write to / delete from. 'trash' is system-managed. // 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers // then delegate immediately to NotesService rather than the generic file writer. -const WRITABLE_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'notes'] as const; +const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const; type WritableSubdir = typeof WRITABLE_SUBDIRS[number]; function isWritableSubdir(s: string): s is WritableSubdir { return (WRITABLE_SUBDIRS as readonly string[]).includes(s); @@ -639,31 +639,18 @@ export function createUserFolderApi(deps: Deps): Router { return; } - const { params, timeoutMs, kind } = ((req.body as Record) ?? {}) as { + const { params, timeoutMs } = ((req.body as Record) ?? {}) as { params?: Record; timeoutMs?: number; - kind?: string; }; - // Resolve script path depending on kind + // Plain-Node scripts/ were retired (2026-06) — only browser-macros run here. let scriptPath: string | null = null; - let resolvedRuntime: 'plain' | 'playwright' = 'plain'; - - if (!kind || kind === 'script') { - try { - const candidate = resolveUserSubdir(userFolderRoot, u.id, 'scripts', scriptFileName); - if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'plain'; } - } catch { /* invalid path */ } - } - if (!scriptPath && (!kind || kind === 'browser-macro')) { - try { - const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName); - if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'playwright'; } - } catch { /* invalid path */ } - } - if (!scriptPath && kind === 'script') { - // explicit kind but no match — keep null to hit 404 below - } + const resolvedRuntime = 'playwright' as const; + try { + const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName); + if (existsSync(candidate)) scriptPath = candidate; + } catch { /* invalid path */ } if (scriptPath === null) { res.status(404).json({ error: `Script not found: ${scriptFileName}` }); return; diff --git a/src/config.ts b/src/config.ts index e2d8689..2a0900f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,12 +71,11 @@ export interface ToolsConfig { */ taskUploadMaxSizeMb?: number; /** - * Allow RunUserScript to execute user-authored scripts. + * Allow RunUserScript to execute user-authored browser-macros. * Default: false (opt-in required). - * Plain-runtime scripts now run under Node's Permissions Model - * (--permission), which blocks child_process, worker threads, and FS access - * outside tmpdir. browser-macros still run with full Node.js capabilities - * because Playwright needs them — only enable for trusted users. + * Browser-macros run with full Node.js capabilities because Playwright + * needs them — only enable for trusted users. + * (Plain-Node scripts/ were retired in 2026-06; Skills + Bash replace them.) */ userScriptsEnabled?: boolean; /** diff --git a/src/engine/tools/user-folder.test.ts b/src/engine/tools/user-folder.test.ts index 0ab26a1..247a6dd 100644 --- a/src/engine/tools/user-folder.test.ts +++ b/src/engine/tools/user-folder.test.ts @@ -81,9 +81,7 @@ beforeEach(() => { mkdirSync(userFolderRoot, { recursive: true }); // Create user subdirs (including memory and trash for memory tool tests) - mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true }); - mkdirSync(join(userFolderRoot, TEST_USER, 'templates'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'recordings'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'memory'), { recursive: true }); mkdirSync(join(userFolderRoot, TEST_USER, 'trash'), { recursive: true }); @@ -117,12 +115,12 @@ describe('TOOL_DEFS', () => { // ── ListUserAssets ──────────────────────────────────────────────────────────── describe('ListUserAssets', () => { - it('lists scripts with descriptions and params', async () => { - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO); - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'bar.js'), SCRIPT_BAR); + it('lists browser-macros with descriptions and params', async () => { + writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); + writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'bar.js'), SCRIPT_BAR); const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ListUserAssets', { kind: 'scripts' }, ctx); + const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx); expect(result).not.toBeNull(); expect(result!.isError).toBe(false); @@ -131,7 +129,7 @@ describe('ListUserAssets', () => { expect(result!.output).toContain('date:string'); expect(result!.output).toContain('bar.js'); expect(result!.output).toContain('Check dashboard'); - expect(result!.output).toContain('Scripts (2)'); + expect(result!.output).toContain('Browser Macros (2)'); }); it('returns error when userId is missing', async () => { @@ -145,41 +143,43 @@ describe('ListUserAssets', () => { it('cross-user access is denied (ctx.userId must match target folder)', async () => { // Create another user's folder - mkdirSync(join(userFolderRoot, 'other-user', 'scripts'), { recursive: true }); + mkdirSync(join(userFolderRoot, 'other-user', 'browser-macros'), { recursive: true }); writeFileSync( - join(userFolderRoot, 'other-user', 'scripts', 'secret.js'), + join(userFolderRoot, 'other-user', 'browser-macros', 'secret.js'), SCRIPT_FOO, ); // Logged in as TEST_USER but the tool always reads from ctx.userId, // so there is no way to list another user's folder. - // Verify that the tool reads only TEST_USER's scripts (0 scripts). + // Verify that the tool reads only TEST_USER's macros (0 macros). const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ListUserAssets', { kind: 'scripts' }, ctx); + const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx); expect(result!.isError).toBe(false); - expect(result!.output).toContain('Scripts (0)'); + expect(result!.output).toContain('Browser Macros (0)'); expect(result!.output).not.toContain('secret.js'); }); it('returns "all" categories when kind is omitted', async () => { - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO); + writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('ListUserAssets', {}, ctx); expect(result!.isError).toBe(false); - expect(result!.output).toContain('Scripts'); - expect(result!.output).toContain('Templates'); + expect(result!.output).toContain('Browser Macros'); expect(result!.output).toContain('Recordings'); + // Retired categories must no longer appear + expect(result!.output).not.toContain('Templates'); + expect(result!.output).not.toContain('Scripts ('); }); }); // ── RunUserScript ───────────────────────────────────────────────────────────── describe('RunUserScript', () => { - it('runs a fixture script that returns a value', async () => { - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM); + it('runs a fixture macro that returns a value', async () => { + writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx); @@ -190,8 +190,8 @@ describe('RunUserScript', () => { }); it('returns isError true with "param" in message for bad params', async () => { - // Script declares `date:string` but we pass wrong type - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO); + // Macro declares `date:string` but we pass wrong type + writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO); const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool( @@ -289,67 +289,34 @@ module.exports = async function main() { return 'ok'; }; expect(result!.output).toMatch(/invalid script name|outside owner folder|not found/); }); - // ── P2a: kind fallback (undefined → search scripts/ then browser-macros/) ─ + // ── Retirement (2026-06): plain-Node scripts/ are gone ───────────────────── - it('resolves to scripts/ first when kind omitted', async () => { - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM); - writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_THROWS); + it('a file living only in the retired scripts/ dir is no longer resolvable', async () => { + mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true }); + writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'legacy.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); - // kind omitted → should find scripts/nofm.js (plain runtime, returns 42) - const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx); - expect(result!.isError).toBe(false); - expect(result!.output).toContain('42'); + const result = await executeTool('RunUserScript', { name: 'legacy' }, ctx); + expect(result!.isError).toBe(true); + expect(result!.output.toLowerCase()).toContain('not found'); }); - it('falls back to browser-macros/ when kind omitted and not in scripts/', async () => { - // Only exists in browser-macros/ + it('explicit kind: "script" is rejected with a pointer to Bash/Skills', async () => { + const ctx = buildCtx({ userId: TEST_USER }); + const result = await executeTool('RunUserScript', { name: 'whatever', kind: 'script' }, ctx); + expect(result!.isError).toBe(true); + expect(result!.output).toContain('retired'); + expect(result!.output).toContain('Bash'); + }); + + it('kind: "browser-macro" still accepted explicitly (back-compat)', async () => { writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM); const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx); - // Should find it in browser-macros/ (plain-compatible SCRIPT_NO_FM, no playwright deps) - expect(result!.isError).toBe(false); - expect(result!.output).toContain('42'); - }); - - it('kind: "browser-macro" goes straight to browser-macros/', async () => { - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_THROWS); - writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM); - - const ctx = buildCtx({ userId: TEST_USER }); - // Explicit kind=browser-macro should use browser-macros/ not scripts/ const result = await executeTool('RunUserScript', { name: 'nofm', kind: 'browser-macro' }, ctx); expect(result!.isError).toBe(false); expect(result!.output).toContain('42'); }); - - it('returns error mentioning both subdirs when kind omitted and not found', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RunUserScript', { name: 'nonexistent' }, ctx); - expect(result!.isError).toBe(true); - expect(result!.output).toContain('scripts/'); - expect(result!.output).toContain('browser-macros/'); - }); - - // ── session_profile_id in scripts/ (plain runtime) → friendly error ─────── - - it('rejects scripts/*.js with session_profile_id (plain runtime mismatch)', async () => { - const scriptWithSession = `\ ---- -description: Mistakenly placed browser macro -session_profile_id: 3 ---- -module.exports = async function main() { return 'unreachable'; }; -`; - writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'wrongplace.js'), scriptWithSession); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RunUserScript', { name: 'wrongplace', kind: 'script' }, ctx); - expect(result!.isError).toBe(true); - expect(result!.output).toMatch(/session_profile_id/); - expect(result!.output).toMatch(/browser-macros/); - }); }); // ── UpdateUserMemory ────────────────────────────────────────────────────────── @@ -447,232 +414,6 @@ describe('UpdateUserMemory', () => { }); }); -// ── ReadUserTemplate ────────────────────────────────────────────────────────── - -describe('ReadUserTemplate', () => { - it('reads a plain markdown template by name', async () => { - writeFileSync( - join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md'), - '# Weekly Report\n\nFill in this week\'s highlights here.\n', - ); - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ReadUserTemplate', { name: 'weekly-report' }, ctx); - - expect(result).not.toBeNull(); - expect(result!.isError).toBe(false); - expect(result!.output).toContain('# Template: weekly-report'); - expect(result!.output).toContain('Fill in this week\'s highlights here.'); - }); - - it('reads a template with frontmatter', async () => { - const content = [ - '---', - 'title: API Error Email', - 'audience: external', - '---', - 'Dear customer,', - '', - 'We apologize for the inconvenience.', - ].join('\n') + '\n'; - writeFileSync( - join(userFolderRoot, TEST_USER, 'templates', 'api-error-email.md'), - content, - ); - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ReadUserTemplate', { name: 'api-error-email' }, ctx); - - expect(result!.isError).toBe(false); - expect(result!.output).toContain('## Frontmatter'); - expect(result!.output).toContain('title'); - expect(result!.output).toContain('API Error Email'); - expect(result!.output).toContain('Dear customer,'); - }); - - it('returns error for missing template', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ReadUserTemplate', { name: 'nonexistent' }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('not found'); - }); - - it('rejects path traversal in name', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('ReadUserTemplate', { name: '../escape' }, ctx); - - expect(result!.isError).toBe(true); - }); - - it('handles name with .md suffix gracefully', async () => { - writeFileSync( - join(userFolderRoot, TEST_USER, 'templates', 'boilerplate.md'), - 'Hello world template.\n', - ); - const ctx = buildCtx({ userId: TEST_USER }); - // Pass name with .md extension — should still work - const result = await executeTool('ReadUserTemplate', { name: 'boilerplate.md' }, ctx); - - expect(result!.isError).toBe(false); - expect(result!.output).toContain('Hello world template.'); - }); - - it('rejects without authenticated user', async () => { - const ctx = buildCtx({ userId: undefined }); - const result = await executeTool('ReadUserTemplate', { name: 'anything' }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('authenticated'); - }); -}); - -// ── RenderUserTemplate ──────────────────────────────────────────────────────── - -describe('RenderUserTemplate', () => { - it('substitutes {{var}} declared in frontmatter.params', async () => { - const content = [ - '---', - 'description: Weekly report', - 'params:', - ' - name: date', - ' type: string', - ' - name: summary', - ' type: string', - '---', - 'On {{date}}: {{summary}}', - ].join('\n') + '\n'; - writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'weekly.md'), content); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool( - 'RenderUserTemplate', - { name: 'weekly', params: { date: '2026-05-11', summary: 'shipped 3 PRs' } }, - ctx, - ); - - expect(result!.isError).toBe(false); - expect(result!.output).toBe('On 2026-05-11: shipped 3 PRs\n'); - }); - - it('applies declared defaults when params are omitted', async () => { - const content = [ - '---', - 'description: Greeting', - 'params:', - ' - name: name', - ' type: string', - ' default: world', - '---', - 'Hello, {{name}}!', - ].join('\n') + '\n'; - writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'greet.md'), content); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RenderUserTemplate', { name: 'greet' }, ctx); - - expect(result!.isError).toBe(false); - expect(result!.output).toBe('Hello, world!\n'); - }); - - it('leaves undeclared {{var}} literal', async () => { - const content = [ - '---', - 'description: Mixed', - 'params:', - ' - name: title', - ' type: string', - '---', - '# {{title}}\n\nSee {{see_also}} for details.', - ].join('\n') + '\n'; - writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'mixed.md'), content); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool( - 'RenderUserTemplate', - { name: 'mixed', params: { title: 'Notes' } }, - ctx, - ); - - expect(result!.isError).toBe(false); - expect(result!.output).toContain('# Notes'); - expect(result!.output).toContain('See {{see_also}} for details.'); - }); - - it('rejects when a required param is missing', async () => { - const content = [ - '---', - 'description: Required param', - 'params:', - ' - name: subject', - ' type: string', - '---', - 'Subject: {{subject}}', - ].join('\n') + '\n'; - writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'req.md'), content); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RenderUserTemplate', { name: 'req' }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toMatch(/subject.*required/i); - }); - - it('rejects type-mismatched params', async () => { - const content = [ - '---', - 'description: Number param', - 'params:', - ' - name: count', - ' type: number', - '---', - 'Total: {{count}}', - ].join('\n') + '\n'; - writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'count.md'), content); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool( - 'RenderUserTemplate', - { name: 'count', params: { count: 'three' } }, - ctx, - ); - - expect(result!.isError).toBe(true); - expect(result!.output).toMatch(/count.*expected number/); - }); - - it('renders a template without frontmatter as-is', async () => { - writeFileSync( - join(userFolderRoot, TEST_USER, 'templates', 'plain.md'), - 'Just plain text with {{notRendered}}.\n', - ); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RenderUserTemplate', { name: 'plain' }, ctx); - - expect(result!.isError).toBe(false); - expect(result!.output).toBe('Just plain text with {{notRendered}}.\n'); - }); - - it('rejects path traversal', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RenderUserTemplate', { name: '../escape' }, ctx); - expect(result!.isError).toBe(true); - }); - - it('returns error for missing template', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('RenderUserTemplate', { name: 'never-existed' }, ctx); - expect(result!.isError).toBe(true); - expect(result!.output).toContain('not found'); - }); - - it('rejects without authenticated user', async () => { - const ctx = buildCtx({ userId: undefined }); - const result = await executeTool('RenderUserTemplate', { name: 'anything' }, ctx); - expect(result!.isError).toBe(true); - expect(result!.output).toContain('authenticated'); - }); -}); - // ── ReadUserMemory ──────────────────────────────────────────────────────────── describe('ReadUserMemory', () => { @@ -725,7 +466,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => { tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] }, }); writeFileSync( - join(userFolderRoot, TEST_USER, 'scripts', 'noop.js'), + join(userFolderRoot, TEST_USER, 'browser-macros', 'noop.js'), `---\nparams: []\n---\nasync function main(){return 'ok';}\nmodule.exports=main;\n`, 'utf-8', ); @@ -740,7 +481,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => { tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] }, }); writeFileSync( - join(userFolderRoot, TEST_USER, 'scripts', 'ok.js'), + join(userFolderRoot, TEST_USER, 'browser-macros', 'ok.js'), `---\nparams: []\n---\nasync function main(){return 'allowed';}\nmodule.exports=main;\n`, 'utf-8', ); @@ -755,7 +496,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => { tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] }, }); writeFileSync( - join(userFolderRoot, TEST_USER, 'scripts', 'empty.js'), + join(userFolderRoot, TEST_USER, 'browser-macros', 'empty.js'), `---\nparams: []\n---\nasync function main(){return 'still ok';}\nmodule.exports=main;\n`, 'utf-8', ); @@ -792,7 +533,7 @@ async function main({ context, params }) { `; describe('WriteUserScript', () => { - it('writes a plain script to scripts/', async () => { + it('rejects the retired kind: "script" with a pointer to Skills/Bash', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'fetch-and-clean', @@ -801,13 +542,8 @@ describe('WriteUserScript', () => { }, ctx); expect(result).not.toBeNull(); - expect(result!.isError).toBe(false); - expect(result!.output).toContain('scripts/fetch-and-clean.js'); - - const written = join(userFolderRoot, TEST_USER, 'scripts', 'fetch-and-clean.js'); - const { existsSync: checkExists, readFileSync: rf } = await import('fs'); - expect(checkExists(written)).toBe(true); - expect(rf(written, 'utf-8')).toBe(VALID_SCRIPT); + expect(result!.isError).toBe(true); + expect(result!.output).toContain('retired'); }); it('writes a browser-macro to browser-macros/', async () => { @@ -826,7 +562,7 @@ describe('WriteUserScript', () => { expect(checkExists(written)).toBe(true); }); - it('defaults kind to "script" when omitted', async () => { + it('writes to browser-macros/ when kind is omitted', async () => { const ctx = buildCtx({ userId: TEST_USER }); const result = await executeTool('WriteUserScript', { name: 'implicit-kind', @@ -834,7 +570,7 @@ describe('WriteUserScript', () => { }, ctx); expect(result!.isError).toBe(false); - expect(result!.output).toContain('scripts/implicit-kind.js'); + expect(result!.output).toContain('browser-macros/implicit-kind.js'); }); it('accepts name with .js suffix (strips it)', async () => { @@ -845,7 +581,7 @@ describe('WriteUserScript', () => { }, ctx); expect(result!.isError).toBe(false); - expect(result!.output).toContain('scripts/my-script.js'); + expect(result!.output).toContain('browser-macros/my-script.js'); // Must not write "my-script.js.js" expect(result!.output).not.toContain('my-script.js.js'); }); @@ -946,7 +682,7 @@ describe('WriteUserScript', () => { expect(result!.isError).toBe(false); const { readFileSync: rf } = await import('fs'); - const written = join(userFolderRoot, TEST_USER, 'scripts', 'overwritable.js'); + const written = join(userFolderRoot, TEST_USER, 'browser-macros', 'overwritable.js'); expect(rf(written, 'utf-8')).toContain('updated'); }); @@ -984,166 +720,6 @@ describe('WriteUserScript', () => { }); }); -// ── WriteUserTemplate ───────────────────────────────────────────────────────── - -const VALID_TEMPLATE = `\ ---- -description: Weekly report -params: - - name: date - type: string ---- -# Weekly Report — {{date}} - -Highlights this week: -- ... -`; - -describe('WriteUserTemplate', () => { - it('writes a template to templates/', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { - name: 'weekly-report', - content: VALID_TEMPLATE, - }, ctx); - - expect(result).not.toBeNull(); - expect(result!.isError).toBe(false); - expect(result!.output).toContain('templates/weekly-report.md'); - - const { existsSync: checkExists, readFileSync: rf } = await import('fs'); - const written = join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md'); - expect(checkExists(written)).toBe(true); - expect(rf(written, 'utf-8')).toBe(VALID_TEMPLATE); - }); - - it('accepts name with .md suffix (strips it)', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { - name: 'report.md', - content: VALID_TEMPLATE, - }, ctx); - - expect(result!.isError).toBe(false); - expect(result!.output).toContain('templates/report.md'); - // Must not write "report.md.md" - expect(result!.output).not.toContain('report.md.md'); - }); - - it('rejects missing name', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { content: VALID_TEMPLATE }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('"name"'); - }); - - it('rejects non-slug name (slash)', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { - name: 'foo/bar', - content: VALID_TEMPLATE, - }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output.toLowerCase()).toContain('alphanumeric'); - }); - - it('rejects path traversal via name', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { - name: '../escape', - content: VALID_TEMPLATE, - }, ctx); - - expect(result!.isError).toBe(true); - }); - - it('rejects oversized content', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - const huge = '# template\n' + 'x'.repeat(128 * 1024); - const result = await executeTool('WriteUserTemplate', { - name: 'big-template', - content: huge, - }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('bytes'); - }); - - it('rejects duplicate without overwrite', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, ctx); - const result = await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('overwrite'); - }); - - it('overwrites when overwrite: true', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - await executeTool('WriteUserTemplate', { name: 'overwritable-tmpl', content: VALID_TEMPLATE }, ctx); - - const updated = VALID_TEMPLATE.replace('Weekly Report', 'Monthly Report'); - const result = await executeTool('WriteUserTemplate', { - name: 'overwritable-tmpl', - content: updated, - overwrite: true, - }, ctx); - - expect(result!.isError).toBe(false); - const { readFileSync: rf } = await import('fs'); - const written = join(userFolderRoot, TEST_USER, 'templates', 'overwritable-tmpl.md'); - expect(rf(written, 'utf-8')).toContain('Monthly Report'); - }); - - it('requires authenticated user', async () => { - const ctx = buildCtx({ userId: undefined }); - const result = await executeTool('WriteUserTemplate', { - name: 'test', - content: VALID_TEMPLATE, - }, ctx); - - expect(result!.isError).toBe(true); - expect(result!.output).toContain('authenticated'); - }); - - it('invokes auditLog on success', async () => { - const auditCalls: Array<{ action: string; detail: Record }> = []; - setUserFolderToolDeps({ - sessRepo: null as never, - masterKeyPath: '', - userFolderRoot, - auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record }); }, - }); - - const ctx = buildCtx({ userId: TEST_USER }); - const result = await executeTool('WriteUserTemplate', { - name: 'audit-tmpl', - content: VALID_TEMPLATE, - }, ctx); - - expect(result!.isError).toBe(false); - const entry = auditCalls.find(c => c.action === 'user_template_written'); - expect(entry).toBeDefined(); - expect(entry!.detail['userId']).toBe(TEST_USER); - expect(entry!.detail['filename']).toBe('audit-tmpl.md'); - }); - - it('written template is immediately readable via ReadUserTemplate', async () => { - const ctx = buildCtx({ userId: TEST_USER }); - await executeTool('WriteUserTemplate', { - name: 'round-trip', - content: VALID_TEMPLATE, - }, ctx); - - const result = await executeTool('ReadUserTemplate', { name: 'round-trip' }, ctx); - expect(result!.isError).toBe(false); - expect(result!.output).toContain('Weekly Report'); - expect(result!.output).toContain('{{date}}'); - }); -}); - describe('RunUserScript: audit log hook', () => { afterEach(() => { mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } }); @@ -1160,7 +736,7 @@ describe('RunUserScript: audit log hook', () => { }, }); writeFileSync( - join(userFolderRoot, TEST_USER, 'scripts', 'audited.js'), + join(userFolderRoot, TEST_USER, 'browser-macros', 'audited.js'), `---\nparams: []\n---\nasync function main(){return 'audited ok';}\nmodule.exports=main;\n`, 'utf-8', ); diff --git a/src/engine/tools/user-folder.ts b/src/engine/tools/user-folder.ts index 1d88849..856eeb5 100644 --- a/src/engine/tools/user-folder.ts +++ b/src/engine/tools/user-folder.ts @@ -1,19 +1,21 @@ /** * user-folder.ts * - * Tools for discovering and executing user-authored Playwright scripts: - * - ListUserAssets: browse scripts / templates / recordings in data/users/{userId}/ - * - RunUserScript: validate params, decrypt session storageState if needed, delegate to runUserScript() + * Tools for the per-user folder (data/users/{userId}/): + * - UpdateUserMemory / ReadUserMemory: persistent memory entries + * - ListUserAssets: browse browser-macros / recordings + * - RunUserScript / WriteUserScript: Playwright browser-macros + * + * Note: plain-Node scripts/ and templates/ tools were retired in 2026-06 — + * reusable knowledge belongs in Skills, ad-hoc code runs via the Bash tool. */ import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'; import { join, extname } from 'node:path'; -import matter from 'gray-matter'; import { ToolDef } from '../../llm/openai-compat.js'; import { ToolContext, ToolResult } from './core.js'; import { loadConfig } from '../../config.js'; import { parseScript } from '../../user-folder/frontmatter.js'; -import { renderTemplate } from '../../user-folder/template-renderer.js'; import { userRoot, assertOwnerAccess, @@ -62,9 +64,6 @@ function getUserFolderRoot(): string { /** Regex for valid memory entry names: alphanumeric, dash, underscore; no extension. */ const MEMORY_NAME_RE = /^[a-zA-Z0-9_-]+$/; -/** Regex for valid template names: alphanumeric, dash, underscore, dot; no path separators. */ -const TEMPLATE_NAME_RE = /^[a-zA-Z0-9_.-]+$/; - export const TOOL_DEFS: Record = { UpdateUserMemory: { type: 'function', @@ -125,65 +124,19 @@ export const TOOL_DEFS: Record = { }, }, - ReadUserTemplate: { - type: 'function', - function: { - name: 'ReadUserTemplate', - description: - 'Loads a template file from the caller\'s templates/ subdir. ' + - 'Returns the raw body (frontmatter optional). Useful for boilerplate / report templates / canned snippets. ' + - 'Details via ReadToolDoc({ name: "ReadUserTemplate" }).', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Template file name (with or without .md extension).', - }, - }, - required: ['name'], - }, - }, - }, - - RenderUserTemplate: { - type: 'function', - function: { - name: 'RenderUserTemplate', - description: - 'Renders a template from templates/ by substituting {{var}} placeholders with caller-supplied params. ' + - 'Frontmatter params spec is validated and defaults are applied. Unknown placeholders are left literal. ' + - 'Details via ReadToolDoc({ name: "RenderUserTemplate" }).', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Template file name (with or without .md extension).', - }, - params: { - type: 'object', - description: 'Key-value params matching the template\'s frontmatter param spec.', - }, - }, - required: ['name'], - }, - }, - }, - ListUserAssets: { type: 'function', function: { name: 'ListUserAssets', description: - 'Lists user-authored scripts, browser-macros, templates, and recordings in the caller\'s folder. ' + + 'Lists user-authored browser-macros and recordings in the caller\'s folder. ' + 'Details via ReadToolDoc({ name: "ListUserAssets" }).', parameters: { type: 'object', properties: { kind: { type: 'string', - enum: ['scripts', 'browser-macros', 'templates', 'recordings', 'all'], + enum: ['browser-macros', 'recordings', 'all'], description: 'Which category to list. Default "all".', }, }, @@ -197,27 +150,20 @@ export const TOOL_DEFS: Record = { function: { name: 'RunUserScript', description: - 'Executes a user-authored script from the caller\'s user folder. ' + - 'Use kind="script" (default) for plain Node scripts in scripts/, ' + - 'or kind="browser-macro" for Playwright scripts in browser-macros/. ' + - 'Node only — NOT for Python: plain scripts run under Node --permission (no child_process), ' + - 'so a JS wrapper that shells out to python WILL fail; run Python directly with the Bash tool (pip pre-baked). ' + + 'Executes a user-authored Playwright browser-macro from the caller\'s browser-macros/ folder ' + + '(main({ context, params }) signature, optional session_profile_id frontmatter). ' + + 'For ad-hoc code (Node, Python, …) use the Bash tool instead. ' + 'Details via ReadToolDoc({ name: "RunUserScript" }).', parameters: { type: 'object', properties: { name: { type: 'string', - description: 'Script filename (with or without .js extension).', + description: 'Macro filename (with or without .js extension).', }, params: { type: 'object', - description: 'Key-value params matching the script\'s frontmatter param spec.', - }, - kind: { - type: 'string', - enum: ['script', 'browser-macro'], - description: '"script" (default): plain Node, scripts/ dir, main({ params }). "browser-macro": Playwright, browser-macros/ dir, main({ context, params }).', + description: 'Key-value params matching the macro\'s frontmatter param spec.', }, }, required: ['name'], @@ -230,22 +176,16 @@ export const TOOL_DEFS: Record = { function: { name: 'WriteUserScript', description: - 'ユーザーフォルダの scripts/ または browser-macros/ に script を作成・上書きする。' + - 'kind="script" は scripts/ (plain Node、main({ params }) 形式)、' + - 'kind="browser-macro" は browser-macros/ (Playwright、main({ context, params }) 形式)。' + - 'Node 専用 — Python 不可: python を呼ぶだけの JS ラッパーを書かないこと (plain は --permission で child_process 不可)。python は Bash ツールで直接実行する (pip は pre-baked)。' + + 'ユーザーフォルダの browser-macros/ に Playwright マクロを作成・上書きする ' + + '(main({ context, params }) 形式)。' + + 'アドホックなコード実行 (Node / Python 等) は Bash ツールを使うこと。' + '詳細は ReadToolDoc({ name: "WriteUserScript" })。', parameters: { type: 'object', properties: { name: { type: 'string', - description: 'ファイル名 (slug)。`.js` は自動補完される。例: "fetch-and-clean"', - }, - kind: { - type: 'string', - enum: ['script', 'browser-macro'], - description: '"script" (default、plain Node) または "browser-macro" (Playwright)', + description: 'ファイル名 (slug)。`.js` は自動補完される。例: "nightly-login-check"', }, content: { type: 'string', @@ -260,35 +200,6 @@ export const TOOL_DEFS: Record = { }, }, }, - - WriteUserTemplate: { - type: 'function', - function: { - name: 'WriteUserTemplate', - description: - 'ユーザーフォルダの templates/ にテンプレートを作成・上書きする。' + - '本文は Markdown、frontmatter で params 仕様を宣言可能 (ReadUserTemplate/RenderUserTemplate と互換)。' + - '詳細は ReadToolDoc({ name: "WriteUserTemplate" })。', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'ファイル名 (slug)。`.md` は自動補完される', - }, - content: { - type: 'string', - description: 'ファイル全文。frontmatter + 本文', - }, - overwrite: { - type: 'boolean', - description: '既存ファイルを上書きするかどうか (default: false)', - }, - }, - required: ['name', 'content'], - }, - }, - }, }; // ── UpdateUserMemory implementation ────────────────────────────────────────── @@ -398,156 +309,6 @@ async function executeReadUserMemory( return { output, isError: false }; } -// ── ReadUserTemplate implementation ────────────────────────────────────────── - -async function executeReadUserTemplate( - input: Record, - ctx: ToolContext, -): Promise { - if (!ctx.userId) { - return { output: 'ReadUserTemplate requires an authenticated user', isError: true }; - } - - const rawName = input['name']; - if (typeof rawName !== 'string' || !rawName.trim()) { - return { output: 'ReadUserTemplate: "name" parameter is required', isError: true }; - } - - // Strip .md suffix so callers can pass either form; re-add once - const baseName = rawName.replace(/\.md$/i, ''); - - if (baseName.length === 0 || baseName.length > 128) { - return { output: 'ReadUserTemplate: "name" must be 1–128 characters', isError: true }; - } - if (!TEMPLATE_NAME_RE.test(baseName)) { - return { - output: - 'ReadUserTemplate: "name" must contain only alphanumeric characters, dashes, underscores, or dots', - isError: true, - }; - } - - const folderRoot = getUserFolderRoot(); - let templatePath: string; - try { - templatePath = resolveUserSubdir(folderRoot, ctx.userId, 'templates', `${baseName}.md`); - } catch (err) { - return { output: `ReadUserTemplate: ${(err as Error).message}`, isError: true }; - } - - if (!existsSync(templatePath)) { - return { output: `ReadUserTemplate: template "${baseName}" not found`, isError: true }; - } - - let raw: string; - try { - raw = readFileSync(templatePath, 'utf-8'); - } catch (err) { - return { output: `ReadUserTemplate: failed to read template: ${(err as Error).message}`, isError: true }; - } - - // Best-effort frontmatter parse (not required for templates) - let body = raw; - const fmLines: string[] = []; - try { - const parsed = matter(raw); - const data = parsed.data as Record; - if (Object.keys(data).length > 0) { - for (const [k, v] of Object.entries(data)) { - fmLines.push(`${k}: ${JSON.stringify(v)}`); - } - } - // Normalize leading newline from gray-matter - body = parsed.content.startsWith('\n') ? parsed.content.slice(1) : parsed.content; - } catch { - // If parse fails, fall back to raw content as body - body = raw; - } - - const parts: string[] = [`# Template: ${baseName}`]; - if (fmLines.length > 0) { - parts.push(''); - parts.push('## Frontmatter'); - parts.push(...fmLines); - } - parts.push(''); - parts.push('## Body'); - parts.push(body.trim()); - - return { output: parts.join('\n'), isError: false }; -} - -// ── RenderUserTemplate implementation ─────────────────────────────────────── - -async function executeRenderUserTemplate( - input: Record, - ctx: ToolContext, -): Promise { - if (!ctx.userId) { - return { output: 'RenderUserTemplate requires an authenticated user', isError: true }; - } - - const rawName = input['name']; - if (typeof rawName !== 'string' || !rawName.trim()) { - return { output: 'RenderUserTemplate: "name" parameter is required', isError: true }; - } - - const baseName = rawName.replace(/\.md$/i, ''); - if (baseName.length === 0 || baseName.length > 128) { - return { output: 'RenderUserTemplate: "name" must be 1–128 characters', isError: true }; - } - if (!TEMPLATE_NAME_RE.test(baseName)) { - return { - output: - 'RenderUserTemplate: "name" must contain only alphanumeric characters, dashes, underscores, or dots', - isError: true, - }; - } - - const folderRoot = getUserFolderRoot(); - let templatePath: string; - try { - templatePath = resolveUserSubdir(folderRoot, ctx.userId, 'templates', `${baseName}.md`); - } catch (err) { - return { output: `RenderUserTemplate: ${(err as Error).message}`, isError: true }; - } - - if (!existsSync(templatePath)) { - return { output: `RenderUserTemplate: template "${baseName}" not found`, isError: true }; - } - - let raw: string; - try { - raw = readFileSync(templatePath, 'utf-8'); - } catch (err) { - return { - output: `RenderUserTemplate: failed to read template: ${(err as Error).message}`, - isError: true, - }; - } - - // Parse frontmatter via parseScript — shares the params schema with scripts. - // Templates without frontmatter render as a pass-through (no param substitution). - let parsed; - try { - parsed = parseScript(raw); - } catch (err) { - return { - output: `RenderUserTemplate: invalid frontmatter: ${(err as Error).message}`, - isError: true, - }; - } - - const rawParams = (input['params'] as Record | undefined) ?? {}; - - try { - const rendered = renderTemplate(parsed.body, parsed.frontmatter.params, rawParams); - return { output: rendered, isError: false }; - } catch (err) { - return { output: `RenderUserTemplate: ${(err as Error).message}`, isError: true }; - } -} - // ── ListUserAssets implementation ───────────────────────────────────────────── async function executeListUserAssets( @@ -565,39 +326,6 @@ async function executeListUserAssets( const lines: string[] = [`User folder for ${ctx.userId}:`]; - // Scripts (plain Node runtime) - if (kind === 'scripts' || kind === 'all') { - const scriptsDir = join(userDir, 'scripts'); - const scriptEntries: string[] = []; - - if (existsSync(scriptsDir)) { - const files = readdirSync(scriptsDir) - .filter((f) => extname(f) === '.js') - .sort(); - - for (const file of files) { - const scriptPath = join(scriptsDir, file); - try { - const source = readFileSync(scriptPath, 'utf-8'); - const parsed = parseScript(source); - const { description, params } = parsed.frontmatter; - const paramStr = params.map((p) => `${p.name}:${p.type}`).join(', '); - const entry = ` - ${file}: "${description}" — params: [${paramStr}]`; - scriptEntries.push(entry); - } catch (err) { - scriptEntries.push(` - ${file}: (parse error: ${(err as Error).message})`); - } - } - } - - lines.push(`Scripts (${scriptEntries.length}):`); - if (scriptEntries.length === 0) { - lines.push(' (none)'); - } else { - lines.push(...scriptEntries); - } - } - // Browser Macros (Playwright runtime) if (kind === 'browser-macros' || kind === 'all') { const macrosDir = join(userDir, 'browser-macros'); @@ -634,35 +362,6 @@ async function executeListUserAssets( } } - // Templates - if (kind === 'templates' || kind === 'all') { - const templatesDir = join(userDir, 'templates'); - const templateEntries: string[] = []; - - if (existsSync(templatesDir)) { - const files = readdirSync(templatesDir).sort(); - for (const file of files) { - try { - const stat = statSync(join(templatesDir, file)); - if (stat.isFile()) { - templateEntries.push( - ` - ${file} (${stat.size} bytes, ${stat.mtime.toISOString()})`, - ); - } - } catch { - // skip unreadable entries - } - } - } - - lines.push(`Templates (${templateEntries.length}):`); - if (templateEntries.length === 0) { - lines.push(' (none)'); - } else { - lines.push(...templateEntries); - } - } - // Recordings if (kind === 'recordings' || kind === 'all') { const recordingsDir = join(userDir, 'recordings'); @@ -710,8 +409,7 @@ async function executeRunUserScript( if (cfg.tools?.userScriptsEnabled !== true) { return { output: - 'User scripts are disabled (set tools.user_scripts_enabled: true in config.yaml). ' + - 'Note: plain-runtime scripts run under Node --permission, but browser-macros do not.', + 'User browser-macros are disabled (set tools.user_scripts_enabled: true in config.yaml).', isError: true, }; } @@ -740,11 +438,17 @@ async function executeRunUserScript( const params = (input['params'] as Record | undefined) ?? {}; - const rawKind = input['kind']; - const kind: 'script' | 'browser-macro' | undefined = - rawKind === 'browser-macro' ? 'browser-macro' : - rawKind === 'script' ? 'script' : - undefined; + // Plain-Node scripts were retired (2026-06): everything is a browser-macro. + // Reject an explicit kind="script" with a pointer to the replacement. + if (input['kind'] === 'script') { + return { + output: + 'RunUserScript: plain-Node scripts/ were retired. Run ad-hoc code with the Bash tool, ' + + 'or keep reusable procedures in Skills. This tool now runs browser-macros only.', + isError: true, + }; + } + const kind = 'browser-macro' as const; const scriptBaseName = (rawName.endsWith('.js') ? rawName : `${rawName}.js`).replace(/\.js$/, ''); @@ -824,7 +528,6 @@ async function executeWriteUserScript( const name = input['name']; const content = input['content']; - const kind = (input['kind'] as string | undefined) ?? 'script'; const overwrite = input['overwrite'] === true; if (typeof name !== 'string' || !name) { @@ -833,9 +536,16 @@ async function executeWriteUserScript( if (typeof content !== 'string') { return { output: 'WriteUserScript: "content" must be a string', isError: true }; } - if (kind !== 'script' && kind !== 'browser-macro') { - return { output: 'WriteUserScript: "kind" must be "script" or "browser-macro"', isError: true }; + // Plain-Node scripts were retired (2026-06): everything is a browser-macro. + if (input['kind'] === 'script') { + return { + output: + 'WriteUserScript: plain-Node scripts/ were retired. Keep reusable procedures in Skills ' + + 'and run ad-hoc code with the Bash tool. This tool now writes browser-macros only.', + isError: true, + }; } + const kind = 'browser-macro' as const; // Strip optional .js suffix; validate the base name as a slug const baseName = name.replace(/\.js$/i, ''); @@ -858,14 +568,14 @@ async function executeWriteUserScript( return { output: 'WriteUserScript: content must define a `main` function ' + - '(e.g. async function main({params}) {…} or module.exports = async function main(…)). ' + + '(e.g. async function main({context, params}) {…} or module.exports = async function main(…)). ' + 'See ReadToolDoc({ name: "WriteUserScript" }) for examples.', isError: true, }; } const userFolderRoot = getUserFolderRoot(); - const subdir = kind === 'script' ? 'scripts' : 'browser-macros'; + const subdir = 'browser-macros'; let targetPath: string; try { @@ -907,88 +617,6 @@ async function executeWriteUserScript( }; } -// ── WriteUserTemplate implementation ───────────────────────────────────────── - -/** Slug regex for templates: same rules as scripts but allow dots too (e.g. v2.0). */ -const TEMPLATE_SLUG_RE = /^[a-zA-Z0-9_-]+$/; - -async function executeWriteUserTemplate( - input: Record, - ctx: ToolContext, -): Promise { - if (!ctx.userId) { - return { output: 'WriteUserTemplate: requires an authenticated user', isError: true }; - } - - const name = input['name']; - const content = input['content']; - const overwrite = input['overwrite'] === true; - - if (typeof name !== 'string' || !name) { - return { output: 'WriteUserTemplate: "name" parameter is required', isError: true }; - } - if (typeof content !== 'string') { - return { output: 'WriteUserTemplate: "content" must be a string', isError: true }; - } - - // Strip optional .md suffix; validate as a slug - const baseName = name.replace(/\.md$/i, ''); - if (!TEMPLATE_SLUG_RE.test(baseName)) { - return { - output: - 'WriteUserTemplate: "name" must contain only alphanumeric characters, dashes, or underscores (no spaces, no slashes)', - isError: true, - }; - } - - // Size limit: 128 KB - const MAX_BYTES = 128 * 1024; - if (Buffer.byteLength(content, 'utf-8') > MAX_BYTES) { - return { output: `WriteUserTemplate: content exceeds ${MAX_BYTES} bytes`, isError: true }; - } - - const userFolderRoot = getUserFolderRoot(); - - let targetPath: string; - try { - targetPath = resolveUserSubdir(userFolderRoot, ctx.userId, 'templates', `${baseName}.md`); - } catch (err) { - return { output: `WriteUserTemplate: ${(err as Error).message}`, isError: true }; - } - - const targetDir = join(targetPath, '..'); - try { - mkdirSync(targetDir, { recursive: true }); - } catch (err) { - return { output: `WriteUserTemplate: failed to create templates/: ${(err as Error).message}`, isError: true }; - } - - if (existsSync(targetPath) && !overwrite) { - return { - output: `WriteUserTemplate: templates/${baseName}.md already exists. Pass overwrite: true to replace.`, - isError: true, - }; - } - - // Atomic write - const tmpPath = `${targetPath}.tmp.${Date.now()}`; - try { - writeFileSync(tmpPath, content, { encoding: 'utf-8', mode: 0o600 }); - renameSync(tmpPath, targetPath); - } catch (err) { - try { unlinkSync(tmpPath); } catch { /* ignore */ } - return { output: `WriteUserTemplate: write failed: ${(err as Error).message}`, isError: true }; - } - - const bytes = Buffer.byteLength(content, 'utf-8'); - _deps?.auditLog?.('user_template_written', { userId: ctx.userId, filename: `${baseName}.md`, bytes }, ctx.taskId ?? null); - - return { - output: `WriteUserTemplate: wrote templates/${baseName}.md (${bytes} bytes)`, - isError: false, - }; -} - // ── Dispatch ────────────────────────────────────────────────────────────────── export async function executeTool( @@ -998,11 +626,8 @@ export async function executeTool( ): Promise { if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx); if (name === 'ReadUserMemory') return executeReadUserMemory(input, ctx); - if (name === 'ReadUserTemplate') return executeReadUserTemplate(input, ctx); - if (name === 'RenderUserTemplate') return executeRenderUserTemplate(input, ctx); if (name === 'ListUserAssets') return executeListUserAssets(input, ctx); if (name === 'RunUserScript') return executeRunUserScript(input, ctx); if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx); - if (name === 'WriteUserTemplate') return executeWriteUserTemplate(input, ctx); return null; } diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index 72165eb..ccf83a6 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -75,8 +75,8 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // mission.ts 'MissionUpdate', // user-folder.ts - 'ListUserAssets', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate', - 'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', 'WriteUserTemplate', + 'ListUserAssets', 'ReadUserMemory', + 'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', // brainstorm.ts 'Brainstorm', // app-docs.ts diff --git a/src/scheduler.test.ts b/src/scheduler.test.ts index 21092c1..df6b9d2 100644 --- a/src/scheduler.test.ts +++ b/src/scheduler.test.ts @@ -363,12 +363,12 @@ describe('Scheduler.executeScheduledTask: task_kind="script"', () => { }); function writeScript(userId: string, name: string, source: string): void { - const dir = join(userFolderRoot, userId, 'scripts'); + const dir = join(userFolderRoot, userId, 'browser-macros'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, name), source, 'utf-8'); } - it('runs a plain script and marks the job succeeded with stdout saved to workspace', async () => { + it('runs a browser-macro and marks the job succeeded with stdout saved to workspace', async () => { const owner = repo.createUser({ email: 'eve@x.com', name: 'eve', role: 'user', status: 'active' }); writeScript(owner.id, 'hello.js', `--- params: @@ -473,7 +473,7 @@ module.exports = main; // (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask // used to throw "requires an owner_id", so no-auth scheduled scripts could // never run. Now it falls back to the 'local' namespace (same as the - // RunUserScript tool), resolving the script from data/users/local/scripts/. + // RunUserScript tool), resolving the macro from data/users/local/browser-macros/. writeScript('local', 'noauth.js', `async function main() { return { ran: 'no-auth-local' }; } diff --git a/src/scheduler.ts b/src/scheduler.ts index ad46b55..f7bdc9a 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -303,7 +303,7 @@ export class Scheduler { throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`); } // No-auth mode stores scheduled tasks with ownerId=null (scheduled-tasks-api - // has no authenticated user). Scripts are per-user (data/users/{id}/scripts/), + // has no authenticated user). Macros are per-user (data/users/{id}/browser-macros/), // so resolve to the same 'local' namespace the RunUserScript tool uses in // no-auth (ctx.userId='local'). In auth mode item.ownerId is always set, so // scriptOwner === item.ownerId and behaviour is unchanged. diff --git a/src/user-folder/paths.test.ts b/src/user-folder/paths.test.ts index c156ade..926e516 100644 --- a/src/user-folder/paths.test.ts +++ b/src/user-folder/paths.test.ts @@ -11,7 +11,7 @@ describe('user-folder/paths', () => { it('creates the standard subdirs on first ensure', () => { ensureUserFolder(root, 'user-abc'); - for (const sub of ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets']) { + for (const sub of ['browser-macros', 'recordings', 'trash', 'memory', 'pets', 'notes']) { expect(existsSync(join(root, 'user-abc', sub))).toBe(true); } }); @@ -35,17 +35,17 @@ describe('user-folder/paths', () => { }); it('resolves subdir paths under the owner root', () => { - const p = resolveUserSubdir(root, 'user-abc', 'scripts', 'foo.js'); - expect(p).toBe(join(root, 'user-abc', 'scripts', 'foo.js')); + const p = resolveUserSubdir(root, 'user-abc', 'browser-macros', 'foo.js'); + expect(p).toBe(join(root, 'user-abc', 'browser-macros', 'foo.js')); }); it('rejects path traversal in the relative segment', () => { - expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', '../../etc/passwd')) + expect(() => resolveUserSubdir(root, 'user-abc', 'browser-macros', '../../etc/passwd')) .toThrow(/outside owner folder/); }); it('rejects empty relPath', () => { - expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', '')) + expect(() => resolveUserSubdir(root, 'user-abc', 'browser-macros', '')) .toThrow(/relPath must not be empty/); }); diff --git a/src/user-folder/paths.ts b/src/user-folder/paths.ts index a88b9f6..c744f49 100644 --- a/src/user-folder/paths.ts +++ b/src/user-folder/paths.ts @@ -6,7 +6,10 @@ export const LOCAL_SYSTEM_OWNER_ID = 'local'; const USER_AGENTS_MAX_BYTES = 64 * 1024; -export const USER_SUBDIRS = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const; +// 'scripts' and 'templates' were retired in 2026-06 (superseded by Skills + +// the Bash tool). Existing files stay on disk but are no longer created, +// listed, or resolvable through the user-folder API / tools. +export const USER_SUBDIRS = ['browser-macros', 'recordings', 'trash', 'memory', 'pets', 'notes'] as const; export type UserSubdir = typeof USER_SUBDIRS[number]; export function userRoot(rootDir: string, ownerId: string): string { diff --git a/src/user-folder/script-orchestrator.ts b/src/user-folder/script-orchestrator.ts index 1a67cdf..8f4f6e8 100644 --- a/src/user-folder/script-orchestrator.ts +++ b/src/user-folder/script-orchestrator.ts @@ -1,17 +1,21 @@ /** * script-orchestrator.ts * - * Shared "resolve a user script by name + run it" helper. Used by both: + * Shared "resolve a browser-macro by name + run it" helper. Used by both: * - The LLM-facing RunUserScript tool (engine/tools/user-folder.ts). - * - The scheduler's script kind (scheduler.ts), so a periodic script run + * - The scheduler's script kind (scheduler.ts), so a periodic macro run * does not need to spin up an LLM agent loop. * + * Note: plain-Node `scripts/` and `templates/` were retired in 2026-06 + * (superseded by Skills + the Bash tool). The only user-script runtime left + * is Playwright browser-macros. + * * Responsibilities: - * - Path resolution under data/users/{userId}/{scripts,browser-macros}/ with + * - Path resolution under data/users/{userId}/browser-macros/ with * traversal protection (delegated to resolveUserSubdir). - * - Frontmatter parsing for browser-macros to find session_profile_id. + * - Frontmatter parsing to find session_profile_id. * - Decrypting + loading Playwright storageState for the owning user. - * - Calling runUserScript() with the right runtime. + * - Calling runUserScript() with the playwright runtime. * * Intentionally NOT responsible for: * - Config gating (tools.user_scripts_enabled) — callers decide. @@ -27,9 +31,9 @@ import { runUserScript } from './script-runner.js'; import { loadSessionStateForUser } from './session-loader.js'; import type { BrowserSessionRepo } from '../db/browser-session-repo.js'; -export type ScriptKind = 'script' | 'browser-macro'; -export type ScriptSubdir = 'scripts' | 'browser-macros'; -export type ScriptRuntime = 'plain' | 'playwright'; +export type ScriptKind = 'browser-macro'; +export type ScriptSubdir = 'browser-macros'; +export type ScriptRuntime = 'playwright'; export interface ResolveScriptResult { scriptPath: string; @@ -37,49 +41,31 @@ export interface ResolveScriptResult { runtime: ScriptRuntime; } -/** - * Resolve a script name to its on-disk path. When kind is omitted, scripts/ - * is searched first, then browser-macros/. - */ +/** Resolve a macro name to its on-disk path under browser-macros/. */ export function resolveScriptForKind( rootDir: string, userId: string, scriptName: string, - kind: ScriptKind | undefined, + _kind?: ScriptKind, ): ResolveScriptResult | { error: string } { - const tryOne = (sd: ScriptSubdir): string | null => { - try { - const p = resolveUserSubdir(rootDir, userId, sd, scriptName); - return existsSync(p) ? p : null; - } catch { - return null; - } - }; - - if (kind === 'script') { - const p = tryOne('scripts'); - if (!p) return { error: `script not found: scripts/${basename(scriptName)}` }; - return { scriptPath: p, subdir: 'scripts', runtime: 'plain' }; + let p: string | null; + try { + const full = resolveUserSubdir(rootDir, userId, 'browser-macros', scriptName); + p = existsSync(full) ? full : null; + } catch { + p = null; } - if (kind === 'browser-macro') { - const p = tryOne('browser-macros'); - if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` }; - return { scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' }; - } - const sp = tryOne('scripts'); - if (sp) return { scriptPath: sp, subdir: 'scripts', runtime: 'plain' }; - const bp = tryOne('browser-macros'); - if (bp) return { scriptPath: bp, subdir: 'browser-macros', runtime: 'playwright' }; - return { error: `script not found: ${scriptName} (searched scripts/ and browser-macros/)` }; + if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` }; + return { scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' }; } export interface ResolveAndRunOptions { rootDir: string; userId: string; - /** Script name with or without `.js` extension. */ + /** Macro name with or without `.js` extension. */ name: string; params: Record; - /** If omitted, scripts/ is tried first, then browser-macros/. */ + /** Kept for call-site compatibility; the only kind is 'browser-macro'. */ kind?: ScriptKind; /** Required only when the resolved script is a browser-macro that declares session_profile_id. */ sessRepo?: BrowserSessionRepo; diff --git a/src/user-folder/template-renderer.ts b/src/user-folder/template-renderer.ts deleted file mode 100644 index 055eca5..0000000 --- a/src/user-folder/template-renderer.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * template-renderer.ts - * - * Simple {{var}} substitution for user templates. Intentionally minimal: - * - No conditionals, no loops, no helpers. If those become needed, - * graduate to Handlebars in a follow-up. - * - Unknown placeholders (var not declared in frontmatter.params) are - * left literal — so README-style templates with prose like "use {{x}}" - * don't blow up when there's no x param. - * - * Param semantics (type-check + defaults) are shared with scripts via - * validateAndApplyDefaults from script-runner.ts; templates use the same - * frontmatter.params schema as scripts/browser-macros. - */ - -import type { ParamSpec } from './frontmatter.js'; -import { validateAndApplyDefaults } from './script-runner.js'; - -/** - * Replaces {{name}} with the corresponding param value, but only for params - * that appear in `declared` (the validated set). Unknown {{xxx}} stays literal. - */ -export function renderTemplate( - body: string, - paramSpec: ParamSpec[], - rawParams: Record, -): string { - const resolved = validateAndApplyDefaults(paramSpec, rawParams); - return body.replace(/\{\{(\w+)\}\}/g, (match, name) => - Object.prototype.hasOwnProperty.call(resolved, name) ? String(resolved[name]) : match, - ); -} diff --git a/ui/src/components/settings/ToolsExternalForm.tsx b/ui/src/components/settings/ToolsExternalForm.tsx index cf14ea1..dc4ffa1 100644 --- a/ui/src/components/settings/ToolsExternalForm.tsx +++ b/ui/src/components/settings/ToolsExternalForm.tsx @@ -144,7 +144,7 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) { checked={tools.userScriptsEnabled === true} onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)} /> - 有効 (LLM の RunUserScript + scheduled script task が動作) + 有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作) plain runtime は Node --permission で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。 @@ -160,7 +160,7 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) { placeholder="user id (例: 12345)" /> - 未指定なら user_scripts_enabled のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。 + 未指定なら user_scripts_enabled のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。 diff --git a/ui/src/components/settings/ToolsForm.tsx b/ui/src/components/settings/ToolsForm.tsx index 0d50fc7..aaaa946 100644 --- a/ui/src/components/settings/ToolsForm.tsx +++ b/ui/src/components/settings/ToolsForm.tsx @@ -270,7 +270,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) { checked={tools.userScriptsEnabled === true} onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)} /> - 有効 (LLM の RunUserScript + scheduled script task が動作) + 有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作) plain runtime は Node --permission で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。 @@ -286,7 +286,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) { placeholder="user id (例: 12345)" /> - 未指定なら user_scripts_enabled のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。 + 未指定なら user_scripts_enabled のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。
diff --git a/ui/src/components/userfolder/FileTree.tsx b/ui/src/components/userfolder/FileTree.tsx index 28a79dc..d6083ae 100644 --- a/ui/src/components/userfolder/FileTree.tsx +++ b/ui/src/components/userfolder/FileTree.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; // 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories) -export type SubdirId = 'agents-md' | 'scripts' | 'browser-macros' | 'templates' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes'; +// ('scripts' / 'templates' were retired 2026-06 — superseded by Skills + the Bash tool) +export type SubdirId = 'agents-md' | 'browser-macros' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes'; /** True for subdirs that have actual files on disk */ -export const FILE_SUBDIRS: SubdirId[] = ['scripts', 'browser-macros', 'templates', 'recordings', 'trash', 'memory', 'notes']; +export const FILE_SUBDIRS: SubdirId[] = ['browser-macros', 'recordings', 'trash', 'memory', 'notes']; export interface FileEntry { name: string; @@ -29,9 +30,7 @@ interface FileTreeProps { const SUBDIR_LABELS: Record = { 'agents-md': 'AGENTS.md', - scripts: 'scripts', 'browser-macros': 'browser-macros', - templates: 'templates', recordings: 'recordings', trash: 'trash', memory: 'memory', @@ -46,9 +45,7 @@ const SUBDIR_LABELS: Record = { const SUBDIR_ICONS: Record = { 'agents-md': '📖', - scripts: '📜', 'browser-macros': '🤖', - templates: '📄', recordings: '🎬', trash: '🗑', memory: '🧠', diff --git a/ui/src/components/userfolder/NewFileForm.tsx b/ui/src/components/userfolder/NewFileForm.tsx index 201da3e..c42b4e1 100644 --- a/ui/src/components/userfolder/NewFileForm.tsx +++ b/ui/src/components/userfolder/NewFileForm.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -type WritableSubdir = 'scripts' | 'browser-macros' | 'templates'; +type WritableSubdir = 'browser-macros'; interface NewFileFormProps { subdir: WritableSubdir; @@ -11,23 +11,6 @@ interface NewFileFormProps { const TODAY = new Date().toISOString().slice(0, 10); const SKELETON: Record = { - scripts: { - ext: '.js', - body: `--- -description: -params: - # name: { type: string, required: true, description: "..." } ---- - -/** - * Generated ${TODAY} via User Folder UI. - */ -export async function main({ params }) { - console.log('script start', params); - return { ok: true }; -} -`, - }, 'browser-macros': { ext: '.js', body: `--- @@ -45,27 +28,12 @@ export async function main({ context, params }) { // await page.goto(params.url); return { ok: true }; } -`, - }, - templates: { - ext: '.md', - body: `--- -description: -params: - # title: { type: string, required: true } ---- - -# {{title}} - -Content here. `, }, }; const SUBDIR_LABEL: Record = { - scripts: 'スクリプト', 'browser-macros': 'ブラウザマクロ', - templates: 'テンプレート', }; export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) { diff --git a/ui/src/components/userfolder/UserFolderTab.tsx b/ui/src/components/userfolder/UserFolderTab.tsx index a58ab06..1745534 100644 --- a/ui/src/components/userfolder/UserFolderTab.tsx +++ b/ui/src/components/userfolder/UserFolderTab.tsx @@ -14,7 +14,7 @@ import { NotesPanel } from './NotesPanel'; import { SubscriptionsPanel } from './SubscriptionsPanel'; import { SkillsPanel } from './SkillsPanel'; /** All subdirs shown in the tree — both real file-based and virtual. */ -const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'scripts', 'browser-macros', 'templates', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory']; +const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'browser-macros', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory']; const SUBDIR_INFO: { id: SubdirId; icon: string; title: string; desc: string; agency: string }[] = [ { @@ -24,27 +24,13 @@ const SUBDIR_INFO: { id: SubdirId; icon: string; title: string; desc: string; ag desc: 'タスク起動時に system prompt へ自動注入される、ユーザー専用の永続的な指示。 「常に丁寧な日本語で答える」「Tailwind を優先」等、毎タスクで覚えて欲しい好み・ルールを書く。 最大 64KB。 ファイル形式は markdown。', agency: 'ユーザー編集 / エージェントが自動参照', }, - { - id: 'scripts', - icon: '📜', - title: 'scripts/', - desc: 'AI 生成の汎用 Node スクリプト。エージェントが RunUserScript ツールで実行 (kind: "script")。Chromium 起動なし、main({ params }) シグネチャ。データ整形・API 呼び出し・計算・ファイル変換等の繰り返し処理に向く。', - agency: 'エージェント/ユーザー両方 / 軽量・高速', - }, { id: 'browser-macros', icon: '🤖', title: 'browser-macros/', - desc: 'Playwright ベースのブラウザマクロ。recordings/ から "Save as Script" で生成、または UI で手書き。RunUserScript ツール (kind: "browser-macro") で実行。main({ context, params }) シグネチャで context は Playwright BrowserContext。session_profile_id で保存済みログインを利用可能。.next.js は self-healing 失敗時の自動パッチ候補で、Diff レビュー後に accept/reject。', + desc: 'Playwright ベースのブラウザマクロ。recordings/ から "Save as Script" で生成、または UI で手書き。RunUserScript ツールで実行。main({ context, params }) シグネチャで context は Playwright BrowserContext。session_profile_id で保存済みログインを利用可能。.next.js は self-healing 失敗時の自動パッチ候補で、Diff レビュー後に accept/reject。雛形・手順書は Skills へ、アドホックなコード実行は Bash ツールへ。', agency: 'エージェント実行 / UI で recordings → スクリプト化', }, - { - id: 'templates', - icon: '📄', - title: 'templates/', - desc: '定型文・雛形の置き場。UI で作成・編集する。エージェントが ReadUserTemplate で本文を読むか、RenderUserTemplate で frontmatter.params の {{var}} を埋めた結果を取得できる。報告書の雛形・メール文面・コードボイラープレート等を貯めておくと、繰り返しタスクで「雛形を埋めて」と指示しやすい。', - agency: 'ユーザー作成 / エージェントが ReadUserTemplate / RenderUserTemplate で利用', - }, { id: 'recordings', icon: '🎬', @@ -186,7 +172,7 @@ async function apiFolderDelete(subdir: SubdirId, path: string): Promise { const VIRTUAL_SUBDIRS = new Set(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']); /** Subdirs where users can create new files from the UI */ -const WRITABLE_USER_SUBDIRS = new Set(['scripts', 'browser-macros', 'templates']); +const WRITABLE_USER_SUBDIRS = new Set(['browser-macros']); type ShowToast = (message: string, variant?: 'success' | 'error') => void; @@ -195,7 +181,7 @@ interface UserFolderTabProps { } export function UserFolderTab({ showToast }: UserFolderTabProps = {}) { - const [selectedSubdir, setSelectedSubdir] = useState('scripts'); + const [selectedSubdir, setSelectedSubdir] = useState('browser-macros'); const [selectedFile, setSelectedFile] = useState(null); const [editorDirty, setEditorDirty] = useState(false); const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false); @@ -475,7 +461,7 @@ export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
)} f.name)} onCreate={async (filename, skeleton) => { await apiFolderPut(selectedSubdir, filename, skeleton); diff --git a/ui/src/content/help/09-userfolder.md b/ui/src/content/help/09-userfolder.md index 9b13792..acb3deb 100644 --- a/ui/src/content/help/09-userfolder.md +++ b/ui/src/content/help/09-userfolder.md @@ -3,12 +3,12 @@ id: userfolder title: User Folder(自分の資産) category: advanced order: 90 -keywords: [User Folder, AGENTS.md, notes, scripts, browser-macros, templates, browser-sessions] +keywords: [User Folder, AGENTS.md, notes, browser-macros, browser-sessions, recordings] --- # User Folder(自分の資産) -User Folder は、ユーザーごとに永続化される個人の資産置き場です。エージェントへの恒久指示、共有メモ、自作スクリプト、ブラウザ自動化、ログイン済みセッションなどがここに集まり、タスクをまたいで「あなた仕様」のエージェントを作り込めます。 +User Folder は、ユーザーごとに永続化される個人の資産置き場です。エージェントへの恒久指示、共有メモ、ブラウザ自動化、ログイン済みセッションなどがここに集まり、タスクをまたいで「あなた仕様」のエージェントを作り込めます。 TopBar → **ユーザーフォルダ** タブで開きます。左にサブフォルダのツリー、右にファイルエディタ(または専用パネル)という 2 カラム構成です。 @@ -18,9 +18,7 @@ TopBar → **ユーザーフォルダ** タブで開きます。左にサブフ |---|---|---| | AGENTS.md | タスク起動時に system prompt へ注入される恒久指示 | ユーザー | | notes/ | 共有可能な Markdown メモ(エージェントが検索・参照) | ユーザー | -| scripts/ | 汎用 Node スクリプト(RunUserScript で実行) | ユーザー / エージェント | | browser-macros/ | Playwright ブラウザマクロ | ユーザー / エージェント | -| templates/ | 定型文・雛形(`{{var}}` プレースホルダ) | ユーザー | | recordings/ | BrowseWeb の操作トレース(JSON) | エージェント | | pets/ | Chat 画面に表示するキャラクター | ユーザー | | browser-sessions/ | 保存済みログインプロファイル(cookie / storage) | ユーザー | @@ -55,28 +53,13 @@ TopBar → **ユーザーフォルダ** タブで開きます。左にサブフ 他のエージェントや他ユーザーと共有したい情報を Markdown で書く場所です。visibility(公開範囲)を設定でき、エージェントは `SearchNotes` / `ReadNote` / `WriteNote` でアクセスします。notes はフォルダ階層を持てます(`notes//.md`)。 -## scripts/(汎用 Node) - -繰り返し処理を Node スクリプトとして保存します。エージェントは `RunUserScript`(kind: script)で実行します。Chromium は起動せず、`main({ params })` シグネチャです。データ整形・API 呼び出し・計算・ファイル変換などに向きます。 - -作成方法: - -1. ユーザーフォルダ → **scripts** → 新規ファイルフォーム -2. ファイル名(`.js`)と内容を入力 → 作成 - -「○○するスクリプトを作って」とエージェントに頼むと自動生成されることもあります。 - ## browser-macros/(Playwright) -ログイン済みブラウザを使った Web 操作を自動化します。`RunUserScript`(kind: browser-macro)で実行され、`main({ context, params })` の `context` は Playwright の BrowserContext です。`session_profile_id` を指定すると保存済みログイン(browser-sessions/)を復元してから実行します。 +ログイン済みブラウザを使った Web 操作を自動化します。`RunUserScript` で実行され、`main({ context, params })` の `context` は Playwright の BrowserContext です。`session_profile_id` を指定すると保存済みログイン(browser-sessions/)を復元してから実行します。 recordings/ の操作トレースから「Save as Script」でマクロ化することもできます。 -> scripts/ は外部 API を呼ぶ汎用処理、browser-macros/ は Web UI を操作する処理、と使い分けます。混ぜないこと。 - -## templates/(定型文・雛形) - -`{{var}}` プレースホルダ付きの雛形です。エージェントは `ReadUserTemplate` で本文を読むか、`RenderUserTemplate` で frontmatter の `params` を埋めた結果を取得します。週次レポートや議事録など、定型フォーマットがあるタスクで効きます。 +> 以前あった scripts/(汎用 Node)と templates/(雛形)は廃止されました。雛形・手順書は **Skills** に、アドホックなコード実行はエージェントの **Bash** ツールに統一されています。 ## browser-sessions/ @@ -100,6 +83,6 @@ CAPTCHA / 2FA を越えて取得した cookie / storage を user-scoped に暗 ## ファイルの作成・編集 -- 作成できるのは **scripts / browser-macros / templates** の 3 つ(左ツリーで選択 → 新規ファイルフォーム) +- 作成できるのは **browser-macros**(左ツリーで選択 → 新規ファイルフォーム) - 既存ファイルはツリーから選んでエディタで編集 → 保存 - AGENTS.md・browser-sessions・mcp・skills・pets・ssh-connections・Subscribed Notes は専用パネルで操作します diff --git a/ui/src/content/help/16-tools.md b/ui/src/content/help/16-tools.md index 090d386..daf1c54 100644 --- a/ui/src/content/help/16-tools.md +++ b/ui/src/content/help/16-tools.md @@ -30,7 +30,7 @@ movement の開始時には、その movement で使えるツールの一覧と | レビュー | LLM による一括レビュー | BatchReviewTextWithLLM | | スライド | PPTX スライド生成 | AddSlide / BuildPptx / SetTheme | | チェックリスト | タスク内の進捗チェックリスト | CreateChecklist / CheckItem | -| ノート / テンプレート | 共有ノート・ユーザーテンプレート | SearchNotes / ReadNote / RenderUserTemplate | +| ノート | 共有ノートの検索・読み書き | SearchNotes / ReadNote / WriteNote | | SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend | | オーケストレーション | サブタスクの生成 | SpawnSubTask | | 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |