From 94ad5adf7386a9b1fac209573e113dca6983f43e Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 7 Feb 2026 17:44:21 -0700 Subject: [PATCH] feat: migrate to pi-agent-core, harden sandbox Migrate from @mariozechner/pi-agent to @mariozechner/pi-agent-core v0.52.8, alongside pi-ai v0.52.8 which adds AWS Bedrock support. Security improvements for workspace sandboxing: - Remove tilde expansion from expandPath() to prevent homedir escape - Add symlink traversal detection in ensureWorkspacePath() - Filesystem tools (read, grep, ls, find) now enforce containment New features for custom model configuration: - api_key field with env var, $VAR, ${VAR}, and !shell command resolution - Optional custom headers support with same value resolution Other changes: - Add comprehensive test suite (bun test) - Improve workspace cleanup on clone/checkout failures - Update AGENTS.md and README.md documentation --- .gitignore | 3 + AGENTS.md | 8 +- README.md | 20 ++ bun.lock | 258 +++++++++++++++++++--- package.json | 7 +- src/agent/model-resolver.ts | 14 +- src/agent/runner.ts | 58 ++++- src/agent/tools/find.ts | 5 +- src/agent/tools/git/blame.ts | 17 +- src/agent/tools/git/checkout.ts | 6 +- src/agent/tools/git/diff.ts | 18 +- src/agent/tools/git/log.ts | 41 ++-- src/agent/tools/git/refs.ts | 35 +-- src/agent/tools/git/show.ts | 17 +- src/agent/tools/grep.ts | 5 +- src/agent/tools/index.ts | 16 +- src/agent/tools/ls.ts | 5 +- src/agent/tools/path-utils.ts | 61 +++++- src/agent/tools/read.ts | 5 +- src/agent/tools/web-fetch.ts | 2 +- src/agent/tools/web-search.ts | 21 +- src/cli/commands/repo.ts | 80 +++---- src/cli/commands/web.ts | 98 ++++----- src/cli/index.ts | 61 ++---- src/cli/output.ts | 20 +- src/cli/parse-args.ts | 56 +++++ src/config/loader.ts | 40 +++- src/config/schema.ts | 23 +- src/util/env.ts | 75 +++++++ src/util/errors.ts | 6 + src/util/path.ts | 16 ++ src/workspace/content.ts | 2 + test/agent-runner.test.ts | 214 +++++++++++++++++++ test/cli-parser.test.ts | 50 +++++ test/config-loader.test.ts | 55 +++++ test/config-validation.test.ts | 206 ++++++++++++++++++ test/expand-home-path.test.ts | 44 ++++ test/git-log-validation.test.ts | 56 +++++ test/git-tools.test.ts | 131 ++++++++++++ test/model-resolver.test.ts | 70 ++++++ test/web-search.test.ts | 23 ++ test/workspace-cleanup.test.ts | 139 ++++++++++++ test/workspace-containment.test.ts | 330 +++++++++++++++++++++++++++++ tsconfig.json | 2 +- 44 files changed, 2154 insertions(+), 265 deletions(-) create mode 100644 .gitignore create mode 100644 src/cli/parse-args.ts create mode 100644 src/util/env.ts create mode 100644 src/util/path.ts create mode 100644 test/agent-runner.test.ts create mode 100644 test/cli-parser.test.ts create mode 100644 test/config-loader.test.ts create mode 100644 test/config-validation.test.ts create mode 100644 test/expand-home-path.test.ts create mode 100644 test/git-log-validation.test.ts create mode 100644 test/git-tools.test.ts create mode 100644 test/model-resolver.test.ts create mode 100644 test/web-search.test.ts create mode 100644 test/workspace-cleanup.test.ts create mode 100644 test/workspace-containment.test.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..69192d043ffdda16a1d115980ae693cd50b1936a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ + +dist/ diff --git a/AGENTS.md b/AGENTS.md index 921d5e41097a64de6fa0952d4221057275e7f677..55d24f57850c90388bacbde91bbcbb5bf9efd788 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ bun run build # Build to dist/ bun run typecheck # TypeScript check (also: bun run lint) ``` -No test suite currently exists (`test/` is empty). +Run `bun test` to execute the test suite. ## Architecture @@ -48,10 +48,12 @@ Tools use `@sinclair/typebox` for parameter schemas. Execute functions return `{ ### Workspace Sandboxing -Tools must constrain paths to workspace: +Filesystem tools (`read`, `grep`, `ls`, `find`) must constrain paths to workspace: - `ensureWorkspacePath()` in `src/agent/tools/index.ts` validates paths don't escape - `resolveToCwd()` / `resolveReadPath()` in `src/agent/tools/path-utils.ts` handle expansion and normalization +Git tools (`git_show`, `git_blame`, `git_diff`, `git_checkout`, `git_log`, `git_refs`) do **not** apply path containment. Refs and paths are passed directly to `simple-git`, which is initialized with `workspacePath` so all commands are scoped to the cloned repository. The user explicitly chooses which repository to clone, so its git objects are trusted content. This is an accepted trust boundary: we sandbox the filesystem but trust git data within the user's chosen repo. + ### Config Cascade ``` @@ -64,6 +66,8 @@ Config uses TOML, validated against TypeBox schema (`src/config/schema.ts`). Model strings use `provider:model` format. `custom:name` prefix looks up custom model definitions from config's `[custom_models]` section. Built-in providers delegate to `@mariozechner/pi-ai`. +API key resolution for custom models uses `resolveConfigValue()` from `src/util/env.ts`, which supports bare env var names, `$VAR` / `${VAR}` references, and `!shell-command` execution. Built-in providers fall back to `pi-ai`'s `getEnvApiKey()` (e.g. `ANTHROPIC_API_KEY`). + ### Error Handling Custom error classes in `src/util/errors.ts` extend `RumiloError` with error codes: diff --git a/README.md b/README.md index 69f41b969c7ad215dc6124cadb7a58a14e638191..46852a72bc50c479719d9abbab00681eb4427ccb 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ You can define custom OpenAI-compatible endpoints like Ollama, vLLM, or self-hos provider = "ollama" api = "openai-completions" base_url = "http://localhost:11434/v1" +api_key = "ollama" id = "ollama/llama3" name = "Llama 3 (Ollama)" reasoning = false @@ -60,6 +61,7 @@ rumilo repo -u "query" --model custom:ollama - `provider`: Provider identifier (e.g., "ollama", "custom") - `api`: API type - typically "openai-completions" - `base_url`: API endpoint URL +- `api_key`: API key (see value resolution below) - `id`: Unique model identifier - `name`: Human-readable display name - `reasoning`: Whether the model supports thinking/reasoning @@ -67,6 +69,24 @@ rumilo repo -u "query" --model custom:ollama - `cost`: Cost per million tokens (can use 0 for local models) - `context_window`: Maximum context size in tokens - `max_tokens`: Maximum output tokens +- `headers`: Optional custom HTTP headers (values support same resolution as `api_key`) + +#### Value Resolution + +The `api_key` and `headers` fields support three formats, following [pi-coding-agent conventions](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md): + +- **Environment variable name:** bare name is checked as env var, then used as literal + ```toml + api_key = "MY_API_KEY" # resolves process.env.MY_API_KEY, or literal "MY_API_KEY" + ``` +- **Env var reference:** explicit `$VAR` or `${VAR}` + ```toml + api_key = "$MY_API_KEY" # always resolves from env + ``` +- **Shell command:** `!command` executes and uses stdout + ```toml + api_key = "!security find-generic-password -ws 'my-api'" + ``` #### Compatibility Flags (Optional) diff --git a/bun.lock b/bun.lock index 4d2f606d7d0c777e44de01c18102c9ba29988b1a..849e3396e1fbe04e78ca0dcdfea7373b1cbd8435 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "rumilo", "dependencies": { - "@mariozechner/pi-agent": "^0.9.0", - "@mariozechner/pi-ai": "^0.6.1", + "@mariozechner/pi-agent-core": "^0.52.8", + "@mariozechner/pi-ai": "^0.52.8", "@sinclair/typebox": "^0.32.14", "@tabstack/sdk": "^2.1.0", "kagi-ken": "github:czottmann/kagi-ken#1.2.0", @@ -21,7 +21,77 @@ }, }, "packages": { - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.61.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.985.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-node": "^3.972.6", "@aws-sdk/eventstream-handler-node": "^3.972.5", "@aws-sdk/middleware-eventstream": "^3.972.3", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/middleware-websocket": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/token-providers": "3.985.0", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/eventstream-serde-config-resolver": "^4.3.8", "@smithy/eventstream-serde-node": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jkQ+G+b/6Z6gUsn8jNSjJsFVgxnA4HtyOjrpHfmp8nHWLRFTOIw3HfY2vAlDgg/uUJ7cezVG0/tmbwujFqX25A=="], + + "@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.985.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.973.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/xml-builder": "^3.972.4", "@smithy/core": "^3.22.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.7", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-login": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.6", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.5", "@aws-sdk/credential-provider-http": "^3.972.7", "@aws-sdk/credential-provider-ini": "^3.972.5", "@aws-sdk/credential-provider-process": "^3.972.5", "@aws-sdk/credential-provider-sso": "^3.972.5", "@aws-sdk/credential-provider-web-identity": "^3.972.5", "@aws-sdk/types": "^3.973.1", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.5", "", { "dependencies": { "@aws-sdk/client-sso": "3.985.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/token-providers": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.5", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/eventstream-codec": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.7", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@smithy/core": "^3.22.1", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-format-url": "^3.972.3", "@smithy/eventstream-codec": "^4.2.8", "@smithy/eventstream-serde-browser": "^4.2.8", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/protocol-http": "^5.3.8", "@smithy/signature-v4": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-BN4A9K71WRIlpQ3+IYGdBC2wVyobZ95g6ZomodmJ8Te772GWo0iDk2Mv6JIHdr842tOTgi1b3npLIFDUS4hl4g=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.985.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.985.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.5", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.1", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-retry": "^4.4.30", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.9", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.29", "@smithy/util-defaults-mode-node": "^4.2.32", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.985.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.7", "@aws-sdk/nested-clients": "3.985.0", "@aws-sdk/types": "^3.973.1", "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.985.0", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" } }, "sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.972.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.7", "@aws-sdk/types": "^3.973.1", "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.4", "", { "dependencies": { "@smithy/types": "^4.12.0", "fast-xml-parser": "5.3.4", "tslib": "^2.6.2" } }, "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], "@google/genai": ["@google/genai@1.40.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA=="], @@ -31,11 +101,11 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], - "@mariozechner/pi-agent": ["@mariozechner/pi-agent@0.9.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.9.0", "@mariozechner/pi-tui": "^0.9.0" } }, "sha512-VS53eCoyn3vSeqFGzWPV4BoN2ag+FjjFsx4ApLolxgAaLH2uQUEh1V8fHXeuG7j2Cjyr6ZiMY6I1HwX1JTs1ng=="], + "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.52.8", "", { "dependencies": { "@mariozechner/pi-ai": "^0.52.8" } }, "sha512-oI563VL+JInc3L9n3PyA5wouUWrhNjXjB28o2FXnfyL7OngGgMIf4+bbvMjX7AdF8dZhUeskCftozZQZS5T0xQ=="], - "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.6.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.17.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-sVuNRo7j2AL+dk2RQrjVa6+j5Hf+5wFssJoRs0EpSbaVlLveiEAXOYx8ajryirTvzzpAPzbOX4S/UoJiqDh1vQ=="], + "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.52.8", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.10.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-+aFCUbKJcskDJhr9wPcMBTy0x/xWio5v1dkxRYXUBPWp+Zt9DSdT5Kmd/IIQ+a0TOZDF4ajt4GY/oAw37X7XTw=="], - "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.9.4", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", "string-width": "^8.1.0" } }, "sha512-jEuqehUniAnJsaiwRIkhHO4APWGZJqoxWgayj02MhSSKUhwi9M+KiqvV8I0Mfgy+4YuZiqY0F9ld/mn1XWoj/w=="], + "@mistralai/mistralai": ["@mistralai/mistralai@1.10.0", "", { "dependencies": { "zod": "^3.20.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -61,9 +131,99 @@ "@sinclair/typebox": ["@sinclair/typebox@0.32.35", "", {}, "sha512-Ul3YyOTU++to8cgNkttakC0dWvpERr6RYoHO2W47DLbFvrwBDJUY31B1sImH6JZSYc4Kt4PyHtoPNu+vL2r2dA=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="], + + "@smithy/core": ["@smithy/core@3.22.1", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.11", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], + + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.8", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw=="], + + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ=="], + + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.8", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A=="], + + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.8", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.13", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.30", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.8", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.9", "", { "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/querystring-builder": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w=="], + + "@smithy/property-provider": ["@smithy/property-provider@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ=="], + + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw=="], + + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA=="], + + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0" } }, "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ=="], + + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.3", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.11.2", "", { "dependencies": { "@smithy/core": "^3.22.1", "@smithy/middleware-endpoint": "^4.4.13", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.11", "tslib": "^2.6.2" } }, "sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A=="], + + "@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.2.8", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="], + + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.29", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.32", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.2", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.2.8", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg=="], + + "@smithy/util-stream": ["@smithy/util-stream@4.5.11", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", "@smithy/node-http-handler": "^4.4.9", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA=="], + + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="], + + "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@tabstack/sdk": ["@tabstack/sdk@2.1.0", "", {}, "sha512-gKqQDo+UaY2E5XjQWvFNmuQCuUuYxAbv+JBhfaP5EdWA0ho0fq2EOZ+pMVJ3ALzHVF50RqmKJlogaDwNq42OjA=="], - "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@types/node": ["@types/node@22.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A=="], @@ -77,14 +237,20 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -107,10 +273,12 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -129,12 +297,22 @@ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-xml-parser": ["fast-xml-parser@5.3.4", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -145,7 +323,7 @@ "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], @@ -157,10 +335,14 @@ "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -169,6 +351,8 @@ "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -179,13 +363,7 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -193,13 +371,19 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "openai": ["openai@5.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-E9LuV51vgvwbahPJaZu2x4V6SWMq9g3X6Bj2/wnFiNfV7lmAxYVxPxcQNZqCWbAVMaEoers9HzIxpOp6Vvgn8w=="], + "openai": ["openai@6.10.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -217,6 +401,10 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], @@ -233,7 +421,15 @@ "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], - "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -241,8 +437,14 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="], @@ -263,40 +465,48 @@ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@mariozechner/pi-agent/@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.9.4", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.30.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-xI2bnh0LgNBSszDqzXxM3aMPB23hhM5r5xtJaj1KNPVpDP17fTGPcpfO3/4xOIlDYGOvc+OC4778suGRYwwbsg=="], + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@mariozechner/pi-agent/@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], } } diff --git a/package.json b/package.json index 1bc72bb965a0910193bc9dc59cf1aa88556b9b20..9ca8f16af8ff8c2ffd44db1001db14e490a793d1 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,19 @@ "private": true, "type": "module", "bin": { - "rumilo": "./dist/cli/index.js" + "rumilo": "./dist/index.js" }, "scripts": { "dev": "bun src/cli/index.ts", "build": "bun build src/cli/index.ts --outdir dist --target=node", "start": "bun dist/cli/index.js", "lint": "bun run --silent typecheck", + "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { - "@mariozechner/pi-ai": "^0.6.1", - "@mariozechner/pi-agent": "^0.9.0", + "@mariozechner/pi-agent-core": "^0.52.8", + "@mariozechner/pi-ai": "^0.52.8", "@sinclair/typebox": "^0.32.14", "@tabstack/sdk": "^2.1.0", "kagi-ken": "github:czottmann/kagi-ken#1.2.0", diff --git a/src/agent/model-resolver.ts b/src/agent/model-resolver.ts index 556b9cc4dc96a6d4963f9925f4bf410682989bb2..b989a347cb0b9e6a01c7da45af94543b82f3627a 100644 --- a/src/agent/model-resolver.ts +++ b/src/agent/model-resolver.ts @@ -4,12 +4,19 @@ import { type RumiloConfig, } from "../config/schema.js"; import { ConfigError } from "../util/errors.js"; +import { resolveHeaders } from "../util/env.js"; export function resolveModel( modelString: string, config: RumiloConfig, ): Model { - const [provider, modelName] = modelString.split(":"); + const colonIndex = modelString.indexOf(":"); + if (colonIndex === -1) { + throw new ConfigError("Model must be in provider:model format"); + } + + const provider = modelString.slice(0, colonIndex); + const modelName = modelString.slice(colonIndex + 1); if (!provider || !modelName) { throw new ConfigError("Model must be in provider:model format"); @@ -71,8 +78,9 @@ function buildCustomModel(config: CustomModelConfig): Model { maxTokens: config.max_tokens, }; - if (config.headers) { - model.headers = config.headers; + const resolvedHeaders = resolveHeaders(config.headers); + if (resolvedHeaders) { + model.headers = resolvedHeaders; } if (config.compat) { diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 5ed58733cd0fd8e2c635b492c3427d19cb47bfe7..2445e26681e29728e6a849ccf03e178d8c4418af 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,8 +1,9 @@ -import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent"; -import { type AgentTool } from "@mariozechner/pi-ai"; -import { ToolInputError } from "../util/errors.js"; +import { Agent, type AgentEvent, type AgentTool } from "@mariozechner/pi-agent-core"; +import { getEnvApiKey, type AssistantMessage } from "@mariozechner/pi-ai"; import type { RumiloConfig } from "../config/schema.js"; import { resolveModel } from "./model-resolver.js"; +import { AgentError } from "../util/errors.js"; +import { resolveConfigValue } from "../util/env.js"; export interface AgentRunOptions { model: string; @@ -15,6 +16,30 @@ export interface AgentRunOptions { export interface AgentRunResult { message: string; usage?: unknown; + requestCount: number; +} + +/** + * Build a getApiKey callback for the Agent. + * + * Resolution order: + * 1. Custom model config — if a custom model for this provider defines an + * `apiKey` field, resolve it via `resolveConfigValue` (supports env var + * names, `$VAR` references, and `!shell` commands). + * 2. pi-ai’s built-in env-var lookup (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.). + */ +export function buildGetApiKey(config: RumiloConfig): (provider: string) => string | undefined { + return (provider: string) => { + if (config.custom_models) { + for (const model of Object.values(config.custom_models)) { + if (model.provider === provider && model.api_key) { + return resolveConfigValue(model.api_key); + } + } + } + + return getEnvApiKey(provider); + }; } export async function runAgent(query: string, options: AgentRunOptions): Promise { @@ -24,7 +49,7 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise model: resolveModel(options.model, options.config), tools: options.tools, }, - transport: new ProviderTransport(), + getApiKey: buildGetApiKey(options.config), }); if (options.onEvent) { @@ -33,19 +58,36 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise await agent.prompt(query); + // Check for errors in agent state + if (agent.state.error) { + throw new AgentError(agent.state.error); + } + const last = agent.state.messages .slice() .reverse() - .find((msg) => msg.role === "assistant"); + .find((msg): msg is AssistantMessage => msg.role === "assistant"); + + // Check if the last assistant message indicates an error + if (last?.stopReason === "error") { + throw new AgentError(last.errorMessage ?? "Agent stopped with an unknown error"); + } const text = last?.content - ?.filter((content) => content.type === "text") + ?.filter((content): content is Extract => content.type === "text") .map((content) => content.text) .join("") .trim(); + if (text === undefined || text === "") { + throw new AgentError("Agent returned no text response"); + } + + const requestCount = agent.state.messages.filter((msg) => msg.role === "assistant").length; + return { - message: text ?? "", - usage: (last as any)?.usage, + message: text, + usage: last?.usage, + requestCount, }; } diff --git a/src/agent/tools/find.ts b/src/agent/tools/find.ts index d67dd063f47c45bcfc913664c080a6c2d0170e7b..0266686fd5a386fda7cb0e45b1a0c92082f8e3ed 100644 --- a/src/agent/tools/find.ts +++ b/src/agent/tools/find.ts @@ -1,8 +1,8 @@ import { spawnSync } from "node:child_process"; import { relative } from "node:path"; import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; -import { resolveToCwd } from "./path-utils.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js"; import { ToolInputError } from "../../util/errors.js"; @@ -28,6 +28,7 @@ export const createFindTool = (workspacePath: string): AgentTool => { const searchDir: string = params.path || "."; const effectiveLimit = params.limit ?? DEFAULT_LIMIT; const searchPath = resolveToCwd(searchDir, workspacePath); + ensureWorkspacePath(workspacePath, searchPath); const args = [ "--glob", diff --git a/src/agent/tools/git/blame.ts b/src/agent/tools/git/blame.ts index 8cfbc6b3ba46d2876f3fd5c7978133fca485049b..3c3c6c24dcfbfecaf8dc483d6b9753a1e78cdde2 100644 --- a/src/agent/tools/git/blame.ts +++ b/src/agent/tools/git/blame.ts @@ -1,7 +1,12 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { formatSize, truncateHead } from "../../../util/truncate.js"; + +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. const BlameSchema = Type.Object({ path: Type.String({ description: "File path relative to repo root" }), @@ -17,11 +22,17 @@ export const createGitBlameTool = (workspacePath: string): AgentTool => ({ throw new ToolInputError("path must be a non-empty string"); } const git = simpleGit(workspacePath); - const text = await git.raw(["blame", "--", params.path]); + const raw = await git.raw(["blame", "--", params.path]); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } return { content: [{ type: "text", text }], - details: { path: params.path }, + details: { path: params.path, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/git/checkout.ts b/src/agent/tools/git/checkout.ts index da3ee6895ca2b17380388d3299c99156af0e3660..ed3c1b6ff9ae249ec2bfc65ccb093e00a9711383 100644 --- a/src/agent/tools/git/checkout.ts +++ b/src/agent/tools/git/checkout.ts @@ -1,8 +1,12 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. + const CheckoutSchema = Type.Object({ ref: Type.String({ description: "Ref to checkout" }), }); diff --git a/src/agent/tools/git/diff.ts b/src/agent/tools/git/diff.ts index 56773c2fa75c861a08eeff9ddc1647253c2e61a1..528c87d90806b14a5288de686c9ae0077c92730b 100644 --- a/src/agent/tools/git/diff.ts +++ b/src/agent/tools/git/diff.ts @@ -1,7 +1,12 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { formatSize, truncateHead } from "../../../util/truncate.js"; + +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. const DiffSchema = Type.Object({ ref: Type.Optional(Type.String({ description: "Base ref (optional)" })), @@ -31,10 +36,17 @@ export const createGitDiffTool = (workspacePath: string): AgentTool => ({ if (params.ref2) args.push(params.ref2); if (params.path) args.push("--", params.path); - const text = await git.diff(args); + const raw = await git.diff(args); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + return { content: [{ type: "text", text }], - details: { path: params.path ?? null }, + details: { path: params.path ?? null, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/git/log.ts b/src/agent/tools/git/log.ts index 56fe195dbcf134901d4c302c27184cdbe0e46443..68ab169085338fe1e83f4323247094f38c8e79e1 100644 --- a/src/agent/tools/git/log.ts +++ b/src/agent/tools/git/log.ts @@ -1,8 +1,14 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. + +const DEFAULT_LOG_LIMIT = 20; + const LogSchema = Type.Object({ path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })), author: Type.Optional(Type.String({ description: "Filter by author name/email" })), @@ -21,25 +27,30 @@ export const createGitLogTool = (workspacePath: string): AgentTool => ({ const git = simpleGit(workspacePath); const options: string[] = []; - if (params.n !== undefined) { - if (typeof params.n !== "number" || Number.isNaN(params.n) || params.n <= 0) { - throw new ToolInputError("n must be a positive number"); - } - options.push("-n", String(Math.floor(params.n))); + const limit = params.n !== undefined ? params.n : DEFAULT_LOG_LIMIT; + if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) { + throw new ToolInputError("n must be a positive number"); } + options.push("-n", String(Math.floor(limit))); if (params.oneline) options.push("--oneline"); - if (params.author && !String(params.author).trim()) { - throw new ToolInputError("author must be a non-empty string"); + if (params.author !== undefined) { + if (!String(params.author).trim()) { + throw new ToolInputError("author must be a non-empty string"); + } + options.push(`--author=${params.author}`); } - if (params.author) options.push(`--author=${params.author}`); - if (params.since && !String(params.since).trim()) { - throw new ToolInputError("since must be a non-empty string"); + if (params.since !== undefined) { + if (!String(params.since).trim()) { + throw new ToolInputError("since must be a non-empty string"); + } + options.push(`--since=${params.since}`); } - if (params.since) options.push(`--since=${params.since}`); - if (params.until && !String(params.until).trim()) { - throw new ToolInputError("until must be a non-empty string"); + if (params.until !== undefined) { + if (!String(params.until).trim()) { + throw new ToolInputError("until must be a non-empty string"); + } + options.push(`--until=${params.until}`); } - if (params.until) options.push(`--until=${params.until}`); const result = await git.log(options.concat(params.path ? ["--", params.path] : [])); diff --git a/src/agent/tools/git/refs.ts b/src/agent/tools/git/refs.ts index 2c87551a4e1fe2f1e7e56a6f5b21b0382e8ccbb1..362548b3aba689bb717bd6dfd5f1d216102f5b90 100644 --- a/src/agent/tools/git/refs.ts +++ b/src/agent/tools/git/refs.ts @@ -1,6 +1,11 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; +import { formatSize, truncateHead } from "../../../util/truncate.js"; + +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. const RefsSchema = Type.Object({ type: Type.Union([ @@ -18,26 +23,28 @@ export const createGitRefsTool = (workspacePath: string): AgentTool => ({ execute: async (_toolCallId: string, params: any) => { const git = simpleGit(workspacePath); + let raw: string; + let baseDetails: Record = {}; + if (params.type === "tags") { const tags = await git.tags(); - return { - content: [{ type: "text", text: tags.all.join("\n") }], - details: { count: tags.all.length }, - }; + raw = tags.all.join("\n"); + baseDetails = { count: tags.all.length }; + } else if (params.type === "remotes") { + raw = await git.raw(["branch", "-r"]); + } else { + raw = await git.raw(["branch", "-a"]); } - if (params.type === "remotes") { - const raw = await git.raw(["branch", "-r"]); - return { - content: [{ type: "text", text: raw }], - details: {}, - }; + const truncation = truncateHead(raw); + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; } - const raw = await git.raw(["branch", "-a"]); return { - content: [{ type: "text", text: raw }], - details: {}, + content: [{ type: "text", text }], + details: { ...baseDetails, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/git/show.ts b/src/agent/tools/git/show.ts index 9a60b0a6a63f2aea84db3c762b37f393bfc6bc29..0df8a79c9dc07baf242aed9988e02764a4ebdacd 100644 --- a/src/agent/tools/git/show.ts +++ b/src/agent/tools/git/show.ts @@ -1,7 +1,12 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import simpleGit from "simple-git"; import { ToolInputError } from "../../../util/errors.js"; +import { formatSize, truncateHead } from "../../../util/truncate.js"; + +// Trust boundary: refs and paths are passed directly to simple-git, which is +// scoped to the workspace. The user chose to clone this repo, so its contents +// are trusted. See AGENTS.md § Workspace Sandboxing. const ShowSchema = Type.Object({ ref: Type.String({ description: "Commit hash or ref" }), @@ -17,11 +22,17 @@ export const createGitShowTool = (workspacePath: string): AgentTool => ({ throw new ToolInputError("ref must be a non-empty string"); } const git = simpleGit(workspacePath); - const text = await git.show([params.ref]); + const raw = await git.show([params.ref]); + const truncation = truncateHead(raw); + + let text = truncation.content; + if (truncation.truncated) { + text += `\n\n[truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } return { content: [{ type: "text", text }], - details: { ref: params.ref }, + details: { ref: params.ref, ...(truncation.truncated ? { truncation } : {}) }, }; }, }); diff --git a/src/agent/tools/grep.ts b/src/agent/tools/grep.ts index a27a3a438a228999afbee3fbd82c72cd6d5b7eb4..66b4f52edfbf4a001df0d9f94357480fd9736c28 100644 --- a/src/agent/tools/grep.ts +++ b/src/agent/tools/grep.ts @@ -3,8 +3,8 @@ import { createInterface } from "node:readline"; import { readFileSync, statSync } from "node:fs"; import { relative, basename } from "node:path"; import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; -import { resolveToCwd } from "./path-utils.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, @@ -43,6 +43,7 @@ export const createGrepTool = (workspacePath: string): AgentTool => { execute: async (_toolCallId: string, params: any) => { const searchDir: string | undefined = params.path; const searchPath = resolveToCwd(searchDir || ".", workspacePath); + ensureWorkspacePath(workspacePath, searchPath); let isDirectory = false; try { isDirectory = statSync(searchPath).isDirectory(); diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 5e803d0ea06cccbedcdd62b4497e9757475cd16d..5d0bd1583ed7abb3bdd2576789b0765f2ffd8825 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -1,6 +1,4 @@ -import { resolve, sep } from "node:path"; -import type { AgentTool } from "@mariozechner/pi-ai"; -import { ToolInputError } from "../../util/errors.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; export type ToolFactory = (workspacePath: string) => AgentTool; @@ -9,17 +7,7 @@ export interface ToolBundle { tools: AgentTool[]; } -export function ensureWorkspacePath(workspacePath: string, targetPath: string): string { - const resolved = resolve(workspacePath, targetPath); - const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`; - - if (resolved === workspacePath || resolved.startsWith(root)) { - return resolved; - } - - throw new ToolInputError(`Path escapes workspace: ${targetPath}`); -} - +export { ensureWorkspacePath } from "./path-utils.js"; export { createReadTool } from "./read.js"; export { createGrepTool } from "./grep.js"; export { createLsTool } from "./ls.js"; diff --git a/src/agent/tools/ls.ts b/src/agent/tools/ls.ts index 4546a1516c6be1e8073530c8678dec9c7dbc7cba..4fbd84fe9a3c8335168d38d47d9a34a59b699174 100644 --- a/src/agent/tools/ls.ts +++ b/src/agent/tools/ls.ts @@ -1,8 +1,8 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; -import { resolveToCwd } from "./path-utils.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js"; const DEFAULT_LIMIT = 500; @@ -19,6 +19,7 @@ export const createLsTool = (workspacePath: string): AgentTool => ({ parameters: LsSchema as any, execute: async (_toolCallId: string, params: any) => { const resolved = resolveToCwd(params.path || ".", workspacePath); + ensureWorkspacePath(workspacePath, resolved); if (!existsSync(resolved)) { throw new Error(`Path does not exist: ${params.path || "."}`); diff --git a/src/agent/tools/path-utils.ts b/src/agent/tools/path-utils.ts index e4412f8269ccfa71b41e50d65d365e1b631c232e..cb6209a736a82186efd06ff9275135626cb9074a 100644 --- a/src/agent/tools/path-utils.ts +++ b/src/agent/tools/path-utils.ts @@ -1,6 +1,6 @@ -import { accessSync, constants } from "node:fs"; -import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { accessSync, constants, realpathSync } from "node:fs"; +import { dirname, isAbsolute, resolve as resolvePath, sep } from "node:path"; +import { ToolInputError } from "../../util/errors.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; @@ -33,12 +33,10 @@ export function expandPath(filePath: string): string { let result = filePath.replace(UNICODE_SPACES, " "); result = normalizeAtPrefix(result); - if (result === "~") { - return os.homedir(); - } - if (result.startsWith("~/")) { - return resolvePath(os.homedir(), result.slice(2)); - } + // NOTE: tilde expansion is intentionally omitted. + // In a workspace-sandboxed context, expanding ~ to the user's home + // directory would bypass workspace containment. Tildes are treated + // as literal path characters. return result; } @@ -68,3 +66,48 @@ export function resolveReadPath(filePath: string, cwd: string): string { return resolved; } + +/** + * Resolve the real path of `p`, following symlinks. If `p` does not exist, + * walk up to the nearest existing ancestor, resolve *that*, and re-append + * the remaining segments. This lets us validate write targets that don't + * exist yet while still catching symlink escapes in any ancestor directory. + */ +function safeRealpath(p: string): string { + try { + return realpathSync(p); + } catch (err: any) { + if (err?.code === "ENOENT") { + const parent = dirname(p); + if (parent === p) { + // filesystem root — nothing more to resolve + return p; + } + const realParent = safeRealpath(parent); + const tail = p.slice(parent.length); + return realParent + tail; + } + throw err; + } +} + +export function ensureWorkspacePath(workspacePath: string, targetPath: string): string { + const resolved = resolvePath(workspacePath, targetPath); + + // Quick textual check first (catches the common case cheaply) + const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`; + if (resolved !== workspacePath && !resolved.startsWith(root)) { + throw new ToolInputError(`Path escapes workspace: ${targetPath}`); + } + + // Resolve symlinks to catch symlink-based escapes + const realWorkspace = safeRealpath(workspacePath); + const realTarget = safeRealpath(resolved); + const realRoot = realWorkspace.endsWith(sep) ? realWorkspace : `${realWorkspace}${sep}`; + + if (realTarget !== realWorkspace && !realTarget.startsWith(realRoot)) { + throw new ToolInputError(`Path escapes workspace via symlink: ${targetPath}`); + } + + return resolved; +} diff --git a/src/agent/tools/read.ts b/src/agent/tools/read.ts index 0630a23457c1d4cbb2364d58dba77427be7550a0..2a6dfd00f29e608190dafa26de79b690d43f40dd 100644 --- a/src/agent/tools/read.ts +++ b/src/agent/tools/read.ts @@ -1,7 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; -import { resolveReadPath } from "./path-utils.js"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveReadPath, ensureWorkspacePath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js"; import { ToolInputError } from "../../util/errors.js"; @@ -20,6 +20,7 @@ export const createReadTool = (workspacePath: string): AgentTool => ({ parameters: ReadSchema as any, execute: async (_toolCallId: string, params: any) => { const absolutePath = resolveReadPath(params.path, workspacePath); + ensureWorkspacePath(workspacePath, absolutePath); const fileStats = await stat(absolutePath); if (fileStats.size > MAX_READ_BYTES) { diff --git a/src/agent/tools/web-fetch.ts b/src/agent/tools/web-fetch.ts index 141cbe0d691699ed91567b5f2023ac005f0e8a68..202a0675d94221be779041cd39e8512283d7c84e 100644 --- a/src/agent/tools/web-fetch.ts +++ b/src/agent/tools/web-fetch.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import Tabstack from "@tabstack/sdk"; import { FetchError, ToolInputError } from "../../util/errors.js"; diff --git a/src/agent/tools/web-search.ts b/src/agent/tools/web-search.ts index 77dd047f2da47ee1a0c3ea231bfc620180b42b20..ac7b10be61726720c6d3ddd3b7045cd250cea483 100644 --- a/src/agent/tools/web-search.ts +++ b/src/agent/tools/web-search.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; -import type { AgentTool } from "@mariozechner/pi-ai"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; import { search } from "kagi-ken"; -import { ToolInputError } from "../../util/errors.js"; +import { FetchError, ToolInputError } from "../../util/errors.js"; const SearchSchema = Type.Object({ query: Type.String({ description: "Search query" }), @@ -17,10 +17,17 @@ export const createWebSearchTool = (sessionToken: string): AgentTool => ({ throw new ToolInputError("Missing Kagi session token"); } - const result = await search(params.query, sessionToken); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - details: { query: params.query, resultCount: result?.data?.length ?? 0 }, - }; + try { + const result = await search(params.query, sessionToken); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: { query: params.query, resultCount: result?.data?.length ?? 0 }, + }; + } catch (error: any) { + throw new FetchError( + `kagi:search?q=${encodeURIComponent(params.query)}`, + error?.message ?? String(error), + ); + } }, }); diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts index 5e9915c79e200b28c4946b2420e1d6728a072b49..e5d1fd7f0ab4802ddc9552cbcb0da0d8309225fa 100644 --- a/src/cli/commands/repo.ts +++ b/src/cli/commands/repo.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { expandHomePath } from "../../util/path.js"; import { applyConfigOverrides, loadConfig } from "../../config/loader.js"; import { createWorkspace } from "../../workspace/manager.js"; import { createGrepTool } from "../../agent/tools/grep.js"; @@ -35,52 +36,51 @@ export async function runRepoCommand(options: RepoCommandOptions): Promise }); const workspace = await createWorkspace({ cleanup: overrides.defaults.cleanup }); - const logger = createEventLogger({ verbose: options.verbose }); - let systemPrompt = REPO_SYSTEM_PROMPT; - const promptPath = overrides.repo.system_prompt_path; - if (promptPath) { - const home = process.env["HOME"] ?? ""; - systemPrompt = await readFile(promptPath.replace(/^~\//, `${home}/`), "utf8"); - } + try { + const logger = createEventLogger({ verbose: options.verbose }); - const git = simpleGit(); - const cloneArgs: string[] = []; - if (!options.full) { - const depth = overrides.repo.default_depth ?? 1; - const blobLimit = overrides.repo.blob_limit ?? "5m"; - cloneArgs.push("--depth", String(depth), `--filter=blob:limit=${blobLimit}`); - } + let systemPrompt = REPO_SYSTEM_PROMPT; + const promptPath = overrides.repo.system_prompt_path; + if (promptPath) { + systemPrompt = await readFile(expandHomePath(promptPath), "utf8"); + } - try { - await git.clone(options.uri, workspace.path, cloneArgs); - } catch (error: any) { - await workspace.cleanup(); - if (!overrides.defaults.cleanup) { - console.error(`Workspace preserved at ${workspace.path}`); + const git = simpleGit(); + const cloneArgs: string[] = []; + if (!options.full) { + const depth = overrides.repo.default_depth ?? 1; + const blobLimit = overrides.repo.blob_limit ?? "5m"; + cloneArgs.push("--depth", String(depth), `--filter=blob:limit=${blobLimit}`); } - throw new CloneError(options.uri, error?.message ?? String(error)); - } - const repoGit = simpleGit(workspace.path); - if (options.ref) { - await repoGit.checkout(options.ref); - } + try { + await git.clone(options.uri, workspace.path, cloneArgs); + } catch (error: any) { + if (!overrides.defaults.cleanup) { + console.error(`Workspace preserved at ${workspace.path}`); + } + throw new CloneError(options.uri, error?.message ?? String(error)); + } - const tools = [ - createReadTool(workspace.path), - createGrepTool(workspace.path), - createLsTool(workspace.path), - createFindTool(workspace.path), - createGitLogTool(workspace.path), - createGitShowTool(workspace.path), - createGitBlameTool(workspace.path), - createGitDiffTool(workspace.path), - createGitRefsTool(workspace.path), - createGitCheckoutTool(workspace.path), - ]; + const repoGit = simpleGit(workspace.path); + if (options.ref) { + await repoGit.checkout(options.ref); + } + + const tools = [ + createReadTool(workspace.path), + createGrepTool(workspace.path), + createLsTool(workspace.path), + createFindTool(workspace.path), + createGitLogTool(workspace.path), + createGitShowTool(workspace.path), + createGitBlameTool(workspace.path), + createGitDiffTool(workspace.path), + createGitRefsTool(workspace.path), + createGitCheckoutTool(workspace.path), + ]; - try { const result = await runAgent(options.query, { model: overrides.repo.model ?? overrides.defaults.model, systemPrompt, @@ -90,7 +90,7 @@ export async function runRepoCommand(options: RepoCommandOptions): Promise }); process.stdout.write(result.message + "\n"); - printUsageSummary(result.usage as any); + printUsageSummary(result.usage as any, result.requestCount); } finally { await workspace.cleanup(); } diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index 13abe98ba9c5c453c7b6df922592517819589e53..1e86b935e953d51553ac144073c35b91e3485ef8 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { basename } from "node:path"; +import { expandHomePath } from "../../util/path.js"; import { applyConfigOverrides, loadConfig } from "../../config/loader.js"; import { createWorkspace } from "../../workspace/manager.js"; import { writeWorkspaceFile } from "../../workspace/content.js"; @@ -12,7 +13,7 @@ import { createWebSearchTool } from "../../agent/tools/web-search.js"; import { runAgent } from "../../agent/runner.js"; import { WEB_SYSTEM_PROMPT } from "../../agent/prompts/web.js"; import { createEventLogger, printUsageSummary } from "../output.js"; -import { FetchError, ToolInputError } from "../../util/errors.js"; +import { ConfigError, FetchError } from "../../util/errors.js"; const INJECT_THRESHOLD = 50 * 1024; @@ -32,62 +33,61 @@ export async function runWebCommand(options: WebCommandOptions): Promise { }); const workspace = await createWorkspace({ cleanup: overrides.defaults.cleanup }); - const logger = createEventLogger({ verbose: options.verbose }); - const kagiSession = - overrides.web.kagi_session_token ?? overrides.defaults.kagi_session_token ?? process.env["KAGI_SESSION_TOKEN"]; - const tabstackKey = - overrides.web.tabstack_api_key ?? - overrides.defaults.tabstack_api_key ?? - process.env["TABSTACK_API_KEY"]; + try { + const logger = createEventLogger({ verbose: options.verbose }); - if (!kagiSession) { - throw new ToolInputError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)"); - } - if (!tabstackKey) { - throw new ToolInputError("Missing Tabstack API key (set TABSTACK_API_KEY or config)"); - } + const kagiSession = + overrides.web.kagi_session_token ?? overrides.defaults.kagi_session_token ?? process.env["KAGI_SESSION_TOKEN"]; + const tabstackKey = + overrides.web.tabstack_api_key ?? + overrides.defaults.tabstack_api_key ?? + process.env["TABSTACK_API_KEY"]; - let systemPrompt = WEB_SYSTEM_PROMPT; - const promptPath = overrides.web.system_prompt_path; - if (promptPath) { - const home = process.env["HOME"] ?? ""; - systemPrompt = await readFile(promptPath.replace(/^~\//, `${home}/`), "utf8"); - } + if (!kagiSession) { + throw new ConfigError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)"); + } + if (!tabstackKey) { + throw new ConfigError("Missing Tabstack API key (set TABSTACK_API_KEY or config)"); + } + + let systemPrompt = WEB_SYSTEM_PROMPT; + const promptPath = overrides.web.system_prompt_path; + if (promptPath) { + systemPrompt = await readFile(expandHomePath(promptPath), "utf8"); + } - const tools = [ - createWebSearchTool(kagiSession), - createWebFetchTool(tabstackKey), - createReadTool(workspace.path), - createGrepTool(workspace.path), - createLsTool(workspace.path), - createFindTool(workspace.path), - ]; + const tools = [ + createWebSearchTool(kagiSession), + createWebFetchTool(tabstackKey), + createReadTool(workspace.path), + createGrepTool(workspace.path), + createLsTool(workspace.path), + createFindTool(workspace.path), + ]; - let seededContext = ""; - if (options.url) { - const fetchTool = createWebFetchTool(tabstackKey); - try { - const result = await fetchTool.execute("prefetch", { url: options.url, nocache: false }); - const text = result.content - .map((block) => (block.type === "text" ? block.text ?? "" : "")) - .join(""); - if (text.length <= INJECT_THRESHOLD) { - seededContext = text; - } else { - const filename = `web/${basename(new URL(options.url).pathname) || "index"}.md`; - await writeWorkspaceFile(workspace.path, filename, text); - seededContext = `Fetched content stored at ${filename}`; + let seededContext = ""; + if (options.url) { + const fetchTool = createWebFetchTool(tabstackKey); + try { + const result = await fetchTool.execute("prefetch", { url: options.url, nocache: false }); + const text = result.content + .map((block) => (block.type === "text" ? block.text ?? "" : "")) + .join(""); + if (text.length <= INJECT_THRESHOLD) { + seededContext = text; + } else { + const filename = `web/${basename(new URL(options.url).pathname) || "index"}.md`; + await writeWorkspaceFile(workspace.path, filename, text); + seededContext = `Fetched content stored at ${filename}`; + } + } catch (error: any) { + throw new FetchError(options.url, error?.message ?? String(error)); } - } catch (error: any) { - await workspace.cleanup(); - throw new FetchError(options.url, error?.message ?? String(error)); } - } - const query = seededContext ? `${options.query}\n\n${seededContext}` : options.query; + const query = seededContext ? `${options.query}\n\n${seededContext}` : options.query; - try { const result = await runAgent(query, { model: overrides.web.model ?? overrides.defaults.model, systemPrompt, @@ -97,7 +97,7 @@ export async function runWebCommand(options: WebCommandOptions): Promise { }); process.stdout.write(result.message + "\n"); - printUsageSummary(result.usage as any); + printUsageSummary(result.usage as any, result.requestCount); } finally { await workspace.cleanup(); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 72ea1eea2dcd4ef5b96332a5d422de7613417ba1..dde880ae1185ea2e5680e6ea4667751fe5717ffe 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,55 +2,32 @@ import { runWebCommand } from "./commands/web.js"; import { runRepoCommand } from "./commands/repo.js"; import { RumiloError } from "../util/errors.js"; +import { parseArgs } from "./parse-args.js"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; -interface ParsedArgs { - command?: string; - options: Record; - positional: string[]; -} - -function parseArgs(args: string[]): ParsedArgs { - const [, , command, ...rest] = args; - const options: Record = {}; - const positional: string[] = []; +const VERSION = "0.1.0"; - for (let i = 0; i < rest.length; i += 1) { - const arg = rest[i]; - if (!arg) continue; +async function main() { + const { command, options, positional } = parseArgs(process.argv); - if (arg.startsWith("--")) { - const [key, value] = arg.slice(2).split("="); - if (!key) continue; - if (value !== undefined) { - options[key] = value; - } else if (rest[i + 1] && !rest[i + 1]?.startsWith("-")) { - options[key] = rest[i + 1] as string; - i += 1; - } else { - options[key] = true; - } - } else if (arg.startsWith("-")) { - const short = arg.slice(1); - if (short === "u" && rest[i + 1]) { - options["uri"] = rest[i + 1] as string; - i += 1; - } else if (short === "f") { - options["full"] = true; - } else { - options[short] = true; - } - } else { - positional.push(arg); - } + // Handle version flag coming before any command (e.g., rumilo -v) + // When short flags come right after process.argv, they end up in 'command' + if (command && (command === "-v" || command === "--version") && Object.keys(options).length === 0 && positional.length === 0) { + console.log(`rumilo v${VERSION}`); + process.exit(0); } - return { command, options, positional }; -} + const actualCommand = command?.startsWith("-") ? undefined : command; -async function main() { - const { command, options, positional } = parseArgs(process.argv); + // Handle version/short version as flag (before command) or as command + if (options["version"] || actualCommand === "version" || actualCommand === "v") { + console.log(`rumilo v${VERSION}`); + process.exit(0); + } - if (!command || command === "help") { + if (!actualCommand || actualCommand === "help" || actualCommand === "--help" || actualCommand === "-h" || options["help"]) { console.log("rumilo web [-u URL] [--model ] [--verbose] [--no-cleanup]"); console.log("rumilo repo -u [--ref ] [--full] [--model ] [--verbose] [--no-cleanup]"); process.exit(0); diff --git a/src/cli/output.ts b/src/cli/output.ts index 9ecdad5880ac39c35e51b5ee57f93fb7b5a6b354..bc78b8ad0b45c449ace2d4688923ce1f625547b3 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,4 +1,4 @@ -import type { AgentEvent } from "@mariozechner/pi-agent"; +import type { AgentEvent } from "@mariozechner/pi-agent-core"; const MAX_OUTPUT_LINES = 20; @@ -44,10 +44,22 @@ export function createEventLogger(options: OutputOptions) { }; } -export function printUsageSummary(usage: { cost?: { total?: number }; totalTokens?: number; output?: number; input?: number } | undefined) { +export function printUsageSummary( + usage: { cost?: { total?: number }; totalTokens?: number; output?: number; input?: number } | undefined, + requestCount?: number, +) { if (!usage) return; - const cost = usage.cost?.total ?? 0; const tokens = usage.totalTokens ?? (usage.output ?? 0) + (usage.input ?? 0); - console.error(`\nusage: ${tokens} tokens, cost $${cost.toFixed(4)}`); + const rawCost = usage.cost?.total; + const cost = typeof rawCost === "number" && !isNaN(rawCost) && rawCost > 0 ? rawCost : undefined; + + let line = `\nusage: ${tokens} tokens`; + if (requestCount !== undefined && requestCount > 0) { + line += ` across ${requestCount} ${requestCount === 1 ? "request" : "requests"}`; + } + if (cost !== undefined) { + line += `, cost $${cost.toFixed(4)}`; + } + console.error(line); } diff --git a/src/cli/parse-args.ts b/src/cli/parse-args.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c11ed2a33cf5efd3ed56f1f87d1fc1b490fef73 --- /dev/null +++ b/src/cli/parse-args.ts @@ -0,0 +1,56 @@ +export interface ParsedArgs { + command?: string; + options: Record; + positional: string[]; +} + +export function parseArgs(args: string[]): ParsedArgs { + const [, , command, ...rest] = args; + const options: Record = {}; + const positional: string[] = []; + + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + if (!arg) continue; + + if (arg.startsWith("--")) { + const eqIndex = arg.indexOf("=", 2); + let key: string; + let value: string | undefined; + if (eqIndex !== -1) { + key = arg.slice(2, eqIndex); + value = arg.slice(eqIndex + 1); + } else { + key = arg.slice(2); + } + if (!key) continue; + if (value !== undefined) { + options[key] = value; + } else if (rest[i + 1] && !rest[i + 1]?.startsWith("-")) { + options[key] = rest[i + 1] as string; + i += 1; + } else { + options[key] = true; + } + } else if (arg.startsWith("-")) { + const short = arg.slice(1); + if (short === "u") { + if (rest[i + 1] && !rest[i + 1]!.startsWith("-")) { + options["uri"] = rest[i + 1] as string; + i += 1; + } + // else: -u with no value — uri stays unset, command handler validates + } else if (short === "f") { + options["full"] = true; + } else if (short === "v") { + options["version"] = true; + } else { + options[short] = true; + } + } else { + positional.push(arg); + } + } + + return { command, options, positional }; +} diff --git a/src/config/loader.ts b/src/config/loader.ts index 140c3a1fafd8092f5f72fa95c313f404a1da1f58..b4a55559e2a831ba12a8c1ce16aa0e07fbf0b312 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -1,6 +1,8 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { Value } from "@sinclair/typebox/value"; import { defaultConfig } from "./defaults.js"; +import { ConfigSchema, PartialConfigSchema } from "./schema.js"; import type { RumiloConfig } from "./schema.js"; import { ConfigError } from "../util/errors.js"; import toml from "toml"; @@ -31,12 +33,27 @@ function mergeConfig(base: RumiloConfig, override: Partial): Rumil }; } -function validateConfig(config: RumiloConfig): void { - if (!config.defaults.model) { - throw new ConfigError("defaults.model is required"); +function validatePartialConfig(parsed: unknown): asserts parsed is Partial { + if (!Value.Check(PartialConfigSchema, parsed)) { + const errors = [...Value.Errors(PartialConfigSchema, parsed)]; + const details = errors + .map((e) => ` ${e.path}: ${e.message} (got ${JSON.stringify(e.value)})`) + .join("\n"); + throw new ConfigError( + `Invalid config:\n${details}`, + ); } - if (typeof config.defaults.cleanup !== "boolean") { - throw new ConfigError("defaults.cleanup must be a boolean"); +} + +function validateFullConfig(config: unknown): asserts config is RumiloConfig { + if (!Value.Check(ConfigSchema, config)) { + const errors = [...Value.Errors(ConfigSchema, config)]; + const details = errors + .map((e) => ` ${e.path}: ${e.message} (got ${JSON.stringify(e.value)})`) + .join("\n"); + throw new ConfigError( + `Invalid merged config:\n${details}`, + ); } } @@ -47,16 +64,21 @@ export async function loadConfig(): Promise { try { const raw = await readFile(configPath, "utf8"); - const parsed = toml.parse(raw) as Partial; + const parsed: unknown = toml.parse(raw); + validatePartialConfig(parsed); const merged = mergeConfig(base, parsed); - validateConfig(merged); + validateFullConfig(merged); return { config: merged, path: configPath }; } catch (error: any) { if (error?.code === "ENOENT") { - validateConfig(base); + validateFullConfig(base); return { config: base }; } + if (error instanceof ConfigError) { + throw error; + } + if (error instanceof Error) { throw new ConfigError(error.message); } @@ -70,6 +92,6 @@ export function applyConfigOverrides( overrides: Partial, ): RumiloConfig { const merged = mergeConfig(config, overrides); - validateConfig(merged); + validateFullConfig(merged); return merged; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 574cb854d957fcc64a6c095cdb20e0644eb09d83..900ba4c3a213983972f195b55fd0eebccd1ca2d5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,4 +1,4 @@ -import { Type, type Static } from "@sinclair/typebox"; +import { Type, Kind, type Static, type TObject, type TProperties } from "@sinclair/typebox"; const CustomModelSchema = Type.Object({ provider: Type.String(), @@ -16,6 +16,7 @@ const CustomModelSchema = Type.Object({ }), context_window: Type.Number(), max_tokens: Type.Number(), + api_key: Type.Optional(Type.String()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), compat: Type.Optional( Type.Object({ @@ -33,7 +34,7 @@ const CustomModelSchema = Type.Object({ ), }); -const ConfigSchema = Type.Object({ +export const ConfigSchema = Type.Object({ defaults: Type.Object({ model: Type.String(), cleanup: Type.Boolean(), @@ -55,5 +56,23 @@ const ConfigSchema = Type.Object({ custom_models: Type.Optional(Type.Record(Type.String(), CustomModelSchema)), }); +/** Deep-partial version of ConfigSchema for validating TOML override files. */ +export function partialObject(schema: TObject) { + const partial: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + const v = value as any; + const inner = v[Kind] === 'Object' && v.properties ? partialObject(v) : v; + partial[key] = Type.Optional(inner as any); + } + return Type.Object(partial as any); +} + +export const PartialConfigSchema = Type.Object({ + defaults: Type.Optional(partialObject(ConfigSchema.properties.defaults)), + web: Type.Optional(partialObject(ConfigSchema.properties.web)), + repo: Type.Optional(partialObject(ConfigSchema.properties.repo)), + custom_models: Type.Optional(Type.Record(Type.String(), CustomModelSchema)), +}); + export type RumiloConfig = Static; export type CustomModelConfig = Static; diff --git a/src/util/env.ts b/src/util/env.ts new file mode 100644 index 0000000000000000000000000000000000000000..27d4ce9b19c87a79bbb26e85e17e3fb55bf27bd3 --- /dev/null +++ b/src/util/env.ts @@ -0,0 +1,75 @@ +import { execSync } from "node:child_process"; + +/** + * Resolve a configuration value (API key, header value, etc.) to a concrete string. + * + * Resolution order, following pi-coding-agent's convention: + * - `"!command"` — executes the rest as a shell command, uses trimmed stdout + * - `"$VAR"` or `"${VAR}"` — treats as an env var reference (with sigil) + * - Otherwise checks `process.env[value]` — bare name is tried as env var + * - If no env var matches, the string is used as a literal value + * + * Returns `undefined` only when a shell command fails or produces empty output. + */ +export function resolveConfigValue(value: string): string | undefined { + if (value.startsWith("!")) { + return executeShellCommand(value.slice(1)); + } + + // Explicit $VAR or ${VAR} reference + const envRef = value.match(/^\$\{(.+)\}$|^\$([A-Za-z_][A-Za-z0-9_]*)$/); + if (envRef) { + const name = envRef[1] ?? envRef[2]!; + return process.env[name] ?? undefined; + } + + // Bare name — check as env var first, then use as literal + const envValue = process.env[value]; + return envValue || value; +} + +/** + * Expand `$VAR` and `${VAR}` references embedded within a larger string. + * Unlike `resolveConfigValue`, this handles mixed literal + env-var strings + * like `"Bearer $API_KEY"`. + */ +export function expandEnvVars(value: string): string { + return value.replace( + /\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, + (_, braced, bare) => { + const name = braced ?? bare; + return process.env[name] ?? ""; + }, + ); +} + +/** + * Resolve all values in a headers record using `resolveConfigValue`. + * Drops entries whose values resolve to `undefined`. + */ +export function resolveHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value); + if (resolvedValue) { + resolved[key] = resolvedValue; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +function executeShellCommand(command: string): string | undefined { + try { + const output = execSync(command, { + encoding: "utf-8", + timeout: 10_000, + stdio: ["ignore", "pipe", "ignore"], + }); + return output.trim() || undefined; + } catch { + return undefined; + } +} diff --git a/src/util/errors.ts b/src/util/errors.ts index 6e6fb9483098d9194de3028431ba79334dd7623a..a91f7fb0a8571cea7aa6e0b3817fac947a02f722 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -37,3 +37,9 @@ export class ToolInputError extends RumiloError { super(message, "TOOL_INPUT_ERROR"); } } + +export class AgentError extends RumiloError { + constructor(message: string) { + super(message, "AGENT_ERROR"); + } +} diff --git a/src/util/path.ts b/src/util/path.ts new file mode 100644 index 0000000000000000000000000000000000000000..861c165d0994485758f6b80cf314e45ee0a025b3 --- /dev/null +++ b/src/util/path.ts @@ -0,0 +1,16 @@ +import { resolve } from "node:path"; + +/** + * Expand a leading ~ in a file path to the user's home directory. + * Use for paths outside the workspace (e.g. system_prompt_path). + * Workspace-sandboxed paths should NOT use this. + */ +export function expandHomePath(filePath: string): string { + const home = process.env["HOME"]; + if (!home) return filePath; + + if (filePath === "~") return home; + if (filePath.startsWith("~/")) return resolve(home, filePath.slice(2)); + + return filePath; +} diff --git a/src/workspace/content.ts b/src/workspace/content.ts index 4c30a22eec9948b8a5af004540997967ce96fac8..ab533a64f1980981361eed4c73ccec616656c0cb 100644 --- a/src/workspace/content.ts +++ b/src/workspace/content.ts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { ensureWorkspacePath } from "../agent/tools/path-utils.js"; export interface WorkspaceContent { filePath: string; @@ -13,6 +14,7 @@ export async function writeWorkspaceFile( content: string, ): Promise { const filePath = join(workspacePath, relativePath); + ensureWorkspacePath(workspacePath, filePath); await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, content, "utf8"); diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..83f6cae48bab5e59ae2bb71bb1b474c7a1b1eb6f --- /dev/null +++ b/test/agent-runner.test.ts @@ -0,0 +1,214 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { AgentError } from "../src/util/errors.js"; +import { expandEnvVars, resolveConfigValue, resolveHeaders } from "../src/util/env.js"; +import { buildGetApiKey } from "../src/agent/runner.js"; +import type { RumiloConfig } from "../src/config/schema.js"; + +const stubConfig: RumiloConfig = { + defaults: { model: "anthropic:test", cleanup: true }, + web: { model: "anthropic:test" }, + repo: { model: "anthropic:test", default_depth: 1, blob_limit: "5m" }, +}; + +function customModel(provider: string, apiKey?: string): RumiloConfig { + return { + ...stubConfig, + custom_models: { + mymodel: { + id: "m1", + name: "M1", + api: "openai-completions" as any, + provider, + base_url: "http://localhost:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0 }, + context_window: 8192, + max_tokens: 4096, + ...(apiKey ? { api_key: apiKey } : {}), + }, + }, + }; +} + +describe("AgentError", () => { + test("has correct name, code, and inherits from Error", () => { + const err = new AgentError("boom"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("AgentError"); + expect(err.code).toBe("AGENT_ERROR"); + expect(err.message).toBe("boom"); + }); +}); + +describe("resolveConfigValue", () => { + const saved: Record = {}; + + beforeAll(() => { + saved["RUMILO_TEST_KEY"] = process.env["RUMILO_TEST_KEY"]; + process.env["RUMILO_TEST_KEY"] = "resolved-value"; + }); + + afterAll(() => { + if (saved["RUMILO_TEST_KEY"] === undefined) delete process.env["RUMILO_TEST_KEY"]; + else process.env["RUMILO_TEST_KEY"] = saved["RUMILO_TEST_KEY"]; + }); + + test("resolves bare env var name", () => { + expect(resolveConfigValue("RUMILO_TEST_KEY")).toBe("resolved-value"); + }); + + test("resolves $VAR reference", () => { + expect(resolveConfigValue("$RUMILO_TEST_KEY")).toBe("resolved-value"); + }); + + test("resolves ${VAR} reference", () => { + expect(resolveConfigValue("${RUMILO_TEST_KEY}")).toBe("resolved-value"); + }); + + test("treats unknown name as literal", () => { + expect(resolveConfigValue("sk-literal-key-12345")).toBe("sk-literal-key-12345"); + }); + + test("returns undefined for unknown $VAR", () => { + expect(resolveConfigValue("$RUMILO_NONEXISTENT_XYZ")).toBeUndefined(); + }); + + test("executes shell commands with ! prefix", () => { + expect(resolveConfigValue("!echo hello")).toBe("hello"); + }); + + test("returns undefined for failing shell command", () => { + expect(resolveConfigValue("!false")).toBeUndefined(); + }); +}); + +describe("expandEnvVars", () => { + const saved: Record = {}; + + beforeAll(() => { + saved["FOO"] = process.env["FOO"]; + saved["BAR"] = process.env["BAR"]; + process.env["FOO"] = "hello"; + process.env["BAR"] = "world"; + }); + + afterAll(() => { + if (saved["FOO"] === undefined) delete process.env["FOO"]; + else process.env["FOO"] = saved["FOO"]; + if (saved["BAR"] === undefined) delete process.env["BAR"]; + else process.env["BAR"] = saved["BAR"]; + }); + + test("expands $VAR", () => { + expect(expandEnvVars("Bearer $FOO")).toBe("Bearer hello"); + }); + + test("expands ${VAR}", () => { + expect(expandEnvVars("Bearer ${FOO}")).toBe("Bearer hello"); + }); + + test("expands multiple vars", () => { + expect(expandEnvVars("$FOO-$BAR")).toBe("hello-world"); + }); + + test("missing var becomes empty string", () => { + expect(expandEnvVars("key=$NONEXISTENT_RUMILO_VAR_XYZ")).toBe("key="); + }); + + test("string without vars is unchanged", () => { + expect(expandEnvVars("plain text")).toBe("plain text"); + }); +}); + +describe("resolveHeaders", () => { + const saved: Record = {}; + + beforeAll(() => { + saved["RUMILO_HDR_KEY"] = process.env["RUMILO_HDR_KEY"]; + process.env["RUMILO_HDR_KEY"] = "hdr-value"; + }); + + afterAll(() => { + if (saved["RUMILO_HDR_KEY"] === undefined) delete process.env["RUMILO_HDR_KEY"]; + else process.env["RUMILO_HDR_KEY"] = saved["RUMILO_HDR_KEY"]; + }); + + test("returns undefined for undefined input", () => { + expect(resolveHeaders(undefined)).toBeUndefined(); + }); + + test("resolves header values via resolveConfigValue", () => { + const result = resolveHeaders({ "X-Key": "RUMILO_HDR_KEY" }); + expect(result).toEqual({ "X-Key": "hdr-value" }); + }); + + test("drops entries that resolve to undefined", () => { + const result = resolveHeaders({ "X-Key": "$RUMILO_NONEXISTENT_XYZ" }); + expect(result).toBeUndefined(); + }); +}); + +describe("buildGetApiKey", () => { + const saved: Record = {}; + + beforeAll(() => { + saved["ANTHROPIC_API_KEY"] = process.env["ANTHROPIC_API_KEY"]; + saved["CUSTOM_KEY"] = process.env["CUSTOM_KEY"]; + process.env["ANTHROPIC_API_KEY"] = "sk-ant-test"; + process.env["CUSTOM_KEY"] = "sk-custom-test"; + }); + + afterAll(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + test("falls back to pi-ai env var lookup for built-in providers", () => { + const getKey = buildGetApiKey(stubConfig); + expect(getKey("anthropic")).toBe("sk-ant-test"); + }); + + test("returns undefined for unknown provider with no config", () => { + const getKey = buildGetApiKey(stubConfig); + expect(getKey("unknown-provider")).toBeUndefined(); + }); + + test("resolves literal api_key from custom model", () => { + const config = customModel("myprovider", "sk-literal-key"); + const getKey = buildGetApiKey(config); + expect(getKey("myprovider")).toBe("sk-literal-key"); + }); + + test("resolves api_key via env var name", () => { + const config = customModel("myprovider", "CUSTOM_KEY"); + const getKey = buildGetApiKey(config); + expect(getKey("myprovider")).toBe("sk-custom-test"); + }); + + test("resolves api_key via $VAR reference", () => { + const config = customModel("myprovider", "$CUSTOM_KEY"); + const getKey = buildGetApiKey(config); + expect(getKey("myprovider")).toBe("sk-custom-test"); + }); + + test("resolves api_key via shell command", () => { + const config = customModel("myprovider", "!echo shell-key"); + const getKey = buildGetApiKey(config); + expect(getKey("myprovider")).toBe("shell-key"); + }); + + test("custom model provider doesn't shadow built-in provider lookup", () => { + const config = customModel("other-provider", "sk-other"); + const getKey = buildGetApiKey(config); + expect(getKey("anthropic")).toBe("sk-ant-test"); + }); + + test("falls back to env var lookup when custom model has no api_key", () => { + const config = customModel("anthropic"); + const getKey = buildGetApiKey(config); + expect(getKey("anthropic")).toBe("sk-ant-test"); + }); +}); diff --git a/test/cli-parser.test.ts b/test/cli-parser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaa8209a94eff0bb4f10523cbf6dc99db4997716 --- /dev/null +++ b/test/cli-parser.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from "bun:test"; +import { parseArgs } from "../src/cli/parse-args.js"; + +describe("CLI --key=value parsing (issue #6)", () => { + test("--key=value with '=' in value preserves full value", () => { + const result = parseArgs(["node", "script", "web", "--key=a=b=c"]); + expect(result.options["key"]).toBe("a=b=c"); + }); + + test("--key=value without extra '=' still works", () => { + const result = parseArgs(["node", "script", "web", "--model=openai:gpt-4"]); + expect(result.options["model"]).toBe("openai:gpt-4"); + }); + + test("--flag without value is boolean true", () => { + const result = parseArgs(["node", "script", "web", "--verbose"]); + expect(result.options["verbose"]).toBe(true); + }); + + test("--key value (space-separated) works", () => { + const result = parseArgs(["node", "script", "web", "--model", "openai:gpt-4"]); + expect(result.options["model"]).toBe("openai:gpt-4"); + }); +}); + +describe("CLI -u short flag (issue #7)", () => { + test("-u does not swallow a following flag as its value", () => { + const result = parseArgs(["node", "script", "web", "-u", "--verbose"]); + expect(result.options["uri"]).toBeUndefined(); + expect(result.options["verbose"]).toBe(true); + }); + + test("-u with valid URL works normally", () => { + const result = parseArgs(["node", "script", "web", "-u", "https://example.com"]); + expect(result.options["uri"]).toBe("https://example.com"); + }); + + test("-u swallowing -f as value is prevented", () => { + const result = parseArgs(["node", "script", "repo", "-u", "-f"]); + expect(result.options["uri"]).toBeUndefined(); + // -u with no valid value is a no-op, -f should be parsed as full flag + expect(result.options["full"]).toBe(true); + }); + + test("-u at end of args leaves uri unset (no stray option)", () => { + const result = parseArgs(["node", "script", "web", "-u"]); + expect(result.options["uri"]).toBeUndefined(); + expect(result.options["u"]).toBeUndefined(); + }); +}); diff --git a/test/config-loader.test.ts b/test/config-loader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..10b9b7300e6be9fc8875e1329941c2607f3605af --- /dev/null +++ b/test/config-loader.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConfigError } from "../src/util/errors.js"; +import { loadConfig } from "../src/config/loader.js"; + +describe("loadConfig - ConfigError rethrown directly (issue #10)", () => { + let configDir: string; + let configPath: string; + const originalEnv = { ...process.env }; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test10-")); + const xdgBase = join(configDir, "xdg"); + const rumiloDir = join(xdgBase, "rumilo"); + mkdirSync(rumiloDir, { recursive: true }); + configPath = join(rumiloDir, "config.toml"); + process.env["XDG_CONFIG_HOME"] = xdgBase; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + try { + rmSync(configDir, { recursive: true, force: true }); + } catch {} + }); + + test("ConfigError from validation is rethrown with original message and stack", async () => { + // Write invalid config that triggers ConfigError from validatePartialConfig + writeFileSync(configPath, `[defaults]\nmodel = 42\n`); + try { + await loadConfig(); + throw new Error("should have thrown"); + } catch (e: any) { + expect(e).toBeInstanceOf(ConfigError); + // The original message should include the validation details, not be re-wrapped + expect(e.message).toContain("/defaults/model"); + // Stack should reference the validation function, not be a generic re-wrap + expect(e.stack).toBeDefined(); + } + }); + + test("TOML parse error is wrapped as ConfigError with original message", async () => { + writeFileSync(configPath, `[invalid toml !!!`); + try { + await loadConfig(); + throw new Error("should have thrown"); + } catch (e: any) { + expect(e).toBeInstanceOf(ConfigError); + // Should contain the original TOML parse error message + expect(e.message.length).toBeGreaterThan(0); + } + }); +}); diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..039bc35de741daf11b1b5f31010b7241aa1a6451 --- /dev/null +++ b/test/config-validation.test.ts @@ -0,0 +1,206 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Type } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; +import { ConfigError } from "../src/util/errors.js"; +import { loadConfig } from "../src/config/loader.js"; +import { partialObject } from "../src/config/schema.js"; + +describe("config validation", () => { + let configDir: string; + let configPath: string; + const originalEnv = { ...process.env }; + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), "rumilo-cfg-test-")); + configPath = join(configDir, "config.toml"); + process.env["XDG_CONFIG_HOME"] = join(configDir, ".."); + // loadConfig looks for /rumilo/config.toml + // So we need the dir structure to match + const rumiloDir = join(configDir, "..", "rumilo"); + require("node:fs").mkdirSync(rumiloDir, { recursive: true }); + configPath = join(rumiloDir, "config.toml"); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + try { + rmSync(configDir, { recursive: true, force: true }); + // Also clean up the rumilo dir we created + const rumiloDir = join(configDir, "..", "rumilo"); + rmSync(rumiloDir, { recursive: true, force: true }); + } catch {} + }); + + test("rejects defaults.model with wrong type (number instead of string)", async () => { + writeFileSync( + configPath, + `[defaults]\nmodel = 42\ncleanup = true\n`, + ); + await expect(loadConfig()).rejects.toThrow(ConfigError); + await expect(loadConfig()).rejects.toThrow(/defaults\/model/); + }); + + test("rejects defaults.cleanup with wrong type (string instead of boolean)", async () => { + writeFileSync( + configPath, + `[defaults]\nmodel = "anthropic:claude-sonnet-4-20250514"\ncleanup = "yes"\n`, + ); + await expect(loadConfig()).rejects.toThrow(ConfigError); + await expect(loadConfig()).rejects.toThrow(/defaults\/cleanup/); + }); + + test("rejects repo.default_depth with wrong type (string instead of number)", async () => { + writeFileSync( + configPath, + `[repo]\ndefault_depth = "deep"\n`, + ); + await expect(loadConfig()).rejects.toThrow(ConfigError); + await expect(loadConfig()).rejects.toThrow(/repo\/default_depth/); + }); + + test("rejects repo.default_depth below minimum (0)", async () => { + writeFileSync( + configPath, + `[repo]\ndefault_depth = 0\n`, + ); + await expect(loadConfig()).rejects.toThrow(ConfigError); + await expect(loadConfig()).rejects.toThrow(/default_depth/); + }); + + test("rejects unknown top-level section type (number instead of object)", async () => { + // web should be an object but we pass a string value at top level + writeFileSync( + configPath, + `[defaults]\nmodel = "x"\ncleanup = true\n[web]\nmodel = 123\n`, + ); + await expect(loadConfig()).rejects.toThrow(ConfigError); + }); + + test("accepts valid partial config (only [repo] section)", async () => { + writeFileSync( + configPath, + `[repo]\nmodel = "anthropic:claude-sonnet-4-20250514"\ndefault_depth = 5\n`, + ); + const { config } = await loadConfig(); + expect(config.repo.model).toBe("anthropic:claude-sonnet-4-20250514"); + expect(config.repo.default_depth).toBe(5); + // defaults should come from defaultConfig + expect(config.defaults.model).toBe("anthropic:claude-sonnet-4-20250514"); + }); + + test("accepts valid complete config", async () => { + writeFileSync( + configPath, + [ + `[defaults]`, + `model = "openai:gpt-4"`, + `cleanup = false`, + ``, + `[web]`, + `model = "openai:gpt-4"`, + ``, + `[repo]`, + `model = "openai:gpt-4"`, + `default_depth = 3`, + `blob_limit = "10m"`, + ].join("\n"), + ); + const { config } = await loadConfig(); + expect(config.defaults.model).toBe("openai:gpt-4"); + expect(config.defaults.cleanup).toBe(false); + expect(config.repo.default_depth).toBe(3); + }); + + test("error message includes path and expected type for diagnostics", async () => { + writeFileSync( + configPath, + `[defaults]\nmodel = 42\ncleanup = true\n`, + ); + try { + await loadConfig(); + throw new Error("should have thrown"); + } catch (e: any) { + expect(e).toBeInstanceOf(ConfigError); + expect(e.message).toContain("/defaults/model"); + expect(e.message).toMatch(/string/i); + } + }); +}); + +describe("partialObject deep-partial behavior", () => { + const NestedSchema = Type.Object({ + name: Type.String(), + inner: Type.Object({ + host: Type.String(), + port: Type.Number(), + }), + }); + + const PartialNested = partialObject(NestedSchema); + + test("accepts empty object (all fields optional at every level)", () => { + const result = Value.Check(PartialNested, {}); + expect(result).toBe(true); + }); + + test("accepts object with nested section present but inner fields omitted", () => { + const result = Value.Check(PartialNested, { inner: {} }); + expect(result).toBe(true); + }); + + test("accepts object with partial inner fields of a nested object", () => { + const result = Value.Check(PartialNested, { inner: { host: "localhost" } }); + expect(result).toBe(true); + }); + + test("accepts fully specified object", () => { + const result = Value.Check(PartialNested, { + name: "test", + inner: { host: "localhost", port: 8080 }, + }); + expect(result).toBe(true); + }); + + test("rejects wrong type inside nested object", () => { + const result = Value.Check(PartialNested, { inner: { port: "not-a-number" } }); + expect(result).toBe(false); + }); + + test("rejects wrong type at top level", () => { + const result = Value.Check(PartialNested, { name: 123 }); + expect(result).toBe(false); + }); + + test("does not recurse into Type.Record", () => { + const SchemaWithRecord = Type.Object({ + headers: Type.Record(Type.String(), Type.String()), + }); + const Partial = partialObject(SchemaWithRecord); + // Record should remain as-is (not turned into a partial object) + // Valid: omitted entirely + expect(Value.Check(Partial, {})).toBe(true); + // Valid: proper record + expect(Value.Check(Partial, { headers: { "x-key": "val" } })).toBe(true); + // Invalid: wrong value type in record + expect(Value.Check(Partial, { headers: { "x-key": 42 } })).toBe(false); + }); + + test("handles deeply nested objects (3 levels)", () => { + const DeepSchema = Type.Object({ + level1: Type.Object({ + level2: Type.Object({ + value: Type.Number(), + }), + }), + }); + const PartialDeep = partialObject(DeepSchema); + expect(Value.Check(PartialDeep, {})).toBe(true); + expect(Value.Check(PartialDeep, { level1: {} })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: {} } })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: { value: 42 } } })).toBe(true); + expect(Value.Check(PartialDeep, { level1: { level2: { value: "nope" } } })).toBe(false); + }); +}); diff --git a/test/expand-home-path.test.ts b/test/expand-home-path.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4de54fb170446347ac756535a707d6651929e2ed --- /dev/null +++ b/test/expand-home-path.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { expandHomePath } from "../src/util/path.js"; + +describe("expandHomePath", () => { + let savedHome: string | undefined; + + beforeEach(() => { + savedHome = process.env["HOME"]; + }); + + afterEach(() => { + if (savedHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = savedHome; + } + }); + + test("returns path unchanged when HOME is unset", () => { + delete process.env["HOME"]; + expect(expandHomePath("~/foo/bar")).toBe("~/foo/bar"); + }); + + test("bare ~ returns HOME", () => { + process.env["HOME"] = "/Users/alice"; + expect(expandHomePath("~")).toBe("/Users/alice"); + }); + + test("~/foo/bar expands to $HOME/foo/bar", () => { + process.env["HOME"] = "/Users/alice"; + expect(expandHomePath("~/foo/bar")).toBe("/Users/alice/foo/bar"); + }); + + test("paths without tilde are returned unchanged", () => { + process.env["HOME"] = "/Users/alice"; + expect(expandHomePath("/absolute/path")).toBe("/absolute/path"); + expect(expandHomePath("relative/path")).toBe("relative/path"); + }); + + test("~user/foo is returned unchanged (not our expansion)", () => { + process.env["HOME"] = "/Users/alice"; + expect(expandHomePath("~user/foo")).toBe("~user/foo"); + }); +}); diff --git a/test/git-log-validation.test.ts b/test/git-log-validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d57862deaf7c2eabfdaeac6bc2927acc83235807 --- /dev/null +++ b/test/git-log-validation.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import simpleGit from "simple-git"; +import { createGitLogTool } from "../src/agent/tools/git/log.js"; +import { ToolInputError } from "../src/util/errors.js"; + +let workDir: string; +let git: ReturnType; + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), "rumilo-gitlog-test-")); + git = simpleGit(workDir); + await git.init(); + await git.addConfig("user.name", "Test"); + await git.addConfig("user.email", "test@test.com"); + writeFileSync(join(workDir, "file.txt"), "hello"); + await git.add("file.txt"); + await git.commit("initial commit"); +}); + +afterAll(() => { + try { + rmSync(workDir, { recursive: true, force: true }); + } catch {} +}); + +describe("git_log validation - dead code fix (issue #12)", () => { + test("whitespace-only author throws ToolInputError", async () => { + const tool = createGitLogTool(workDir); + await expect(tool.execute("id", { author: " " })).rejects.toThrow(ToolInputError); + }); + + test("empty-string author throws ToolInputError", async () => { + const tool = createGitLogTool(workDir); + await expect(tool.execute("id", { author: "" })).rejects.toThrow(ToolInputError); + }); + + test("empty-string since throws ToolInputError", async () => { + const tool = createGitLogTool(workDir); + await expect(tool.execute("id", { since: " " })).rejects.toThrow(ToolInputError); + }); + + test("empty-string until throws ToolInputError", async () => { + const tool = createGitLogTool(workDir); + await expect(tool.execute("id", { until: " " })).rejects.toThrow(ToolInputError); + }); + + test("valid author is accepted", async () => { + const tool = createGitLogTool(workDir); + // Should not throw + const result: any = await tool.execute("id", { author: "Test" }); + expect(result.details.count).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/test/git-tools.test.ts b/test/git-tools.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d59a5206aa5adec48404af8d2833a1ec24be6a2e --- /dev/null +++ b/test/git-tools.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import simpleGit from "simple-git"; +import { createGitShowTool } from "../src/agent/tools/git/show.js"; +import { createGitDiffTool } from "../src/agent/tools/git/diff.js"; +import { createGitBlameTool } from "../src/agent/tools/git/blame.js"; +import { createGitLogTool } from "../src/agent/tools/git/log.js"; +import { DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "../src/util/truncate.js"; + +function textOf(result: any): string { + return result.content[0].text; +} + +let workDir: string; +let git: ReturnType; + +beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), "rumilo-git-test-")); + git = simpleGit(workDir); + await git.init(); + await git.addConfig("user.name", "Test"); + await git.addConfig("user.email", "test@test.com"); + + // Create a large file for truncation tests + const largeLine = "x".repeat(100); + const largeContent = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine}`).join("\n"); + writeFileSync(join(workDir, "large.txt"), largeContent); + await git.add("large.txt"); + await git.commit("add large file"); + + // Create many commits for log default-limit test + for (let i = 0; i < 30; i++) { + writeFileSync(join(workDir, "counter.txt"), String(i)); + await git.add("counter.txt"); + await git.commit(`commit number ${i}`); + } +}); + +afterAll(() => { + try { + rmSync(workDir, { recursive: true, force: true }); + } catch {} +}); + +describe("git_show truncation (issue #8)", () => { + test("truncates large output and appends notice", async () => { + const tool = createGitShowTool(workDir); + // The first commit has the large file diff, which should exceed truncation limits + const logs = await git.log(); + const firstCommitHash = logs.all[logs.all.length - 1]!.hash; + const result = await tool.execute("call-1", { ref: firstCommitHash }); + const text = textOf(result); + + // Output should be bounded - not return all 3000+ lines raw + const lines = text.split("\n"); + expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); // small margin for notice + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); // margin for notice + expect(text).toContain("[truncated"); + }); + + test("small output is not truncated", async () => { + const tool = createGitShowTool(workDir); + const result = await tool.execute("call-2", { ref: "HEAD" }); + const text = textOf(result); + // HEAD commit is small (counter.txt change), should NOT be truncated + expect(text).not.toContain("[truncated"); + }); +}); + +describe("git_diff truncation (issue #8)", () => { + test("truncates large diff output", async () => { + const tool = createGitDiffTool(workDir); + const logs = await git.log(); + const firstCommitHash = logs.all[logs.all.length - 1]!.hash; + const secondCommitHash = logs.all[logs.all.length - 2]!.hash; + // Diff between first commit (large file add) and second commit + const result = await tool.execute("call-3", { ref: firstCommitHash, ref2: secondCommitHash }); + const text = textOf(result); + // The diff won't be huge (only counter.txt changes), so let's create a proper large diff scenario + // Instead, diff from the first commit to HEAD which has many changes but also large.txt unchanged + // Better: modify large.txt to create a big diff + // Actually, let's just verify the mechanism works by checking the first commit via show already. + // For diff specifically, create a modified version of large.txt + const largeLine2 = "y".repeat(100); + const largeContent2 = Array.from({ length: 3000 }, (_, i) => `${i}: ${largeLine2}`).join("\n"); + writeFileSync(join(workDir, "large.txt"), largeContent2); + const result2 = await tool.execute("call-3b", { ref: "HEAD" }); + const text2 = textOf(result2); + const lines2 = text2.split("\n"); + expect(lines2.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); + expect(text2).toContain("[truncated"); + // Restore the file + await git.checkout(["--", "large.txt"]); + }); +}); + +describe("git_blame truncation (issue #8)", () => { + test("truncates large blame output", async () => { + const tool = createGitBlameTool(workDir); + const result = await tool.execute("call-4", { path: "large.txt" }); + const text = textOf(result); + const lines = text.split("\n"); + expect(lines.length).toBeLessThanOrEqual(DEFAULT_MAX_LINES + 5); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + 500); + expect(text).toContain("[truncated"); + }); +}); + +describe("git_log default limit (issue #9)", () => { + test("returns at most 20 commits when n is not specified", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-5", {}); + // We have 31 commits total (1 large file + 30 counter), default should limit to 20 + expect(result.details.count).toBeLessThanOrEqual(20); + expect(result.details.count).toBe(20); + }); + + test("explicit n overrides default limit", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-6", { n: 5 }); + expect(result.details.count).toBe(5); + }); + + test("explicit n larger than 20 works", async () => { + const tool = createGitLogTool(workDir); + const result: any = await tool.execute("call-7", { n: 25 }); + expect(result.details.count).toBe(25); + }); +}); diff --git a/test/model-resolver.test.ts b/test/model-resolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5aabbb052a6d17a5064b4c4cd2092536cca2ebf --- /dev/null +++ b/test/model-resolver.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from "bun:test"; +import { resolveModel } from "../src/agent/model-resolver.js"; +import type { RumiloConfig } from "../src/config/schema.js"; +import { ConfigError } from "../src/util/errors.js"; + +// Minimal config stub for tests +const stubConfig: RumiloConfig = { + defaults: { model: "test:m", cleanup: true }, + web: { model: "test:m" }, + repo: { model: "test:m", default_depth: 1, blob_limit: "5m" }, + custom_models: {}, +}; + +describe("resolveModel - colon handling (issue #5)", () => { + test("model string with multiple colons preserves segments after second colon", () => { + // e.g. "openrouter:google/gemini-2.5-pro:free" should parse as + // provider = "openrouter", modelName = "google/gemini-2.5-pro:free" + // This will throw from getModel (unknown provider) but the parsed modelName + // should contain the full string after the first colon. + // We test via custom: prefix where we can control resolution. + const config: RumiloConfig = { + ...stubConfig, + custom_models: { + "name:with:colons": { + id: "test-id", + name: "test", + api: "openai", + provider: "test", + base_url: "http://localhost", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0 }, + context_window: 1000, + max_tokens: 500, + }, + }, + }; + // "custom:name:with:colons" should split as provider="custom", modelName="name:with:colons" + const model = resolveModel("custom:name:with:colons", config); + expect(model.id).toBe("test-id"); + }); + + test("simple provider:model still works", () => { + // This will call getModel which may throw for unknown providers, + // but at minimum the split should be correct. Test with custom. + const config: RumiloConfig = { + ...stubConfig, + custom_models: { + "simple": { + id: "simple-id", + name: "simple", + api: "openai", + provider: "test", + base_url: "http://localhost", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0 }, + context_window: 1000, + max_tokens: 500, + }, + }, + }; + const model = resolveModel("custom:simple", config); + expect(model.id).toBe("simple-id"); + }); + + test("rejects model string without colon", () => { + expect(() => resolveModel("nocodelimiter", stubConfig)).toThrow(ConfigError); + }); +}); diff --git a/test/web-search.test.ts b/test/web-search.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2451f844622c6b80f05b9ed99044102682531d69 --- /dev/null +++ b/test/web-search.test.ts @@ -0,0 +1,23 @@ +import { describe, test, expect, mock } from "bun:test"; +import { FetchError, ToolInputError } from "../src/util/errors.js"; + +// Mock kagi-ken so the search function throws, exercising the FetchError wrapping +mock.module("kagi-ken", () => ({ + search: async () => { + throw new Error("Unauthorized"); + }, +})); + +import { createWebSearchTool } from "../src/agent/tools/web-search.js"; + +describe("web_search error handling (issue #11)", () => { + test("missing session token throws ToolInputError", async () => { + const tool = createWebSearchTool(""); + await expect(tool.execute("id", { query: "test" })).rejects.toThrow(ToolInputError); + }); + + test("search API failure is wrapped as FetchError", async () => { + const tool = createWebSearchTool("invalid-token-xxx"); + await expect(tool.execute("id", { query: "test query" })).rejects.toThrow(FetchError); + }); +}); diff --git a/test/workspace-cleanup.test.ts b/test/workspace-cleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..954c0f70f144224c44d3755ab09574a83a5738da --- /dev/null +++ b/test/workspace-cleanup.test.ts @@ -0,0 +1,139 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { readdirSync, mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { ConfigError } from "../src/util/errors.js"; + +/** + * Snapshot rumilo-* dirs in tmpdir so we can detect leaks. + */ +function rumiloTmpDirs(): Set { + return new Set(readdirSync(tmpdir()).filter((n) => n.startsWith("rumilo-"))); +} + +function leakedDirs(before: Set, after: Set): string[] { + return [...after].filter((d) => !before.has(d)); +} + +async function cleanupLeaked(leaked: string[]): Promise { + for (const d of leaked) { + await rm(join(tmpdir(), d), { recursive: true, force: true }); + } +} + +// ─── web command: workspace leaked on missing credentials ─────────── + +describe("web command – workspace cleanup on early failure", () => { + const origEnv = { ...process.env }; + + beforeEach(() => { + // Ensure credential env vars are absent so validation throws. + delete process.env["KAGI_SESSION_TOKEN"]; + delete process.env["TABSTACK_API_KEY"]; + }); + + afterEach(() => { + process.env = { ...origEnv }; + }); + + test("workspace dir is removed when credential validation throws", async () => { + const before = rumiloTmpDirs(); + + const { runWebCommand } = await import("../src/cli/commands/web.js"); + + try { + await runWebCommand({ + query: "test", + verbose: false, + cleanup: true, + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ConfigError); + } + + const after = rumiloTmpDirs(); + const leaked = leakedDirs(before, after); + + // Safety: clean up any leaked dirs so the test doesn't pollute. + await cleanupLeaked(leaked); + + // If this fails, the workspace was created but not cleaned up – a leak. + expect(leaked).toEqual([]); + }); +}); + +// ─── repo command: workspace leaked on checkout failure ───────────── + +describe("repo command – workspace cleanup on early failure", () => { + const origEnv = { ...process.env }; + let localRepo: string; + + beforeEach(() => { + // Create a small local bare git repo so clone succeeds without network. + localRepo = mkdtempSync(join(tmpdir(), "rumilo-test-bare-")); + execSync("git init --bare", { cwd: localRepo, stdio: "ignore" }); + // Create a temporary work clone to add a commit (bare repos need content) + const workClone = mkdtempSync(join(tmpdir(), "rumilo-test-work-")); + execSync(`git clone ${localRepo} work`, { cwd: workClone, stdio: "ignore" }); + const workDir = join(workClone, "work"); + execSync("git config user.email test@test.com && git config user.name Test", { cwd: workDir, stdio: "ignore" }); + execSync("echo hello > README.md && git add . && git commit -m init", { cwd: workDir, stdio: "ignore" }); + execSync("git push", { cwd: workDir, stdio: "ignore" }); + // Clean up work clone + execSync(`rm -rf ${workClone}`, { stdio: "ignore" }); + }); + + afterEach(async () => { + process.env = { ...origEnv }; + await rm(localRepo, { recursive: true, force: true }); + }); + + test("workspace dir is removed when clone fails", async () => { + const before = rumiloTmpDirs(); + + const { runRepoCommand } = await import("../src/cli/commands/repo.js"); + + try { + await runRepoCommand({ + query: "test", + uri: "file:///nonexistent-path/repo.git", + full: false, + verbose: false, + cleanup: true, + }); + } catch { + // expected – clone will fail + } + + const after = rumiloTmpDirs(); + const leaked = leakedDirs(before, after); + await cleanupLeaked(leaked); + expect(leaked).toEqual([]); + }); + + test("workspace dir is removed when ref checkout fails after clone", async () => { + const before = rumiloTmpDirs(); + + const { runRepoCommand } = await import("../src/cli/commands/repo.js"); + + try { + await runRepoCommand({ + query: "test", + uri: localRepo, + ref: "nonexistent-ref-abc123", + full: true, // full clone for local bare repo compatibility + verbose: false, + cleanup: true, + }); + } catch { + // expected – checkout of bad ref will fail + } + + const after = rumiloTmpDirs(); + const leaked = leakedDirs(before, after); + await cleanupLeaked(leaked); + expect(leaked).toEqual([]); + }); +}); diff --git a/test/workspace-containment.test.ts b/test/workspace-containment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f44c3ada7ad667b47f4dc2c4e2cd9d84538104d --- /dev/null +++ b/test/workspace-containment.test.ts @@ -0,0 +1,330 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { ensureWorkspacePath } from "../src/agent/tools/index.js"; +import { expandPath, resolveToCwd, resolveReadPath } from "../src/agent/tools/path-utils.js"; +import { writeWorkspaceFile } from "../src/workspace/content.js"; + +let workspace: string; + +beforeAll(async () => { + workspace = await mkdtemp(join(tmpdir(), "rumilo-test-")); + await mkdir(join(workspace, "subdir"), { recursive: true }); + await writeFile(join(workspace, "hello.txt"), "hello"); + await writeFile(join(workspace, "subdir", "nested.txt"), "nested"); +}); + +afterAll(async () => { + await rm(workspace, { recursive: true, force: true }); +}); + +// ─── ensureWorkspacePath ──────────────────────────────────────────── + +describe("ensureWorkspacePath", () => { + test("allows workspace root itself", () => { + const result = ensureWorkspacePath(workspace, "."); + expect(result).toBe(workspace); + }); + + test("allows a relative child path", () => { + const result = ensureWorkspacePath(workspace, "hello.txt"); + expect(result).toBe(join(workspace, "hello.txt")); + }); + + test("allows nested relative path", () => { + const result = ensureWorkspacePath(workspace, "subdir/nested.txt"); + expect(result).toBe(join(workspace, "subdir", "nested.txt")); + }); + + test("rejects .. traversal escaping workspace", () => { + expect(() => ensureWorkspacePath(workspace, "../../../etc/passwd")).toThrow("Path escapes workspace"); + }); + + test("rejects absolute path outside workspace", () => { + expect(() => ensureWorkspacePath(workspace, "/etc/passwd")).toThrow("Path escapes workspace"); + }); + + test("allows absolute path inside workspace", () => { + const absInside = join(workspace, "hello.txt"); + const result = ensureWorkspacePath(workspace, absInside); + expect(result).toBe(absInside); + }); +}); + +// ─── expandPath: tilde must NOT escape workspace ──────────────────── + +describe("expandPath - tilde handling for workspace sandboxing", () => { + test("tilde alone must not expand to homedir", () => { + const result = expandPath("~"); + // After fix, ~ should remain literal (not expand to homedir) + expect(result).toBe("~"); + }); + + test("tilde-prefixed path must not expand to homedir", () => { + const result = expandPath("~/secret"); + expect(result).not.toContain("/home"); + expect(result).not.toContain("/Users"); + // Should stay as literal path + expect(result).toBe("~/secret"); + }); +}); + +// ─── resolveToCwd: must stay within workspace ─────────────────────── + +describe("resolveToCwd - workspace containment", () => { + test("resolves relative path within workspace", () => { + const result = resolveToCwd("hello.txt", workspace); + expect(result).toBe(join(workspace, "hello.txt")); + }); + + test("resolves '.' to workspace root", () => { + const result = resolveToCwd(".", workspace); + expect(result).toBe(workspace); + }); +}); + +// ─── Tool-level containment (read tool) ───────────────────────────── + +describe("read tool - workspace containment", () => { + let readTool: any; + + beforeAll(async () => { + const { createReadTool } = await import("../src/agent/tools/read.js"); + readTool = createReadTool(workspace); + }); + + test("reads file inside workspace", async () => { + const result = await readTool.execute("id", { path: "hello.txt" }); + expect(result.content[0].text).toBe("hello"); + }); + + test("rejects traversal via ..", async () => { + await expect(readTool.execute("id", { path: "../../etc/passwd" })).rejects.toThrow( + /escapes workspace/i, + ); + }); + + test("rejects absolute path outside workspace", async () => { + await expect(readTool.execute("id", { path: "/etc/passwd" })).rejects.toThrow( + /escapes workspace/i, + ); + }); + + test("tilde path stays within workspace (no homedir expansion)", async () => { + // With tilde expansion removed, ~/foo resolves to /~/foo + // which is safely inside the workspace. It will fail with ENOENT, + // NOT succeed in reading the user's homedir file. + await expect(readTool.execute("id", { path: "~/.bashrc" })).rejects.toThrow(/ENOENT/); + }); +}); + +// ─── Tool-level containment (ls tool) ─────────────────────────────── + +describe("ls tool - workspace containment", () => { + let lsTool: any; + + beforeAll(async () => { + const { createLsTool } = await import("../src/agent/tools/ls.js"); + lsTool = createLsTool(workspace); + }); + + test("lists workspace root", async () => { + const result = await lsTool.execute("id", {}); + expect(result.content[0].text).toContain("hello.txt"); + }); + + test("rejects traversal via ..", async () => { + await expect(lsTool.execute("id", { path: "../../" })).rejects.toThrow( + /escapes workspace/i, + ); + }); + + test("rejects absolute path outside workspace", async () => { + await expect(lsTool.execute("id", { path: "/tmp" })).rejects.toThrow( + /escapes workspace/i, + ); + }); +}); + +// ─── Tool-level containment (grep tool) ───────────────────────────── + +describe("grep tool - workspace containment", () => { + let grepTool: any; + + beforeAll(async () => { + const { createGrepTool } = await import("../src/agent/tools/grep.js"); + grepTool = createGrepTool(workspace); + }); + + test("searches within workspace", async () => { + const result = await grepTool.execute("id", { pattern: "hello", literal: true }); + expect(result.content[0].text).toContain("hello"); + }); + + test("rejects traversal via ..", async () => { + await expect( + grepTool.execute("id", { pattern: "root", path: "../../etc" }), + ).rejects.toThrow(/escapes workspace/i); + }); + + test("rejects absolute path outside workspace", async () => { + await expect( + grepTool.execute("id", { pattern: "root", path: "/etc" }), + ).rejects.toThrow(/escapes workspace/i); + }); +}); + +// ─── Tool-level containment (find tool) ───────────────────────────── + +describe("find tool - workspace containment", () => { + let findTool: any; + + beforeAll(async () => { + const { createFindTool } = await import("../src/agent/tools/find.js"); + findTool = createFindTool(workspace); + }); + + test("finds files in workspace", async () => { + const result = await findTool.execute("id", { pattern: "*.txt" }); + expect(result.content[0].text).toContain("hello.txt"); + }); + + test("rejects traversal via ..", async () => { + await expect( + findTool.execute("id", { pattern: "*", path: "../../" }), + ).rejects.toThrow(/escapes workspace/i); + }); + + test("rejects absolute path outside workspace", async () => { + await expect( + findTool.execute("id", { pattern: "*", path: "/tmp" }), + ).rejects.toThrow(/escapes workspace/i); + }); +}); + +// ─── writeWorkspaceFile containment (Issue #4) ────────────────────── + +describe("writeWorkspaceFile - workspace containment", () => { + test("writes file inside workspace", async () => { + const result = await writeWorkspaceFile(workspace, "output.txt", "data"); + expect(result.filePath).toBe(join(workspace, "output.txt")); + }); + + test("writes nested file inside workspace", async () => { + const result = await writeWorkspaceFile(workspace, "a/b/c.txt", "deep"); + expect(result.filePath).toBe(join(workspace, "a", "b", "c.txt")); + }); + + test("rejects traversal via ..", async () => { + await expect( + writeWorkspaceFile(workspace, "../../../tmp/evil.txt", "pwned"), + ).rejects.toThrow(/escapes workspace/i); + }); + + test("absolute path via join stays inside workspace", async () => { + // path.join(workspace, "/tmp/evil.txt") => "/tmp/evil.txt" + // This is actually inside the workspace — join concatenates, doesn't replace. + const result = await writeWorkspaceFile(workspace, "/tmp/evil.txt", "safe"); + expect(result.filePath).toBe(join(workspace, "tmp", "evil.txt")); + }); + + test("tilde path via join stays inside workspace", async () => { + // With no tilde expansion, ~/evil.txt joins as /~/evil.txt + const result = await writeWorkspaceFile(workspace, "~/evil.txt", "safe"); + expect(result.filePath).toBe(join(workspace, "~", "evil.txt")); + }); +}); + +// ─── Symlink containment ──────────────────────────────────────────── + +describe("symlink containment", () => { + let symlinkWorkspace: string; + let outsideDir: string; + + beforeAll(async () => { + symlinkWorkspace = await mkdtemp(join(tmpdir(), "rumilo-symlink-test-")); + outsideDir = await mkdtemp(join(tmpdir(), "rumilo-outside-")); + + // Create a regular file inside workspace + await writeFile(join(symlinkWorkspace, "legit.txt"), "safe content"); + + // Create a subdirectory inside workspace + await mkdir(join(symlinkWorkspace, "subdir"), { recursive: true }); + await writeFile(join(symlinkWorkspace, "subdir", "inner.txt"), "inner content"); + + // Create a file outside workspace + await writeFile(join(outsideDir, "secret.txt"), "secret content"); + + // Symlink inside workspace pointing outside + symlinkSync(outsideDir, join(symlinkWorkspace, "escape-link")); + + // Symlink inside workspace pointing to file outside + symlinkSync(join(outsideDir, "secret.txt"), join(symlinkWorkspace, "secret-link.txt")); + + // Symlink inside workspace pointing to a file inside workspace (benign) + symlinkSync(join(symlinkWorkspace, "legit.txt"), join(symlinkWorkspace, "good-link.txt")); + + // Nested symlink escape: subdir/nested-escape -> outsideDir + symlinkSync(outsideDir, join(symlinkWorkspace, "subdir", "nested-escape")); + }); + + afterAll(async () => { + await rm(symlinkWorkspace, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + }); + + test("rejects symlink directory pointing outside workspace", () => { + expect(() => + ensureWorkspacePath(symlinkWorkspace, "escape-link/secret.txt"), + ).toThrow(/escapes workspace via symlink/); + }); + + test("rejects symlink file pointing outside workspace", () => { + expect(() => + ensureWorkspacePath(symlinkWorkspace, "secret-link.txt"), + ).toThrow(/escapes workspace via symlink/); + }); + + test("allows symlink pointing within workspace", () => { + const result = ensureWorkspacePath(symlinkWorkspace, "good-link.txt"); + expect(result).toBe(join(symlinkWorkspace, "good-link.txt")); + }); + + test("rejects nested symlink escape (subdir/nested-escape)", () => { + expect(() => + ensureWorkspacePath(symlinkWorkspace, "subdir/nested-escape/secret.txt"), + ).toThrow(/escapes workspace via symlink/); + }); + + test("rejects symlink escape via directory symlink alone", () => { + expect(() => + ensureWorkspacePath(symlinkWorkspace, "escape-link"), + ).toThrow(/escapes workspace via symlink/); + }); + + test("handles non-existent file in real directory (write target)", () => { + // File doesn't exist but parent is a real dir inside workspace — should pass + const result = ensureWorkspacePath(symlinkWorkspace, "subdir/new-file.txt"); + expect(result).toBe(join(symlinkWorkspace, "subdir", "new-file.txt")); + }); + + test("rejects non-existent file under symlink-escaped parent", () => { + // Parent is a symlink pointing outside — even though target file doesn't exist + expect(() => + ensureWorkspacePath(symlinkWorkspace, "escape-link/new-file.txt"), + ).toThrow(/escapes workspace via symlink/); + }); + + test("writeWorkspaceFile rejects path through symlink escape", async () => { + await expect( + writeWorkspaceFile(symlinkWorkspace, "escape-link/evil.txt", "pwned"), + ).rejects.toThrow(/escapes workspace via symlink/); + }); + + test("writeWorkspaceFile allows normal nested write", async () => { + const result = await writeWorkspaceFile(symlinkWorkspace, "new-dir/file.txt", "ok"); + expect(result.filePath).toBe(join(symlinkWorkspace, "new-dir", "file.txt")); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ac242056f8e59db0f2be9fab23d4177c8f92e6c4..0df20fc50141656a98ba59deffbb6c51d53faa91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "allowJs": false, "types": ["bun-types"], "outDir": "dist", - "rootDir": "src", + "rootDir": ".", "skipLibCheck": true }, "include": ["src", "test"],