sync: update from private repo (ce93095)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
eb35e32f7a
commit
9f8958c4a2
@ -160,7 +160,7 @@ llm:
|
|||||||
storage:
|
storage:
|
||||||
worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース
|
worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース
|
||||||
# custom_pieces_dir: ./custom-pieces # リポジトリ内の pieces/ に加えて読みに行く Piece dir (任意)
|
# 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)
|
task_upload_max_size_mb: 50 # /api/local/tasks と /comments の body 上限 (MB)
|
||||||
# base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000]
|
# base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000]
|
||||||
trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期)
|
trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期)
|
||||||
@ -285,8 +285,8 @@ tools:
|
|||||||
# amazon_affiliate_tag: "your-tag-22"
|
# amazon_affiliate_tag: "your-tag-22"
|
||||||
# keepa_api_key: "..."
|
# keepa_api_key: "..."
|
||||||
|
|
||||||
# User scripts (RunUserScript)
|
# User browser-macros (RunUserScript)
|
||||||
# user_scripts_enabled: false # true で許可。plain runtime は Node --permission で sandbox 化
|
# user_scripts_enabled: false # true で browser-macros の実行を許可
|
||||||
# user_scripts_allow_userids: # 未指定 = 全ユーザー許可 (user_scripts_enabled に従う)
|
# user_scripts_allow_userids: # 未指定 = 全ユーザー許可 (user_scripts_enabled に従う)
|
||||||
# - alice-id
|
# - alice-id
|
||||||
# - bob-id
|
# - bob-id
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -1,27 +1,29 @@
|
|||||||
# RunUserScript
|
# 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 |
|
| directory | runtime | signature | use case |
|
||||||
|------|-----------|---------|-----------|----------|
|
|-----------|---------|-----------|----------|
|
||||||
| `'script'` (default) | `scripts/` | plain Node.js — no Chromium | `main({ params })` | Data processing, API calls, computation, file conversion |
|
| `browser-macros/` | Playwright — Chromium | `main({ context, params })` | Web automation with a live browser session |
|
||||||
| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` | Web automation with a live browser session |
|
|
||||||
|
|
||||||
## Input
|
## Input
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
name: string, // filename — '.js' is appended if absent
|
name: string, // filename — '.js' is appended if absent
|
||||||
params?: Record<string, unknown>, // runtime values matching the script's param spec
|
params?: Record<string, unknown>, // runtime values matching the macro's param spec
|
||||||
kind?: 'script' | 'browser-macro' // default: 'script'
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Param validation
|
## 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"
|
- Extra params not listed in the spec → error containing "param"
|
||||||
- Wrong type for a declared param → error containing "param"
|
- Wrong type for a declared param → error containing "param"
|
||||||
- Missing required param (no default) → 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.
|
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: <N>`, the tool:
|
If the macro's frontmatter declares `session_profile_id: <N>`, the tool:
|
||||||
|
|
||||||
1. Loads the profile from the DB (owner-gated — must belong to `ctx.userId`).
|
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.
|
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: <N>`, th
|
|||||||
|
|
||||||
If any step fails the tool returns `isError: true` with a descriptive message.
|
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 macro fails at runtime, the tool automatically enables the BrowseWeb
|
||||||
|
recorder for the current task (if not already enabled). On task completion,
|
||||||
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.
|
`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.
|
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
@ -58,14 +59,9 @@ On success:
|
|||||||
<console.log lines from the child process>
|
<console.log lines from the child process>
|
||||||
```
|
```
|
||||||
|
|
||||||
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):
|
On failure:
|
||||||
```
|
|
||||||
RunUserScript "{name}" failed: <error message>
|
|
||||||
```
|
|
||||||
|
|
||||||
On failure (browser-macro):
|
|
||||||
```
|
```
|
||||||
RunUserScript "{name}" failed: <error message>
|
RunUserScript "{name}" failed: <error message>
|
||||||
|
|
||||||
@ -78,36 +74,22 @@ On task complete, a candidate patch will be saved as browser-macros/{name}.next.
|
|||||||
| Situation | `isError` | message contains |
|
| Situation | `isError` | message contains |
|
||||||
|-----------|-----------|-----------------|
|
|-----------|-----------|-----------------|
|
||||||
| No authenticated user | true | "authenticated" |
|
| 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" |
|
| Frontmatter parse error | true | "frontmatter" |
|
||||||
| Param type / missing error | true | "param" |
|
| Param type / missing error | true | "param" |
|
||||||
| Session profile not found / not owned | true | "not found or does not belong" |
|
| Session profile not found / not owned | true | "not found or does not belong" |
|
||||||
| Profile not active | true | "not active" |
|
| Profile not active | true | "not active" |
|
||||||
| DEK / blob decryption failure | true | "decrypt" |
|
| DEK / blob decryption failure | true | "decrypt" |
|
||||||
| Script timeout (60 s) | true | "timeout" |
|
| Macro timeout (60 s) | true | "timeout" |
|
||||||
| Script exits non-zero | true | "exited code" |
|
| Macro exits non-zero | true | "exited code" |
|
||||||
| Plain script denied child_process (e.g. spawning python) | true | "exited code" + "use the Bash tool" |
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The tool is a META_TOOL — it is available in every movement without listing it in `allowed_tools`.
|
- 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 macros and their param specs.
|
||||||
- Use `ListUserAssets` first to discover available scripts and their param specs.
|
- On macro failure, use `BrowseWeb` as a manual fallback.
|
||||||
- On browser-macro failure, use `BrowseWeb` as a manual fallback.
|
- To run Python or other ad-hoc code, use the **Bash** tool (pip packages pre-baked).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Security and trust model
|
## Security and trust model
|
||||||
|
|
||||||
@ -118,13 +100,13 @@ tools:
|
|||||||
user_scripts_enabled: true
|
user_scripts_enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Only enable for trusted users.** User scripts run in a restricted child process:
|
**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 script.
|
- 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.
|
- 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.
|
- 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:**
|
Browser-macros cannot use Node's `--permission` model — Chromium launch, native
|
||||||
|
bindings, and outbound HTTPS all need unrestricted `child_process`/addons/network.
|
||||||
- **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.
|
They run with full Node.js capability (env-scrubbed only) and rely on
|
||||||
- **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.
|
container-level isolation. Treat them as trusted code.
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
# WriteUserScript
|
# 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 |
|
| directory | runtime | signature |
|
||||||
|------|-----------|---------|-----------|
|
|-----------|---------|-----------|
|
||||||
| `'script'` (default) | `scripts/` | plain Node.js | `main({ params })` |
|
| `browser-macros/` | Playwright — Chromium | `main({ context, params })` |
|
||||||
| `'browser-macro'` | `browser-macros/` | Playwright — Chromium | `main({ context, params })` |
|
|
||||||
|
|
||||||
## Input
|
## Input
|
||||||
|
|
||||||
@ -15,7 +18,6 @@ Two destinations are supported:
|
|||||||
{
|
{
|
||||||
name: string, // slug — '.js' appended if absent
|
name: string, // slug — '.js' appended if absent
|
||||||
content: string, // full file text (frontmatter + main())
|
content: string, // full file text (frontmatter + main())
|
||||||
kind?: 'script' | 'browser-macro', // default: 'script'
|
|
||||||
overwrite?: boolean // default: false — error if file exists
|
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
|
```js
|
||||||
// ES function declaration
|
// ES function declaration
|
||||||
async function main({ params }) { … }
|
async function main({ context, params }) { … }
|
||||||
|
|
||||||
// Arrow / assigned function
|
// Arrow / assigned function
|
||||||
const main = async ({ params }) => { … };
|
const main = async ({ context, params }) => { … };
|
||||||
|
|
||||||
// CommonJS export
|
// CommonJS export
|
||||||
module.exports = async function main({ params }) { … };
|
module.exports = async function main({ context, params }) { … };
|
||||||
exports.main = async function({ params }) { … };
|
exports.main = async function({ context, params }) { … };
|
||||||
```
|
```
|
||||||
|
|
||||||
If none of these patterns is found the tool returns `isError: true` with a
|
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.
|
frontmatter still run, but param validation is skipped.
|
||||||
|
|
||||||
Browser macros may additionally declare `session_profile_id: <N>` to auto-load
|
Macros may additionally declare `session_profile_id: <N>` to auto-load
|
||||||
a saved login session (see `RunUserScript` docs).
|
a saved login session (see `RunUserScript` docs).
|
||||||
|
|
||||||
## Size limit
|
## Size limit
|
||||||
@ -70,41 +72,15 @@ Pass `overwrite: true` to replace the existing file atomically.
|
|||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- You discovered a useful reusable pattern during a task — save it for next time.
|
- You discovered a useful browser-automation 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`.
|
- 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.
|
- You want to prototype a browser automation without going through the UI.
|
||||||
|
|
||||||
## Examples
|
## Example
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
WriteUserScript({
|
WriteUserScript({
|
||||||
name: "screenshot-dashboard",
|
name: "screenshot-dashboard",
|
||||||
kind: "browser-macro",
|
|
||||||
content: `---
|
content: `---
|
||||||
description: Take a screenshot of the dashboard
|
description: Take a screenshot of the dashboard
|
||||||
params:
|
params:
|
||||||
@ -126,6 +102,7 @@ async function main({ context, params }) {
|
|||||||
| Situation | `isError` | message contains |
|
| Situation | `isError` | message contains |
|
||||||
|-----------|-----------|-----------------|
|
|-----------|-----------|-----------------|
|
||||||
| No authenticated user | true | "authenticated" |
|
| No authenticated user | true | "authenticated" |
|
||||||
|
| Retired `kind: 'script'` passed | true | "retired" |
|
||||||
| `name` missing / empty | true | `"name"` |
|
| `name` missing / empty | true | `"name"` |
|
||||||
| `name` contains `/`, space, etc. | true | "alphanumeric" |
|
| `name` contains `/`, space, etc. | true | "alphanumeric" |
|
||||||
| `content` missing `main` | true | "main" |
|
| `content` missing `main` | true | "main" |
|
||||||
@ -135,5 +112,5 @@ async function main({ context, params }) {
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `WriteUserScript` is a META_TOOL — available in every movement without listing it in `allowed_tools`.
|
- `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.
|
- After writing, use `RunUserScript` to immediately execute and verify the macro.
|
||||||
- Use `ListUserAssets` to see all scripts currently in the folder.
|
- Use `ListUserAssets` to see all macros currently in the folder.
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -9,7 +9,7 @@ tied to a single run), the user folder **persists indefinitely** across tasks,
|
|||||||
sessions, and server restarts.
|
sessions, and server restarts.
|
||||||
|
|
||||||
The primary use-cases are:
|
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.
|
- 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.
|
- 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.
|
- 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
|
## 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/`
|
### `browser-macros/`
|
||||||
|
|
||||||
**Playwright-based browser automation scripts.** Launches Chromium. Signature: `main({ context, params })`.
|
**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.
|
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.
|
**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/`
|
### `recordings/`
|
||||||
|
|
||||||
Browser-session recordings produced by `BrowseWeb` when the `record_to`
|
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
|
## 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 macros (`browser-macros/`)
|
### Browser macros (`browser-macros/`)
|
||||||
|
|
||||||
|
|||||||
@ -55,10 +55,7 @@ const META_TOOLS = new Set<string>([
|
|||||||
'RunUserScript',
|
'RunUserScript',
|
||||||
'UpdateUserMemory',
|
'UpdateUserMemory',
|
||||||
'ReadUserMemory',
|
'ReadUserMemory',
|
||||||
'ReadUserTemplate',
|
|
||||||
'RenderUserTemplate',
|
|
||||||
'WriteUserScript',
|
'WriteUserScript',
|
||||||
'WriteUserTemplate',
|
|
||||||
'Brainstorm',
|
'Brainstorm',
|
||||||
'ReadAppDoc',
|
'ReadAppDoc',
|
||||||
'ListAppDocs',
|
'ListAppDocs',
|
||||||
|
|||||||
@ -80,12 +80,12 @@ describe('User Folder API', () => {
|
|||||||
describe('GET /folder/list', () => {
|
describe('GET /folder/list', () => {
|
||||||
it('returns files in the requested subdir', async () => {
|
it('returns files in the requested subdir', async () => {
|
||||||
// Write 2 files directly into the subdir
|
// 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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")');
|
writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")');
|
||||||
writeFileSync(join(scriptsDir, 'world.ts'), 'export {}');
|
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);
|
expect(res.status).toBe(200);
|
||||||
const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort();
|
const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort();
|
||||||
expect(names).toEqual(['hello.js', 'world.ts']);
|
expect(names).toEqual(['hello.js', 'world.ts']);
|
||||||
@ -101,12 +101,12 @@ describe('User Folder API', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not return hidden files (starting with .)', async () => {
|
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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
writeFileSync(join(scriptsDir, 'visible.js'), 'ok');
|
writeFileSync(join(scriptsDir, 'visible.js'), 'ok');
|
||||||
writeFileSync(join(scriptsDir, '.hidden'), 'secret');
|
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);
|
expect(res.status).toBe(200);
|
||||||
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
|
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
|
||||||
expect(names).toContain('visible.js');
|
expect(names).toContain('visible.js');
|
||||||
@ -115,7 +115,7 @@ describe('User Folder API', () => {
|
|||||||
|
|
||||||
it('returns 401 when req.user is missing', async () => {
|
it('returns 401 when req.user is missing', async () => {
|
||||||
const unauthApp = makeUnauthApp(tmpRoot);
|
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.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -370,41 +370,41 @@ describe('User Folder API', () => {
|
|||||||
|
|
||||||
describe('GET /folder/file', () => {
|
describe('GET /folder/file', () => {
|
||||||
it('returns file contents as text', async () => {
|
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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
writeFileSync(join(scriptsDir, 'test.js'), 'console.log("hello")');
|
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.status).toBe(200);
|
||||||
expect(res.text).toBe('console.log("hello")');
|
expect(res.text).toBe('console.log("hello")');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 for path traversal attempt', async () => {
|
it('returns 400 for path traversal attempt', async () => {
|
||||||
const res = await request(app).get(
|
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);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 for a missing file', async () => {
|
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);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 413 for a file larger than 1 MB', async () => {
|
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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
// Write a 1.1 MB file
|
// Write a 1.1 MB file
|
||||||
const big = Buffer.alloc(1024 * 1024 + 100, 'x');
|
const big = Buffer.alloc(1024 * 1024 + 100, 'x');
|
||||||
writeFileSync(join(scriptsDir, 'big.txt'), big);
|
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);
|
expect(res.status).toBe(413);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 401 when req.user is missing', async () => {
|
it('returns 401 when req.user is missing', async () => {
|
||||||
const unauthApp = makeUnauthApp(tmpRoot);
|
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);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -417,7 +417,7 @@ describe('User Folder API', () => {
|
|||||||
it('writes the file (verifiable via direct fs read)', async () => {
|
it('writes the file (verifiable via direct fs read)', async () => {
|
||||||
const content = 'export const x = 1;';
|
const content = 'export const x = 1;';
|
||||||
const res = await request(app)
|
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')
|
.set('Content-Type', 'text/plain')
|
||||||
.send(content);
|
.send(content);
|
||||||
|
|
||||||
@ -426,18 +426,18 @@ describe('User Folder API', () => {
|
|||||||
expect(typeof res.body.size).toBe('number');
|
expect(typeof res.body.size).toBe('number');
|
||||||
expect(typeof res.body.mtime).toBe('string');
|
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);
|
expect(written).toBe(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is atomic: a follow-up GET sees the new content', async () => {
|
it('is atomic: a follow-up GET sees the new content', async () => {
|
||||||
const content = 'const y = 42;';
|
const content = 'const y = 42;';
|
||||||
await request(app)
|
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')
|
.set('Content-Type', 'text/plain')
|
||||||
.send(content);
|
.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.status).toBe(200);
|
||||||
expect(res.text).toBe(content);
|
expect(res.text).toBe(content);
|
||||||
});
|
});
|
||||||
@ -445,7 +445,7 @@ describe('User Folder API', () => {
|
|||||||
it('returns 413 when body exceeds 1 MB', async () => {
|
it('returns 413 when body exceeds 1 MB', async () => {
|
||||||
const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString();
|
const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString();
|
||||||
const res = await request(app)
|
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')
|
.set('Content-Type', 'text/plain')
|
||||||
.send(big);
|
.send(big);
|
||||||
expect(res.status).toBe(413);
|
expect(res.status).toBe(413);
|
||||||
@ -463,7 +463,7 @@ describe('User Folder API', () => {
|
|||||||
it('returns 401 when req.user is missing', async () => {
|
it('returns 401 when req.user is missing', async () => {
|
||||||
const unauthApp = makeUnauthApp(tmpRoot);
|
const unauthApp = makeUnauthApp(tmpRoot);
|
||||||
const res = await request(unauthApp)
|
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')
|
.set('Content-Type', 'text/plain')
|
||||||
.send('hi');
|
.send('hi');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
@ -476,12 +476,12 @@ describe('User Folder API', () => {
|
|||||||
|
|
||||||
describe('DELETE /folder/file', () => {
|
describe('DELETE /folder/file', () => {
|
||||||
it('moves the file into trash/ with a timestamp prefix', async () => {
|
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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye');
|
writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye');
|
||||||
|
|
||||||
const res = await request(app).delete(
|
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.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
@ -500,7 +500,7 @@ describe('User Folder API', () => {
|
|||||||
it('returns 401 when req.user is missing', async () => {
|
it('returns 401 when req.user is missing', async () => {
|
||||||
const unauthApp = makeUnauthApp(tmpRoot);
|
const unauthApp = makeUnauthApp(tmpRoot);
|
||||||
const res = await request(unauthApp).delete(
|
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);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
@ -514,19 +514,19 @@ describe('User Folder API', () => {
|
|||||||
|
|
||||||
it('returns 404 when DELETE targets a missing file', async () => {
|
it('returns 404 when DELETE targets a missing file', async () => {
|
||||||
const res = await request(app).delete(
|
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);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles two same-name deletes in quick succession without data loss', async () => {
|
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 });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
|
|
||||||
// First file
|
// First file
|
||||||
writeFileSync(join(scriptsDir, 'dup.js'), 'first');
|
writeFileSync(join(scriptsDir, 'dup.js'), 'first');
|
||||||
const res1 = await request(app).delete(
|
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);
|
expect(res1.status).toBe(200);
|
||||||
const trashedAs1 = res1.body.trashedAs as string;
|
const trashedAs1 = res1.body.trashedAs as string;
|
||||||
@ -534,7 +534,7 @@ describe('User Folder API', () => {
|
|||||||
// Second file with same name
|
// Second file with same name
|
||||||
writeFileSync(join(scriptsDir, 'dup.js'), 'second');
|
writeFileSync(join(scriptsDir, 'dup.js'), 'second');
|
||||||
const res2 = await request(app).delete(
|
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);
|
expect(res2.status).toBe(200);
|
||||||
const trashedAs2 = res2.body.trashedAs as string;
|
const trashedAs2 = res2.body.trashedAs as string;
|
||||||
@ -556,13 +556,13 @@ describe('User Folder API', () => {
|
|||||||
describe('Cross-user isolation', () => {
|
describe('Cross-user isolation', () => {
|
||||||
it('user A cannot read files belonging to user B', async () => {
|
it('user A cannot read files belonging to user B', async () => {
|
||||||
// Write a file under user B's folder directly
|
// 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 });
|
mkdirSync(bScriptsDir, { recursive: true });
|
||||||
writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret');
|
writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret');
|
||||||
|
|
||||||
// app is authed as USER_A; try to reach USER_B's file via traversal
|
// app is authed as USER_A; try to reach USER_B's file via traversal
|
||||||
const res = await request(app).get(
|
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
|
// Must be 400 (traversal blocked) — NOT 200
|
||||||
expect(res.status).toBe(400);
|
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 () => {
|
it('user A list only sees their own files, not user B files', async () => {
|
||||||
// Create scripts for both users
|
// Create scripts for both users
|
||||||
const aDir = join(tmpRoot, USER_A, 'scripts');
|
const aDir = join(tmpRoot, USER_A, 'browser-macros');
|
||||||
const bDir = join(tmpRoot, USER_B, 'scripts');
|
const bDir = join(tmpRoot, USER_B, 'browser-macros');
|
||||||
mkdirSync(aDir, { recursive: true });
|
mkdirSync(aDir, { recursive: true });
|
||||||
mkdirSync(bDir, { recursive: true });
|
mkdirSync(bDir, { recursive: true });
|
||||||
writeFileSync(join(aDir, 'a-only.js'), 'a');
|
writeFileSync(join(aDir, 'a-only.js'), 'a');
|
||||||
writeFileSync(join(bDir, 'b-only.js'), 'b');
|
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);
|
expect(res.status).toBe(200);
|
||||||
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
|
const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
|
||||||
expect(names).toContain('a-only.js');
|
expect(names).toContain('a-only.js');
|
||||||
@ -728,7 +728,7 @@ describe('User Folder API', () => {
|
|||||||
|
|
||||||
describe('POST /scripts/:name/run', () => {
|
describe('POST /scripts/:name/run', () => {
|
||||||
it('runs a script that returns 42', async () => {
|
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 });
|
mkdirSync(scriptDir, { recursive: true });
|
||||||
writeFileSync(join(scriptDir, 'simple.js'), SIMPLE_SCRIPT_BODY);
|
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 () => {
|
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 });
|
mkdirSync(scriptDir, { recursive: true });
|
||||||
// Script with a declared param of type string
|
// Script with a declared param of type string
|
||||||
const scriptWithParam = `\
|
const scriptWithParam = `\
|
||||||
@ -773,7 +773,7 @@ module.exports = async function main({ params }) { return params.username; };
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clamps timeoutMs to 5 minutes max', async () => {
|
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 });
|
mkdirSync(scriptDir, { recursive: true });
|
||||||
writeFileSync(join(scriptDir, 'fast.js'), SIMPLE_SCRIPT_BODY);
|
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 () => {
|
it('returns 401 when authActive=true (default) and no user', async () => {
|
||||||
// makeUnauthApp uses the default (authActive not passed → defaults to true)
|
// makeUnauthApp uses the default (authActive not passed → defaults to true)
|
||||||
const unauthApp = makeUnauthApp(tmpRoot);
|
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.status).toBe(401);
|
||||||
expect(res.body.error).toMatch(/Unauthenticated/i);
|
expect(res.body.error).toMatch(/Unauthenticated/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to synthetic local user when authActive=false', async () => {
|
it('falls back to synthetic local user when authActive=false', async () => {
|
||||||
const noAuthApp = makeNoAuthModeApp(tmpRoot);
|
const noAuthApp = makeNoAuthModeApp(tmpRoot);
|
||||||
// Pre-create the 'local' user scripts dir so list returns 200 rather than 500
|
// Pre-create the 'local' user macros dir so list returns 200 rather than 500
|
||||||
const localScriptsDir = join(tmpRoot, 'local', 'scripts');
|
const localMacrosDir = join(tmpRoot, 'local', 'browser-macros');
|
||||||
mkdirSync(localScriptsDir, { recursive: true });
|
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(res.status).toBe(200);
|
||||||
expect(Array.isArray(res.body.files)).toBe(true);
|
expect(Array.isArray(res.body.files)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -57,7 +57,7 @@ function isUserSubdir(s: string): s is UserSubdir {
|
|||||||
// Subdirs that users may write to / delete from. 'trash' is system-managed.
|
// 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
|
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
|
||||||
// then delegate immediately to NotesService rather than the generic file writer.
|
// 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];
|
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
|
||||||
function isWritableSubdir(s: string): s is WritableSubdir {
|
function isWritableSubdir(s: string): s is WritableSubdir {
|
||||||
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
|
return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
|
||||||
@ -639,31 +639,18 @@ export function createUserFolderApi(deps: Deps): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { params, timeoutMs, kind } = ((req.body as Record<string, unknown>) ?? {}) as {
|
const { params, timeoutMs } = ((req.body as Record<string, unknown>) ?? {}) as {
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
timeoutMs?: number;
|
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 scriptPath: string | null = null;
|
||||||
let resolvedRuntime: 'plain' | 'playwright' = 'plain';
|
const resolvedRuntime = 'playwright' as const;
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
|
const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
|
||||||
if (existsSync(candidate)) { scriptPath = candidate; resolvedRuntime = 'playwright'; }
|
if (existsSync(candidate)) scriptPath = candidate;
|
||||||
} catch { /* invalid path */ }
|
} catch { /* invalid path */ }
|
||||||
}
|
|
||||||
if (!scriptPath && kind === 'script') {
|
|
||||||
// explicit kind but no match — keep null to hit 404 below
|
|
||||||
}
|
|
||||||
if (scriptPath === null) {
|
if (scriptPath === null) {
|
||||||
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
|
res.status(404).json({ error: `Script not found: ${scriptFileName}` });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -71,12 +71,11 @@ export interface ToolsConfig {
|
|||||||
*/
|
*/
|
||||||
taskUploadMaxSizeMb?: number;
|
taskUploadMaxSizeMb?: number;
|
||||||
/**
|
/**
|
||||||
* Allow RunUserScript to execute user-authored scripts.
|
* Allow RunUserScript to execute user-authored browser-macros.
|
||||||
* Default: false (opt-in required).
|
* Default: false (opt-in required).
|
||||||
* Plain-runtime scripts now run under Node's Permissions Model
|
* Browser-macros run with full Node.js capabilities because Playwright
|
||||||
* (--permission), which blocks child_process, worker threads, and FS access
|
* needs them — only enable for trusted users.
|
||||||
* outside tmpdir. browser-macros still run with full Node.js capabilities
|
* (Plain-Node scripts/ were retired in 2026-06; Skills + Bash replace them.)
|
||||||
* because Playwright needs them — only enable for trusted users.
|
|
||||||
*/
|
*/
|
||||||
userScriptsEnabled?: boolean;
|
userScriptsEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -81,9 +81,7 @@ beforeEach(() => {
|
|||||||
mkdirSync(userFolderRoot, { recursive: true });
|
mkdirSync(userFolderRoot, { recursive: true });
|
||||||
|
|
||||||
// Create user subdirs (including memory and trash for memory tool tests)
|
// 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, '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, 'recordings'), { recursive: true });
|
||||||
mkdirSync(join(userFolderRoot, TEST_USER, 'memory'), { recursive: true });
|
mkdirSync(join(userFolderRoot, TEST_USER, 'memory'), { recursive: true });
|
||||||
mkdirSync(join(userFolderRoot, TEST_USER, 'trash'), { recursive: true });
|
mkdirSync(join(userFolderRoot, TEST_USER, 'trash'), { recursive: true });
|
||||||
@ -117,12 +115,12 @@ describe('TOOL_DEFS', () => {
|
|||||||
// ── ListUserAssets ────────────────────────────────────────────────────────────
|
// ── ListUserAssets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('ListUserAssets', () => {
|
describe('ListUserAssets', () => {
|
||||||
it('lists scripts with descriptions and params', async () => {
|
it('lists browser-macros with descriptions and params', async () => {
|
||||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);
|
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO);
|
||||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'bar.js'), SCRIPT_BAR);
|
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'bar.js'), SCRIPT_BAR);
|
||||||
|
|
||||||
const ctx = buildCtx({ userId: TEST_USER });
|
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).not.toBeNull();
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.isError).toBe(false);
|
||||||
@ -131,7 +129,7 @@ describe('ListUserAssets', () => {
|
|||||||
expect(result!.output).toContain('date:string');
|
expect(result!.output).toContain('date:string');
|
||||||
expect(result!.output).toContain('bar.js');
|
expect(result!.output).toContain('bar.js');
|
||||||
expect(result!.output).toContain('Check dashboard');
|
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 () => {
|
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 () => {
|
it('cross-user access is denied (ctx.userId must match target folder)', async () => {
|
||||||
// Create another user's folder
|
// Create another user's folder
|
||||||
mkdirSync(join(userFolderRoot, 'other-user', 'scripts'), { recursive: true });
|
mkdirSync(join(userFolderRoot, 'other-user', 'browser-macros'), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(userFolderRoot, 'other-user', 'scripts', 'secret.js'),
|
join(userFolderRoot, 'other-user', 'browser-macros', 'secret.js'),
|
||||||
SCRIPT_FOO,
|
SCRIPT_FOO,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logged in as TEST_USER but the tool always reads from ctx.userId,
|
// Logged in as TEST_USER but the tool always reads from ctx.userId,
|
||||||
// so there is no way to list another user's folder.
|
// 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 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!.isError).toBe(false);
|
||||||
expect(result!.output).toContain('Scripts (0)');
|
expect(result!.output).toContain('Browser Macros (0)');
|
||||||
expect(result!.output).not.toContain('secret.js');
|
expect(result!.output).not.toContain('secret.js');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns "all" categories when kind is omitted', async () => {
|
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 ctx = buildCtx({ userId: TEST_USER });
|
||||||
const result = await executeTool('ListUserAssets', {}, ctx);
|
const result = await executeTool('ListUserAssets', {}, ctx);
|
||||||
|
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.isError).toBe(false);
|
||||||
expect(result!.output).toContain('Scripts');
|
expect(result!.output).toContain('Browser Macros');
|
||||||
expect(result!.output).toContain('Templates');
|
|
||||||
expect(result!.output).toContain('Recordings');
|
expect(result!.output).toContain('Recordings');
|
||||||
|
// Retired categories must no longer appear
|
||||||
|
expect(result!.output).not.toContain('Templates');
|
||||||
|
expect(result!.output).not.toContain('Scripts (');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── RunUserScript ─────────────────────────────────────────────────────────────
|
// ── RunUserScript ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('RunUserScript', () => {
|
describe('RunUserScript', () => {
|
||||||
it('runs a fixture script that returns a value', async () => {
|
it('runs a fixture macro that returns a value', async () => {
|
||||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);
|
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM);
|
||||||
|
|
||||||
const ctx = buildCtx({ userId: TEST_USER });
|
const ctx = buildCtx({ userId: TEST_USER });
|
||||||
const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);
|
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 () => {
|
it('returns isError true with "param" in message for bad params', async () => {
|
||||||
// Script declares `date:string` but we pass wrong type
|
// Macro declares `date:string` but we pass wrong type
|
||||||
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 ctx = buildCtx({ userId: TEST_USER });
|
||||||
const result = await executeTool(
|
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/);
|
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 () => {
|
it('a file living only in the retired scripts/ dir is no longer resolvable', async () => {
|
||||||
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);
|
mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true });
|
||||||
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_THROWS);
|
writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'legacy.js'), SCRIPT_NO_FM);
|
||||||
|
|
||||||
const ctx = buildCtx({ userId: TEST_USER });
|
const ctx = buildCtx({ userId: TEST_USER });
|
||||||
// kind omitted → should find scripts/nofm.js (plain runtime, returns 42)
|
const result = await executeTool('RunUserScript', { name: 'legacy' }, ctx);
|
||||||
const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);
|
expect(result!.isError).toBe(true);
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.output.toLowerCase()).toContain('not found');
|
||||||
expect(result!.output).toContain('42');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to browser-macros/ when kind omitted and not in scripts/', async () => {
|
it('explicit kind: "script" is rejected with a pointer to Bash/Skills', async () => {
|
||||||
// Only exists in browser-macros/
|
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);
|
writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM);
|
||||||
|
|
||||||
const ctx = buildCtx({ userId: TEST_USER });
|
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);
|
const result = await executeTool('RunUserScript', { name: 'nofm', kind: 'browser-macro' }, ctx);
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.isError).toBe(false);
|
||||||
expect(result!.output).toContain('42');
|
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 ──────────────────────────────────────────────────────────
|
// ── 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 ────────────────────────────────────────────────────────────
|
// ── ReadUserMemory ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('ReadUserMemory', () => {
|
describe('ReadUserMemory', () => {
|
||||||
@ -725,7 +466,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
|||||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] },
|
tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] },
|
||||||
});
|
});
|
||||||
writeFileSync(
|
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`,
|
`---\nparams: []\n---\nasync function main(){return 'ok';}\nmodule.exports=main;\n`,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -740,7 +481,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
|||||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] },
|
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] },
|
||||||
});
|
});
|
||||||
writeFileSync(
|
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`,
|
`---\nparams: []\n---\nasync function main(){return 'allowed';}\nmodule.exports=main;\n`,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -755,7 +496,7 @@ describe('RunUserScript: tools.user_scripts_allow_userids', () => {
|
|||||||
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] },
|
tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] },
|
||||||
});
|
});
|
||||||
writeFileSync(
|
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`,
|
`---\nparams: []\n---\nasync function main(){return 'still ok';}\nmodule.exports=main;\n`,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
@ -792,7 +533,7 @@ async function main({ context, params }) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
describe('WriteUserScript', () => {
|
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 ctx = buildCtx({ userId: TEST_USER });
|
||||||
const result = await executeTool('WriteUserScript', {
|
const result = await executeTool('WriteUserScript', {
|
||||||
name: 'fetch-and-clean',
|
name: 'fetch-and-clean',
|
||||||
@ -801,13 +542,8 @@ describe('WriteUserScript', () => {
|
|||||||
}, ctx);
|
}, ctx);
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.isError).toBe(true);
|
||||||
expect(result!.output).toContain('scripts/fetch-and-clean.js');
|
expect(result!.output).toContain('retired');
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes a browser-macro to browser-macros/', async () => {
|
it('writes a browser-macro to browser-macros/', async () => {
|
||||||
@ -826,7 +562,7 @@ describe('WriteUserScript', () => {
|
|||||||
expect(checkExists(written)).toBe(true);
|
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 ctx = buildCtx({ userId: TEST_USER });
|
||||||
const result = await executeTool('WriteUserScript', {
|
const result = await executeTool('WriteUserScript', {
|
||||||
name: 'implicit-kind',
|
name: 'implicit-kind',
|
||||||
@ -834,7 +570,7 @@ describe('WriteUserScript', () => {
|
|||||||
}, ctx);
|
}, ctx);
|
||||||
|
|
||||||
expect(result!.isError).toBe(false);
|
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 () => {
|
it('accepts name with .js suffix (strips it)', async () => {
|
||||||
@ -845,7 +581,7 @@ describe('WriteUserScript', () => {
|
|||||||
}, ctx);
|
}, ctx);
|
||||||
|
|
||||||
expect(result!.isError).toBe(false);
|
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"
|
// Must not write "my-script.js.js"
|
||||||
expect(result!.output).not.toContain('my-script.js.js');
|
expect(result!.output).not.toContain('my-script.js.js');
|
||||||
});
|
});
|
||||||
@ -946,7 +682,7 @@ describe('WriteUserScript', () => {
|
|||||||
|
|
||||||
expect(result!.isError).toBe(false);
|
expect(result!.isError).toBe(false);
|
||||||
const { readFileSync: rf } = await import('fs');
|
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');
|
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<string, unknown> }> = [];
|
|
||||||
setUserFolderToolDeps({
|
|
||||||
sessRepo: null as never,
|
|
||||||
masterKeyPath: '',
|
|
||||||
userFolderRoot,
|
|
||||||
auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record<string, unknown> }); },
|
|
||||||
});
|
|
||||||
|
|
||||||
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', () => {
|
describe('RunUserScript: audit log hook', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });
|
mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });
|
||||||
@ -1160,7 +736,7 @@ describe('RunUserScript: audit log hook', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
writeFileSync(
|
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`,
|
`---\nparams: []\n---\nasync function main(){return 'audited ok';}\nmodule.exports=main;\n`,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* user-folder.ts
|
* user-folder.ts
|
||||||
*
|
*
|
||||||
* Tools for discovering and executing user-authored Playwright scripts:
|
* Tools for the per-user folder (data/users/{userId}/):
|
||||||
* - ListUserAssets: browse scripts / templates / recordings in data/users/{userId}/
|
* - UpdateUserMemory / ReadUserMemory: persistent memory entries
|
||||||
* - RunUserScript: validate params, decrypt session storageState if needed, delegate to runUserScript()
|
* - 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 { existsSync, readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||||
import { join, extname } from 'node:path';
|
import { join, extname } from 'node:path';
|
||||||
import matter from 'gray-matter';
|
|
||||||
import { ToolDef } from '../../llm/openai-compat.js';
|
import { ToolDef } from '../../llm/openai-compat.js';
|
||||||
import { ToolContext, ToolResult } from './core.js';
|
import { ToolContext, ToolResult } from './core.js';
|
||||||
import { loadConfig } from '../../config.js';
|
import { loadConfig } from '../../config.js';
|
||||||
import { parseScript } from '../../user-folder/frontmatter.js';
|
import { parseScript } from '../../user-folder/frontmatter.js';
|
||||||
import { renderTemplate } from '../../user-folder/template-renderer.js';
|
|
||||||
import {
|
import {
|
||||||
userRoot,
|
userRoot,
|
||||||
assertOwnerAccess,
|
assertOwnerAccess,
|
||||||
@ -62,9 +64,6 @@ function getUserFolderRoot(): string {
|
|||||||
/** Regex for valid memory entry names: alphanumeric, dash, underscore; no extension. */
|
/** Regex for valid memory entry names: alphanumeric, dash, underscore; no extension. */
|
||||||
const MEMORY_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
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<string, ToolDef> = {
|
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||||
UpdateUserMemory: {
|
UpdateUserMemory: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@ -125,65 +124,19 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
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: {
|
ListUserAssets: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'ListUserAssets',
|
name: 'ListUserAssets',
|
||||||
description:
|
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" }).',
|
'Details via ReadToolDoc({ name: "ListUserAssets" }).',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
kind: {
|
kind: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['scripts', 'browser-macros', 'templates', 'recordings', 'all'],
|
enum: ['browser-macros', 'recordings', 'all'],
|
||||||
description: 'Which category to list. Default "all".',
|
description: 'Which category to list. Default "all".',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -197,27 +150,20 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
function: {
|
function: {
|
||||||
name: 'RunUserScript',
|
name: 'RunUserScript',
|
||||||
description:
|
description:
|
||||||
'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 ' +
|
||||||
'Use kind="script" (default) for plain Node scripts in scripts/, ' +
|
'(main({ context, params }) signature, optional session_profile_id frontmatter). ' +
|
||||||
'or kind="browser-macro" for Playwright scripts in browser-macros/. ' +
|
'For ad-hoc code (Node, Python, …) use the Bash tool instead. ' +
|
||||||
'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). ' +
|
|
||||||
'Details via ReadToolDoc({ name: "RunUserScript" }).',
|
'Details via ReadToolDoc({ name: "RunUserScript" }).',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Script filename (with or without .js extension).',
|
description: 'Macro filename (with or without .js extension).',
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'Key-value params matching the script\'s frontmatter param spec.',
|
description: 'Key-value params matching the macro\'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 }).',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['name'],
|
required: ['name'],
|
||||||
@ -230,22 +176,16 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
function: {
|
function: {
|
||||||
name: 'WriteUserScript',
|
name: 'WriteUserScript',
|
||||||
description:
|
description:
|
||||||
'ユーザーフォルダの scripts/ または browser-macros/ に script を作成・上書きする。' +
|
'ユーザーフォルダの browser-macros/ に Playwright マクロを作成・上書きする ' +
|
||||||
'kind="script" は scripts/ (plain Node、main({ params }) 形式)、' +
|
'(main({ context, params }) 形式)。' +
|
||||||
'kind="browser-macro" は browser-macros/ (Playwright、main({ context, params }) 形式)。' +
|
'アドホックなコード実行 (Node / Python 等) は Bash ツールを使うこと。' +
|
||||||
'Node 専用 — Python 不可: python を呼ぶだけの JS ラッパーを書かないこと (plain は --permission で child_process 不可)。python は Bash ツールで直接実行する (pip は pre-baked)。' +
|
|
||||||
'詳細は ReadToolDoc({ name: "WriteUserScript" })。',
|
'詳細は ReadToolDoc({ name: "WriteUserScript" })。',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'ファイル名 (slug)。`.js` は自動補完される。例: "fetch-and-clean"',
|
description: 'ファイル名 (slug)。`.js` は自動補完される。例: "nightly-login-check"',
|
||||||
},
|
|
||||||
kind: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['script', 'browser-macro'],
|
|
||||||
description: '"script" (default、plain Node) または "browser-macro" (Playwright)',
|
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -260,35 +200,6 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
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 ──────────────────────────────────────────
|
// ── UpdateUserMemory implementation ──────────────────────────────────────────
|
||||||
@ -398,156 +309,6 @@ async function executeReadUserMemory(
|
|||||||
return { output, isError: false };
|
return { output, isError: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ReadUserTemplate implementation ──────────────────────────────────────────
|
|
||||||
|
|
||||||
async function executeReadUserTemplate(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
ctx: ToolContext,
|
|
||||||
): Promise<ToolResult> {
|
|
||||||
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<string, unknown>;
|
|
||||||
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<string, unknown>,
|
|
||||||
ctx: ToolContext,
|
|
||||||
): Promise<ToolResult> {
|
|
||||||
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<string, unknown> | 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 ─────────────────────────────────────────────
|
// ── ListUserAssets implementation ─────────────────────────────────────────────
|
||||||
|
|
||||||
async function executeListUserAssets(
|
async function executeListUserAssets(
|
||||||
@ -565,39 +326,6 @@ async function executeListUserAssets(
|
|||||||
|
|
||||||
const lines: string[] = [`User folder for ${ctx.userId}:`];
|
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)
|
// Browser Macros (Playwright runtime)
|
||||||
if (kind === 'browser-macros' || kind === 'all') {
|
if (kind === 'browser-macros' || kind === 'all') {
|
||||||
const macrosDir = join(userDir, 'browser-macros');
|
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
|
// Recordings
|
||||||
if (kind === 'recordings' || kind === 'all') {
|
if (kind === 'recordings' || kind === 'all') {
|
||||||
const recordingsDir = join(userDir, 'recordings');
|
const recordingsDir = join(userDir, 'recordings');
|
||||||
@ -710,8 +409,7 @@ async function executeRunUserScript(
|
|||||||
if (cfg.tools?.userScriptsEnabled !== true) {
|
if (cfg.tools?.userScriptsEnabled !== true) {
|
||||||
return {
|
return {
|
||||||
output:
|
output:
|
||||||
'User scripts are disabled (set tools.user_scripts_enabled: true in config.yaml). ' +
|
'User browser-macros are disabled (set tools.user_scripts_enabled: true in config.yaml).',
|
||||||
'Note: plain-runtime scripts run under Node --permission, but browser-macros do not.',
|
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -740,11 +438,17 @@ async function executeRunUserScript(
|
|||||||
|
|
||||||
const params = (input['params'] as Record<string, unknown> | undefined) ?? {};
|
const params = (input['params'] as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
const rawKind = input['kind'];
|
// Plain-Node scripts were retired (2026-06): everything is a browser-macro.
|
||||||
const kind: 'script' | 'browser-macro' | undefined =
|
// Reject an explicit kind="script" with a pointer to the replacement.
|
||||||
rawKind === 'browser-macro' ? 'browser-macro' :
|
if (input['kind'] === 'script') {
|
||||||
rawKind === 'script' ? 'script' :
|
return {
|
||||||
undefined;
|
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$/, '');
|
const scriptBaseName = (rawName.endsWith('.js') ? rawName : `${rawName}.js`).replace(/\.js$/, '');
|
||||||
|
|
||||||
@ -824,7 +528,6 @@ async function executeWriteUserScript(
|
|||||||
|
|
||||||
const name = input['name'];
|
const name = input['name'];
|
||||||
const content = input['content'];
|
const content = input['content'];
|
||||||
const kind = (input['kind'] as string | undefined) ?? 'script';
|
|
||||||
const overwrite = input['overwrite'] === true;
|
const overwrite = input['overwrite'] === true;
|
||||||
|
|
||||||
if (typeof name !== 'string' || !name) {
|
if (typeof name !== 'string' || !name) {
|
||||||
@ -833,9 +536,16 @@ async function executeWriteUserScript(
|
|||||||
if (typeof content !== 'string') {
|
if (typeof content !== 'string') {
|
||||||
return { output: 'WriteUserScript: "content" must be a string', isError: true };
|
return { output: 'WriteUserScript: "content" must be a string', isError: true };
|
||||||
}
|
}
|
||||||
if (kind !== 'script' && kind !== 'browser-macro') {
|
// Plain-Node scripts were retired (2026-06): everything is a browser-macro.
|
||||||
return { output: 'WriteUserScript: "kind" must be "script" or "browser-macro"', isError: true };
|
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
|
// Strip optional .js suffix; validate the base name as a slug
|
||||||
const baseName = name.replace(/\.js$/i, '');
|
const baseName = name.replace(/\.js$/i, '');
|
||||||
@ -858,14 +568,14 @@ async function executeWriteUserScript(
|
|||||||
return {
|
return {
|
||||||
output:
|
output:
|
||||||
'WriteUserScript: content must define a `main` function ' +
|
'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.',
|
'See ReadToolDoc({ name: "WriteUserScript" }) for examples.',
|
||||||
isError: true,
|
isError: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const userFolderRoot = getUserFolderRoot();
|
const userFolderRoot = getUserFolderRoot();
|
||||||
const subdir = kind === 'script' ? 'scripts' : 'browser-macros';
|
const subdir = 'browser-macros';
|
||||||
|
|
||||||
let targetPath: string;
|
let targetPath: string;
|
||||||
try {
|
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<string, unknown>,
|
|
||||||
ctx: ToolContext,
|
|
||||||
): Promise<ToolResult> {
|
|
||||||
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 ──────────────────────────────────────────────────────────────────
|
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
@ -998,11 +626,8 @@ export async function executeTool(
|
|||||||
): Promise<ToolResult | null> {
|
): Promise<ToolResult | null> {
|
||||||
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
||||||
if (name === 'ReadUserMemory') return executeReadUserMemory(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 === 'ListUserAssets') return executeListUserAssets(input, ctx);
|
||||||
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
||||||
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
||||||
if (name === 'WriteUserTemplate') return executeWriteUserTemplate(input, ctx);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,8 +75,8 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
|||||||
// mission.ts
|
// mission.ts
|
||||||
'MissionUpdate',
|
'MissionUpdate',
|
||||||
// user-folder.ts
|
// user-folder.ts
|
||||||
'ListUserAssets', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate',
|
'ListUserAssets', 'ReadUserMemory',
|
||||||
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript', 'WriteUserTemplate',
|
'RunUserScript', 'UpdateUserMemory', 'WriteUserScript',
|
||||||
// brainstorm.ts
|
// brainstorm.ts
|
||||||
'Brainstorm',
|
'Brainstorm',
|
||||||
// app-docs.ts
|
// app-docs.ts
|
||||||
|
|||||||
@ -363,12 +363,12 @@ describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function writeScript(userId: string, name: string, source: string): void {
|
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 });
|
mkdirSync(dir, { recursive: true });
|
||||||
writeFileSync(join(dir, name), source, 'utf-8');
|
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' });
|
const owner = repo.createUser({ email: 'eve@x.com', name: 'eve', role: 'user', status: 'active' });
|
||||||
writeScript(owner.id, 'hello.js', `---
|
writeScript(owner.id, 'hello.js', `---
|
||||||
params:
|
params:
|
||||||
@ -473,7 +473,7 @@ module.exports = main;
|
|||||||
// (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
|
// (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
|
||||||
// used to throw "requires an owner_id", so no-auth scheduled scripts could
|
// 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
|
// 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() {
|
writeScript('local', 'noauth.js', `async function main() {
|
||||||
return { ran: 'no-auth-local' };
|
return { ran: 'no-auth-local' };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,7 +303,7 @@ export class Scheduler {
|
|||||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
|
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
|
// 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
|
// 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
|
// no-auth (ctx.userId='local'). In auth mode item.ownerId is always set, so
|
||||||
// scriptOwner === item.ownerId and behaviour is unchanged.
|
// scriptOwner === item.ownerId and behaviour is unchanged.
|
||||||
|
|||||||
@ -11,7 +11,7 @@ describe('user-folder/paths', () => {
|
|||||||
|
|
||||||
it('creates the standard subdirs on first ensure', () => {
|
it('creates the standard subdirs on first ensure', () => {
|
||||||
ensureUserFolder(root, 'user-abc');
|
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);
|
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', () => {
|
it('resolves subdir paths under the owner root', () => {
|
||||||
const p = resolveUserSubdir(root, 'user-abc', 'scripts', 'foo.js');
|
const p = resolveUserSubdir(root, 'user-abc', 'browser-macros', 'foo.js');
|
||||||
expect(p).toBe(join(root, 'user-abc', 'scripts', 'foo.js'));
|
expect(p).toBe(join(root, 'user-abc', 'browser-macros', 'foo.js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects path traversal in the relative segment', () => {
|
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/);
|
.toThrow(/outside owner folder/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects empty relPath', () => {
|
it('rejects empty relPath', () => {
|
||||||
expect(() => resolveUserSubdir(root, 'user-abc', 'scripts', ''))
|
expect(() => resolveUserSubdir(root, 'user-abc', 'browser-macros', ''))
|
||||||
.toThrow(/relPath must not be empty/);
|
.toThrow(/relPath must not be empty/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,10 @@ export const LOCAL_SYSTEM_OWNER_ID = 'local';
|
|||||||
|
|
||||||
const USER_AGENTS_MAX_BYTES = 64 * 1024;
|
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 type UserSubdir = typeof USER_SUBDIRS[number];
|
||||||
|
|
||||||
export function userRoot(rootDir: string, ownerId: string): string {
|
export function userRoot(rootDir: string, ownerId: string): string {
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* script-orchestrator.ts
|
* 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 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.
|
* 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:
|
* 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).
|
* 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.
|
* - Decrypting + loading Playwright storageState for the owning user.
|
||||||
* - Calling runUserScript() with the right runtime.
|
* - Calling runUserScript() with the playwright runtime.
|
||||||
*
|
*
|
||||||
* Intentionally NOT responsible for:
|
* Intentionally NOT responsible for:
|
||||||
* - Config gating (tools.user_scripts_enabled) — callers decide.
|
* - 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 { loadSessionStateForUser } from './session-loader.js';
|
||||||
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||||
|
|
||||||
export type ScriptKind = 'script' | 'browser-macro';
|
export type ScriptKind = 'browser-macro';
|
||||||
export type ScriptSubdir = 'scripts' | 'browser-macros';
|
export type ScriptSubdir = 'browser-macros';
|
||||||
export type ScriptRuntime = 'plain' | 'playwright';
|
export type ScriptRuntime = 'playwright';
|
||||||
|
|
||||||
export interface ResolveScriptResult {
|
export interface ResolveScriptResult {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
@ -37,49 +41,31 @@ export interface ResolveScriptResult {
|
|||||||
runtime: ScriptRuntime;
|
runtime: ScriptRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resolve a macro name to its on-disk path under browser-macros/. */
|
||||||
* Resolve a script name to its on-disk path. When kind is omitted, scripts/
|
|
||||||
* is searched first, then browser-macros/.
|
|
||||||
*/
|
|
||||||
export function resolveScriptForKind(
|
export function resolveScriptForKind(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
scriptName: string,
|
scriptName: string,
|
||||||
kind: ScriptKind | undefined,
|
_kind?: ScriptKind,
|
||||||
): ResolveScriptResult | { error: string } {
|
): ResolveScriptResult | { error: string } {
|
||||||
const tryOne = (sd: ScriptSubdir): string | null => {
|
let p: string | null;
|
||||||
try {
|
try {
|
||||||
const p = resolveUserSubdir(rootDir, userId, sd, scriptName);
|
const full = resolveUserSubdir(rootDir, userId, 'browser-macros', scriptName);
|
||||||
return existsSync(p) ? p : null;
|
p = existsSync(full) ? full : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
p = 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' };
|
|
||||||
}
|
|
||||||
if (kind === 'browser-macro') {
|
|
||||||
const p = tryOne('browser-macros');
|
|
||||||
if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` };
|
if (!p) return { error: `browser-macro not found: browser-macros/${basename(scriptName)}` };
|
||||||
return { scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' };
|
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/)` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveAndRunOptions {
|
export interface ResolveAndRunOptions {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
/** Script name with or without `.js` extension. */
|
/** Macro name with or without `.js` extension. */
|
||||||
name: string;
|
name: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
/** If omitted, scripts/ is tried first, then browser-macros/. */
|
/** Kept for call-site compatibility; the only kind is 'browser-macro'. */
|
||||||
kind?: ScriptKind;
|
kind?: ScriptKind;
|
||||||
/** Required only when the resolved script is a browser-macro that declares session_profile_id. */
|
/** Required only when the resolved script is a browser-macro that declares session_profile_id. */
|
||||||
sessRepo?: BrowserSessionRepo;
|
sessRepo?: BrowserSessionRepo;
|
||||||
|
|||||||
@ -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, unknown>,
|
|
||||||
): string {
|
|
||||||
const resolved = validateAndApplyDefaults(paramSpec, rawParams);
|
|
||||||
return body.replace(/\{\{(\w+)\}\}/g, (match, name) =>
|
|
||||||
Object.prototype.hasOwnProperty.call(resolved, name) ? String(resolved[name]) : match,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -144,7 +144,7 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
|||||||
checked={tools.userScriptsEnabled === true}
|
checked={tools.userScriptsEnabled === true}
|
||||||
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>有効 (LLM の RunUserScript + scheduled script task が動作)</span>
|
<span>有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作)</span>
|
||||||
</label>
|
</label>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
||||||
@ -160,7 +160,7 @@ export function ToolsExternalForm({ config, onChange }: SectionFormProps) {
|
|||||||
placeholder="user id (例: 12345)"
|
placeholder="user id (例: 12345)"
|
||||||
/>
|
/>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。
|
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。
|
||||||
</HelpText>
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -270,7 +270,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
|||||||
checked={tools.userScriptsEnabled === true}
|
checked={tools.userScriptsEnabled === true}
|
||||||
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
onChange={e => onChange('tools.userScriptsEnabled', e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>有効 (LLM の RunUserScript + scheduled script task が動作)</span>
|
<span>有効 (browser-macros: LLM の RunUserScript + scheduled script task が動作)</span>
|
||||||
</label>
|
</label>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
plain runtime は Node <code>--permission</code> で sandbox 化され child_process / worker / tmpdir 外の FS アクセスを deny。
|
||||||
@ -286,7 +286,7 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
|
|||||||
placeholder="user id (例: 12345)"
|
placeholder="user id (例: 12345)"
|
||||||
/>
|
/>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ RunUserScript / scheduled script task が許可される。
|
未指定なら <code>user_scripts_enabled</code> のみで制御。設定すると指定 ID のみ browser-macro の実行 (RunUserScript / scheduled script task) が許可される。
|
||||||
</HelpText>
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
// 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories)
|
// '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 */
|
/** 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 {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@ -29,9 +30,7 @@ interface FileTreeProps {
|
|||||||
|
|
||||||
const SUBDIR_LABELS: Record<SubdirId, string> = {
|
const SUBDIR_LABELS: Record<SubdirId, string> = {
|
||||||
'agents-md': 'AGENTS.md',
|
'agents-md': 'AGENTS.md',
|
||||||
scripts: 'scripts',
|
|
||||||
'browser-macros': 'browser-macros',
|
'browser-macros': 'browser-macros',
|
||||||
templates: 'templates',
|
|
||||||
recordings: 'recordings',
|
recordings: 'recordings',
|
||||||
trash: 'trash',
|
trash: 'trash',
|
||||||
memory: 'memory',
|
memory: 'memory',
|
||||||
@ -46,9 +45,7 @@ const SUBDIR_LABELS: Record<SubdirId, string> = {
|
|||||||
|
|
||||||
const SUBDIR_ICONS: Record<SubdirId, string> = {
|
const SUBDIR_ICONS: Record<SubdirId, string> = {
|
||||||
'agents-md': '📖',
|
'agents-md': '📖',
|
||||||
scripts: '📜',
|
|
||||||
'browser-macros': '🤖',
|
'browser-macros': '🤖',
|
||||||
templates: '📄',
|
|
||||||
recordings: '🎬',
|
recordings: '🎬',
|
||||||
trash: '🗑',
|
trash: '🗑',
|
||||||
memory: '🧠',
|
memory: '🧠',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
type WritableSubdir = 'scripts' | 'browser-macros' | 'templates';
|
type WritableSubdir = 'browser-macros';
|
||||||
|
|
||||||
interface NewFileFormProps {
|
interface NewFileFormProps {
|
||||||
subdir: WritableSubdir;
|
subdir: WritableSubdir;
|
||||||
@ -11,23 +11,6 @@ interface NewFileFormProps {
|
|||||||
const TODAY = new Date().toISOString().slice(0, 10);
|
const TODAY = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const SKELETON: Record<WritableSubdir, { ext: string; body: string }> = {
|
const SKELETON: Record<WritableSubdir, { ext: string; body: string }> = {
|
||||||
scripts: {
|
|
||||||
ext: '.js',
|
|
||||||
body: `---
|
|
||||||
description: <short summary>
|
|
||||||
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': {
|
'browser-macros': {
|
||||||
ext: '.js',
|
ext: '.js',
|
||||||
body: `---
|
body: `---
|
||||||
@ -45,27 +28,12 @@ export async function main({ context, params }) {
|
|||||||
// await page.goto(params.url);
|
// await page.goto(params.url);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
`,
|
|
||||||
},
|
|
||||||
templates: {
|
|
||||||
ext: '.md',
|
|
||||||
body: `---
|
|
||||||
description: <short summary>
|
|
||||||
params:
|
|
||||||
# title: { type: string, required: true }
|
|
||||||
---
|
|
||||||
|
|
||||||
# {{title}}
|
|
||||||
|
|
||||||
Content here.
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBDIR_LABEL: Record<WritableSubdir, string> = {
|
const SUBDIR_LABEL: Record<WritableSubdir, string> = {
|
||||||
scripts: 'スクリプト',
|
|
||||||
'browser-macros': 'ブラウザマクロ',
|
'browser-macros': 'ブラウザマクロ',
|
||||||
templates: 'テンプレート',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) {
|
export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { NotesPanel } from './NotesPanel';
|
|||||||
import { SubscriptionsPanel } from './SubscriptionsPanel';
|
import { SubscriptionsPanel } from './SubscriptionsPanel';
|
||||||
import { SkillsPanel } from './SkillsPanel';
|
import { SkillsPanel } from './SkillsPanel';
|
||||||
/** All subdirs shown in the tree — both real file-based and virtual. */
|
/** 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 }[] = [
|
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。',
|
desc: 'タスク起動時に system prompt へ自動注入される、ユーザー専用の永続的な指示。 「常に丁寧な日本語で答える」「Tailwind を優先」等、毎タスクで覚えて欲しい好み・ルールを書く。 最大 64KB。 ファイル形式は markdown。',
|
||||||
agency: 'ユーザー編集 / エージェントが自動参照',
|
agency: 'ユーザー編集 / エージェントが自動参照',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'scripts',
|
|
||||||
icon: '📜',
|
|
||||||
title: 'scripts/',
|
|
||||||
desc: 'AI 生成の汎用 Node スクリプト。エージェントが RunUserScript ツールで実行 (kind: "script")。Chromium 起動なし、main({ params }) シグネチャ。データ整形・API 呼び出し・計算・ファイル変換等の繰り返し処理に向く。',
|
|
||||||
agency: 'エージェント/ユーザー両方 / 軽量・高速',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'browser-macros',
|
id: 'browser-macros',
|
||||||
icon: '🤖',
|
icon: '🤖',
|
||||||
title: 'browser-macros/',
|
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 → スクリプト化',
|
agency: 'エージェント実行 / UI で recordings → スクリプト化',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'templates',
|
|
||||||
icon: '📄',
|
|
||||||
title: 'templates/',
|
|
||||||
desc: '定型文・雛形の置き場。UI で作成・編集する。エージェントが ReadUserTemplate で本文を読むか、RenderUserTemplate で frontmatter.params の {{var}} を埋めた結果を取得できる。報告書の雛形・メール文面・コードボイラープレート等を貯めておくと、繰り返しタスクで「雛形を埋めて」と指示しやすい。',
|
|
||||||
agency: 'ユーザー作成 / エージェントが ReadUserTemplate / RenderUserTemplate で利用',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'recordings',
|
id: 'recordings',
|
||||||
icon: '🎬',
|
icon: '🎬',
|
||||||
@ -186,7 +172,7 @@ async function apiFolderDelete(subdir: SubdirId, path: string): Promise<void> {
|
|||||||
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
||||||
|
|
||||||
/** Subdirs where users can create new files from the UI */
|
/** Subdirs where users can create new files from the UI */
|
||||||
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['scripts', 'browser-macros', 'templates']);
|
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['browser-macros']);
|
||||||
|
|
||||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||||
|
|
||||||
@ -195,7 +181,7 @@ interface UserFolderTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
|
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
|
||||||
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('scripts');
|
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('browser-macros');
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [editorDirty, setEditorDirty] = useState(false);
|
const [editorDirty, setEditorDirty] = useState(false);
|
||||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||||
@ -475,7 +461,7 @@ export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<NewFileForm
|
<NewFileForm
|
||||||
subdir={selectedSubdir as 'scripts' | 'browser-macros' | 'templates'}
|
subdir={selectedSubdir as 'browser-macros'}
|
||||||
existingFilenames={files.map(f => f.name)}
|
existingFilenames={files.map(f => f.name)}
|
||||||
onCreate={async (filename, skeleton) => {
|
onCreate={async (filename, skeleton) => {
|
||||||
await apiFolderPut(selectedSubdir, filename, skeleton);
|
await apiFolderPut(selectedSubdir, filename, skeleton);
|
||||||
|
|||||||
@ -3,12 +3,12 @@ id: userfolder
|
|||||||
title: User Folder(自分の資産)
|
title: User Folder(自分の資産)
|
||||||
category: advanced
|
category: advanced
|
||||||
order: 90
|
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 は、ユーザーごとに永続化される個人の資産置き場です。エージェントへの恒久指示、共有メモ、自作スクリプト、ブラウザ自動化、ログイン済みセッションなどがここに集まり、タスクをまたいで「あなた仕様」のエージェントを作り込めます。
|
User Folder は、ユーザーごとに永続化される個人の資産置き場です。エージェントへの恒久指示、共有メモ、ブラウザ自動化、ログイン済みセッションなどがここに集まり、タスクをまたいで「あなた仕様」のエージェントを作り込めます。
|
||||||
|
|
||||||
TopBar → **ユーザーフォルダ** タブで開きます。左にサブフォルダのツリー、右にファイルエディタ(または専用パネル)という 2 カラム構成です。
|
TopBar → **ユーザーフォルダ** タブで開きます。左にサブフォルダのツリー、右にファイルエディタ(または専用パネル)という 2 カラム構成です。
|
||||||
|
|
||||||
@ -18,9 +18,7 @@ TopBar → **ユーザーフォルダ** タブで開きます。左にサブフ
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| AGENTS.md | タスク起動時に system prompt へ注入される恒久指示 | ユーザー |
|
| AGENTS.md | タスク起動時に system prompt へ注入される恒久指示 | ユーザー |
|
||||||
| notes/ | 共有可能な Markdown メモ(エージェントが検索・参照) | ユーザー |
|
| notes/ | 共有可能な Markdown メモ(エージェントが検索・参照) | ユーザー |
|
||||||
| scripts/ | 汎用 Node スクリプト(RunUserScript で実行) | ユーザー / エージェント |
|
|
||||||
| browser-macros/ | Playwright ブラウザマクロ | ユーザー / エージェント |
|
| browser-macros/ | Playwright ブラウザマクロ | ユーザー / エージェント |
|
||||||
| templates/ | 定型文・雛形(`{{var}}` プレースホルダ) | ユーザー |
|
|
||||||
| recordings/ | BrowseWeb の操作トレース(JSON) | エージェント |
|
| recordings/ | BrowseWeb の操作トレース(JSON) | エージェント |
|
||||||
| pets/ | Chat 画面に表示するキャラクター | ユーザー |
|
| pets/ | Chat 画面に表示するキャラクター | ユーザー |
|
||||||
| browser-sessions/ | 保存済みログインプロファイル(cookie / storage) | ユーザー |
|
| browser-sessions/ | 保存済みログインプロファイル(cookie / storage) | ユーザー |
|
||||||
@ -55,28 +53,13 @@ TopBar → **ユーザーフォルダ** タブで開きます。左にサブフ
|
|||||||
|
|
||||||
他のエージェントや他ユーザーと共有したい情報を Markdown で書く場所です。visibility(公開範囲)を設定でき、エージェントは `SearchNotes` / `ReadNote` / `WriteNote` でアクセスします。notes はフォルダ階層を持てます(`notes/<folder>/<file>.md`)。
|
他のエージェントや他ユーザーと共有したい情報を Markdown で書く場所です。visibility(公開範囲)を設定でき、エージェントは `SearchNotes` / `ReadNote` / `WriteNote` でアクセスします。notes はフォルダ階層を持てます(`notes/<folder>/<file>.md`)。
|
||||||
|
|
||||||
## scripts/(汎用 Node)
|
|
||||||
|
|
||||||
繰り返し処理を Node スクリプトとして保存します。エージェントは `RunUserScript`(kind: script)で実行します。Chromium は起動せず、`main({ params })` シグネチャです。データ整形・API 呼び出し・計算・ファイル変換などに向きます。
|
|
||||||
|
|
||||||
作成方法:
|
|
||||||
|
|
||||||
1. ユーザーフォルダ → **scripts** → 新規ファイルフォーム
|
|
||||||
2. ファイル名(`.js`)と内容を入力 → 作成
|
|
||||||
|
|
||||||
「○○するスクリプトを作って」とエージェントに頼むと自動生成されることもあります。
|
|
||||||
|
|
||||||
## browser-macros/(Playwright)
|
## 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」でマクロ化することもできます。
|
recordings/ の操作トレースから「Save as Script」でマクロ化することもできます。
|
||||||
|
|
||||||
> scripts/ は外部 API を呼ぶ汎用処理、browser-macros/ は Web UI を操作する処理、と使い分けます。混ぜないこと。
|
> 以前あった scripts/(汎用 Node)と templates/(雛形)は廃止されました。雛形・手順書は **Skills** に、アドホックなコード実行はエージェントの **Bash** ツールに統一されています。
|
||||||
|
|
||||||
## templates/(定型文・雛形)
|
|
||||||
|
|
||||||
`{{var}}` プレースホルダ付きの雛形です。エージェントは `ReadUserTemplate` で本文を読むか、`RenderUserTemplate` で frontmatter の `params` を埋めた結果を取得します。週次レポートや議事録など、定型フォーマットがあるタスクで効きます。
|
|
||||||
|
|
||||||
## browser-sessions/
|
## 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 は専用パネルで操作します
|
- AGENTS.md・browser-sessions・mcp・skills・pets・ssh-connections・Subscribed Notes は専用パネルで操作します
|
||||||
|
|||||||
@ -30,7 +30,7 @@ movement の開始時には、その movement で使えるツールの一覧と
|
|||||||
| レビュー | LLM による一括レビュー | BatchReviewTextWithLLM |
|
| レビュー | LLM による一括レビュー | BatchReviewTextWithLLM |
|
||||||
| スライド | PPTX スライド生成 | AddSlide / BuildPptx / SetTheme |
|
| スライド | PPTX スライド生成 | AddSlide / BuildPptx / SetTheme |
|
||||||
| チェックリスト | タスク内の進捗チェックリスト | CreateChecklist / CheckItem |
|
| チェックリスト | タスク内の進捗チェックリスト | CreateChecklist / CheckItem |
|
||||||
| ノート / テンプレート | 共有ノート・ユーザーテンプレート | SearchNotes / ReadNote / RenderUserTemplate |
|
| ノート | 共有ノートの検索・読み書き | SearchNotes / ReadNote / WriteNote |
|
||||||
| SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend |
|
| SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend |
|
||||||
| オーケストレーション | サブタスクの生成 | SpawnSubTask |
|
| オーケストレーション | サブタスクの生成 | SpawnSubTask |
|
||||||
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
|
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user