diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 017d5f1476..199910a570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: ecr_repo_secret: ECR_MIGRATIONS - dockerfile: ./docker/realtime.Dockerfile ecr_repo_secret: ECR_REALTIME + - dockerfile: ./docker/pii.Dockerfile + ecr_repo_secret: ECR_PII steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -115,7 +117,7 @@ jobs: id: ecr-repo run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT env: - ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || '' }} + ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }} - name: Build and push uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2 @@ -153,6 +155,10 @@ jobs: - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME + # pii is ECR-only (private ECS sidecar) — no ghcr_image, so the tag + # step below skips GHCR for it. + - dockerfile: ./docker/pii.Dockerfile + ecr_repo_secret: ECR_PII steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -188,7 +194,7 @@ jobs: id: ecr-repo run: echo "name=$ECR_REPO" >> $GITHUB_OUTPUT env: - ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || '' }} + ECR_REPO: ${{ matrix.ecr_repo_secret == 'ECR_APP' && secrets.ECR_APP || matrix.ecr_repo_secret == 'ECR_MIGRATIONS' && secrets.ECR_MIGRATIONS || matrix.ecr_repo_secret == 'ECR_REALTIME' && secrets.ECR_REALTIME || matrix.ecr_repo_secret == 'ECR_PII' && secrets.ECR_PII || '' }} - name: Generate tags id: meta @@ -206,7 +212,7 @@ jobs: TAGS="${ECR_IMAGE}" - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 54f8a2f47a..78b7db6510 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -26,6 +26,9 @@ jobs: - dockerfile: ./docker/realtime.Dockerfile ghcr_image: ghcr.io/simstudioai/realtime ecr_repo_secret: ECR_REALTIME + # pii is ECR-only (private ECS sidecar) — no ghcr_image. + - dockerfile: ./docker/pii.Dockerfile + ecr_repo_secret: ECR_PII outputs: registry: ${{ steps.login-ecr.outputs.registry }} @@ -80,8 +83,8 @@ jobs: # Build tags list TAGS="${ECR_IMAGE}" - # Add GHCR tags only for main branch - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + # Add GHCR tags only for main branch (and only for images with a GHCR target) + if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then GHCR_AMD64="${GHCR_IMAGE}:latest-amd64" GHCR_SHA="${GHCR_IMAGE}:${{ github.sha }}-amd64" TAGS="${TAGS},$GHCR_AMD64,$GHCR_SHA" diff --git a/apps/docs/content/docs/en/workflows/blocks/meta.json b/apps/docs/content/docs/en/workflows/blocks/meta.json index c00d0ce097..567a0c3417 100644 --- a/apps/docs/content/docs/en/workflows/blocks/meta.json +++ b/apps/docs/content/docs/en/workflows/blocks/meta.json @@ -2,6 +2,7 @@ "title": "Core Blocks", "pages": [ "agent", + "pi", "api", "function", "condition", diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx new file mode 100644 index 0000000000..f98095bdb1 --- /dev/null +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -0,0 +1,152 @@ +--- +title: Pi Coding Agent +description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — in an isolated cloud sandbox that opens a pull request, or on your own machine over SSH. +pageType: reference +--- + +import { BlockPreview } from '@/components/workflow-preview' +import { FAQ } from '@/components/ui/faq' + +The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it reads, edits, and runs files, then either opens a pull request or changes your files in place. It reuses your models, [skills](/agents/skills), and multi-turn [memory](#memory), and streams its progress as it works. + +It has two modes that decide *where* it runs and *how* its changes land: + +- **Cloud** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**. +- **Local** — connects to your own machine over **SSH** and edits files there directly. + + + +## Modes + +Pick the mode with the **Mode** dropdown. The fields below it change to match. + +### Cloud + +Cloud runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge. + +- Requires sandbox execution to be enabled (the Cloud option only appears when it is). +- Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox, so Sim never injects a hosted key there. +- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud)). +- The deliverable is a **pull request** — nothing is committed to your default branch directly. + +### Local + +Local runs the agent against a repository on a machine you control, reached over SSH. Changes are written **in place** — there's no PR; you review them as normal git changes on that machine. + +- The machine must be reachable on a **public hostname** — `localhost` and LAN/private addresses are blocked. Expose it with a tunnel (see [Setup](#setup-local)). +- The agent's file and shell tools are confined to the **Repository Path** you configure. +- You can also expose **Sim tools** (Gmail, Slack, Exa, …) to the agent so it can act beyond the repo while it works. + +## Configuration + +### Task + +What the agent should do, in plain language — for example *"Add input validation to the signup form and a test for it."* Insert a [connection tag](/workflows/connections) to pass an earlier output, like ``. + +### Model + +The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown lists only models the Pi harness can run: **OpenAI, Anthropic, Google (Gemini), xAI, DeepSeek, Mistral, Groq, Cerebras, and OpenRouter**. + +### API Key + +Your key for the chosen provider. On hosted Sim it's optional for Local runs (a hosted key is used and metered to your workspace), but **Cloud always requires your own key** — enter it in this field. For OpenAI, Anthropic, Google, and Mistral you can instead store a workspace key in **Settings → BYOK**; other providers must use this field. + +### Repository (Cloud) + +- **Repository Owner / Repository Name** — the GitHub repo to clone and open the PR against (for example `your-org` / `your-repo`). +- **GitHub Token** — a personal access token used to clone, push, and open the PR. See [Setup](#setup-cloud) for the exact permissions. +- **Base Branch** — the branch the PR is opened against and cloned from. Defaults to the repository's default branch. +- **Branch Name** *(advanced)* — the branch to push. Auto-generated when blank. +- **Open as Draft PR** *(advanced)* — opens the PR as a draft. On by default. +- **PR Title / PR Body** *(advanced)* — generated from the run when blank. + +### Connection (Local) + +- **Host** — the public hostname or tunnel for the target machine (for example `2.tcp.ngrok.io`). Not `localhost` or a LAN address. +- **Username** — the SSH user (for example `ubuntu`, `root`, or your macOS account). +- **Authentication Method** — `Password` or `Private Key`. +- **Password / Private Key** — the credential for that method. Use a key where you can. +- **Repository Path** — the absolute path to the repo on the target machine (for example `/home/user/my-repo`). The agent's tools are confined to this directory. +- **Port** *(advanced)* — the SSH port. Defaults to `22`; set this to your tunnel's port if it differs. +- **Passphrase** *(advanced)* — for an encrypted private key. + +### Tools (Local) + +Sim tools the agent can call while it works — search a knowledge base, send a Slack message, call any of the [integrations](/integrations). They run through Sim with your connected credentials, exactly like the [Agent block](/workflows/blocks/agent). MCP and custom tools aren't supported here yet (they appear greyed out). + +### Skills + +[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. They're shared with the Agent block, so a skill you author once works in both. + +### Thinking Level + +For models with extended reasoning, how much the model thinks before acting. Higher is more thorough but slower and costs more tokens. Defaults to `medium`. + +### Memory + +Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/workflows/blocks/agent): + +- **None.** Each run is independent. +- **Conversation.** The full history for that conversation ID. +- **Sliding window (messages).** The most recent N messages. +- **Sliding window (tokens).** Recent messages up to a token budget. + +Reuse the same **Conversation ID** across runs to continue a thread. Each turn stores your task and the agent's final summary, which are folded into the next run's prompt. + +### Context limits + +Memory is folded into the agent's first prompt, and two layers keep it within the model's context window: + +- **Sim trims before the run.** The selected memory type bounds what's injected: **Conversation** is automatically capped to a fraction of the model's context window (for models in Sim's catalog), **Sliding window (messages)** keeps the last N messages, and **Sliding window (tokens)** keeps history up to an explicit token budget. +- **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in both Cloud and Local mode, on by default. You don't need to configure anything for context growth mid-run. + +The one case neither layer can rescue is a *first* prompt that already exceeds the window — Pi can only compact once there are older turns to summarize. This is only reachable with **Conversation** memory plus a model typed in manually (not in Sim's catalog), where the automatic cap can't look up a context window. For long histories — and whenever you use a manually entered model — choose **Sliding window (tokens)**: its budget applies regardless of the model, so the first prompt always fits. + +## Outputs + +| Output | What it is | +| --- | --- | +| `` | The agent's final message / run summary | +| `` | The files the agent changed | +| `` | A unified diff of the changes | +| `` | URL of the opened pull request *(Cloud)* | +| `` | The branch pushed with the changes *(Cloud)* | +| `` | The model that ran | +| `` | Token usage, an object `{ input, output, total }` | +| `` | Estimated cost of the run | +| `` | Timing, an object `{ startTime, endTime, duration }` | + +## Setup + +### Cloud + +Cloud runs in a sandbox image with the Pi CLI and git baked in. + +1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals the Cloud option in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. The Cloud option stays hidden until `NEXT_PUBLIC_E2B_ENABLED` is set. +2. **Bring your own model key.** Set the provider API key in the block's API Key field (or, for OpenAI/Anthropic/Google/Mistral, in **Settings → BYOK**). +3. **Create a GitHub token** with permission to clone, push, and open a PR: + - *Fine-grained:* select the repo, then **Contents: Read and write** + **Pull requests: Read and write**. + - *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. + +### Local + +1. **Enable SSH** on the target machine (on macOS: System Settings → General → Sharing → Remote Login). +2. **Expose it on a public host.** Sim blocks `localhost`/LAN, so use a TCP tunnel — for example `ngrok tcp 22`, which gives a `host:port` to put in **Host** and **Port**. +3. **Use a model your provider supports** (for example a Claude model with an Anthropic key). Set the credential method and **Repository Path**, then run. + +## Best Practices + +- **Scope the task.** A specific instruction ("fix the failing `auth` test and add a regression case") produces far better results than a vague one. +- **Use Cloud for hands-off PRs, Local for your working tree.** Cloud is safest for unattended changes (everything lands in a reviewable PR); Local is for iterating on a repo you already have checked out. +- **Prefer key auth and tear down tunnels.** A public SSH tunnel is a real attack surface — use a private key and stop the tunnel when you're done. +- **Reuse a Conversation ID for follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work. + + diff --git a/apps/pii/package.json b/apps/pii/package.json new file mode 100644 index 0000000000..0c3c3807fe --- /dev/null +++ b/apps/pii/package.json @@ -0,0 +1,6 @@ +{ + "name": "@sim/pii", + "version": "0.0.0", + "private": true, + "description": "PII detection + anonymization service (Microsoft Presidio, FastAPI). Python service built as a container image (docker/pii.Dockerfile); not part of the JS/turbo build." +} diff --git a/apps/pii/requirements.txt b/apps/pii/requirements.txt new file mode 100644 index 0000000000..bd120fd57c --- /dev/null +++ b/apps/pii/requirements.txt @@ -0,0 +1,10 @@ +# Pinned for reproducible image builds. Bump deliberately. +presidio-analyzer==2.2.362 +presidio-anonymizer==2.2.362 +spacy==3.8.14 +fastapi==0.138.0 +uvicorn[standard]==0.49.0 + +# The English spaCy model (en_core_web_lg, ~400MB) is fetched + pinned in the +# Dockerfile via curl-with-retry rather than here — a direct pip wheel URL +# truncates on flaky networks and fails wheel validation. diff --git a/apps/pii/server.py b/apps/pii/server.py new file mode 100644 index 0000000000..597fe8f3d9 --- /dev/null +++ b/apps/pii/server.py @@ -0,0 +1,212 @@ +"""Combined Presidio REST service: analyzer + anonymizer on one port. + +Constructs one warm AnalyzerEngine (multi-language NLP + a native check-digit +VIN recognizer) and one AnonymizerEngine at startup, exposing stock-compatible +endpoints so a single PRESIDIO_URL serves both. +""" + +from typing import Any + +from fastapi import FastAPI +from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer, RecognizerResult +from presidio_analyzer.nlp_engine import NlpEngineProvider +from presidio_analyzer.predefined_recognizers import ( + AuAbnRecognizer, + AuAcnRecognizer, + AuMedicareRecognizer, + AuTfnRecognizer, + EsNieRecognizer, + EsNifRecognizer, + FiPersonalIdentityCodeRecognizer, + InAadhaarRecognizer, + InPanRecognizer, + InPassportRecognizer, + InVehicleRegistrationRecognizer, + InVoterRecognizer, + ItDriverLicenseRecognizer, + ItFiscalCodeRecognizer, + ItIdentityCardRecognizer, + ItPassportRecognizer, + ItVatCodeRecognizer, + PlPeselRecognizer, + SgFinRecognizer, + SgUenRecognizer, + UkNinoRecognizer, +) +from presidio_anonymizer import AnonymizerEngine +from presidio_anonymizer.entities import OperatorConfig +from pydantic import BaseModel + +# Languages served. Each needs its spaCy model installed in the image; the +# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...) +# auto-load once their NLP engine is present. +NLP_CONFIGURATION = { + "nlp_engine_name": "spacy", + "models": [ + {"lang_code": "en", "model_name": "en_core_web_lg"}, + {"lang_code": "es", "model_name": "es_core_news_lg"}, + {"lang_code": "it", "model_name": "it_core_news_lg"}, + {"lang_code": "pl", "model_name": "pl_core_news_lg"}, + {"lang_code": "fi", "model_name": "fi_core_news_lg"}, + ], +} +SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]] + +# Predefined recognizers Presidio ships but does NOT load into the default +# registry — they must be added explicitly. Each carries its own +# supported_language, so it fires under that language once its NLP model is +# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids. +EXTRA_RECOGNIZERS = [ + UkNinoRecognizer, + AuAbnRecognizer, + AuAcnRecognizer, + AuTfnRecognizer, + AuMedicareRecognizer, + InPanRecognizer, + InAadhaarRecognizer, + InVehicleRegistrationRecognizer, + InVoterRecognizer, + InPassportRecognizer, + SgFinRecognizer, + SgUenRecognizer, + EsNifRecognizer, + EsNieRecognizer, + ItFiscalCodeRecognizer, + ItDriverLicenseRecognizer, + ItVatCodeRecognizer, + ItPassportRecognizer, + ItIdentityCardRecognizer, + PlPeselRecognizer, + FiPersonalIdentityCodeRecognizer, +] + + +class VinRecognizer(PatternRecognizer): + """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit + validation (position 9). Validation makes accidental matches on arbitrary + 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some + non-North-American VINs omit the check digit and are skipped — an + intentional bias toward precision. + """ + + _TRANSLIT = { + **{str(d): d for d in range(10)}, + "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, + "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9, + "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9, + } + _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] + + def validate_result(self, pattern_text: str): + vin = pattern_text.upper() + if len(vin) != 17: + return False + try: + total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS)) + except KeyError: + return False + check = total % 11 + expected = "X" if check == 10 else str(check) + return vin[8] == expected + + +def build_analyzer() -> AnalyzerEngine: + nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine() + analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES) + # VIN is language-agnostic, so register it under every served language — + # a recognizer only fires for the language the caller routes to. + vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7) + for language in SUPPORTED_LANGUAGES: + analyzer.registry.add_recognizer( + VinRecognizer( + supported_entity="VIN", + patterns=[vin_pattern], + context=["vin", "vehicle", "chassis"], + supported_language=language, + ) + ) + for recognizer_cls in EXTRA_RECOGNIZERS: + analyzer.registry.add_recognizer(recognizer_cls()) + return analyzer + + +analyzer = build_analyzer() +anonymizer = AnonymizerEngine() + +app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None) + + +class AnalyzeRequest(BaseModel): + text: str + language: str = "en" + entities: list[str] | None = None + score_threshold: float | None = None + return_decision_process: bool = False + + +class AnonymizeRequest(BaseModel): + text: str + analyzer_results: list[dict[str, Any]] = [] + anonymizers: dict[str, dict[str, Any]] | None = None + operators: dict[str, dict[str, Any]] | None = None + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/supportedentities") +def supported_entities(language: str = "en") -> list[str]: + return analyzer.get_supported_entities(language) + + +@app.post("/analyze") +def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]: + results = analyzer.analyze( + text=req.text, + language=req.language, + entities=req.entities or None, + score_threshold=req.score_threshold, + return_decision_process=req.return_decision_process, + ) + return [r.to_dict() for r in results] + + +@app.post("/anonymize") +def anonymize(req: AnonymizeRequest) -> dict[str, Any]: + analyzer_results = [ + RecognizerResult( + entity_type=r["entity_type"], + start=r["start"], + end=r["end"], + score=r.get("score", 1.0), + ) + for r in req.analyzer_results + ] + raw_operators = req.anonymizers or req.operators + operators = None + if raw_operators: + operators = {} + for entity, raw_cfg in raw_operators.items(): + op_cfg = dict(raw_cfg) + op_type = op_cfg.pop("type", "replace") + operators[entity] = OperatorConfig(op_type, op_cfg) + result = anonymizer.anonymize( + text=req.text, + analyzer_results=analyzer_results, + operators=operators, + ) + return { + "text": result.text, + "items": [ + { + "operator": item.operator, + "entity_type": item.entity_type, + "start": item.start, + "end": item.end, + "text": item.text, + } + for item in result.items + ], + } diff --git a/apps/sim/app/_shell/providers/session-provider.test.tsx b/apps/sim/app/_shell/providers/session-provider.test.tsx new file mode 100644 index 0000000000..4b0a2b68b8 --- /dev/null +++ b/apps/sim/app/_shell/providers/session-provider.test.tsx @@ -0,0 +1,278 @@ +/** + * @vitest-environment jsdom + */ +import { act, useContext } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockSetActive: vi.fn(), + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + client: { + getSession: mockGetSession, + organization: { setActive: mockSetActive }, + }, +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +vi.mock('posthog-js', () => ({ + default: { + identify: vi.fn(), + reset: vi.fn(), + startSessionRecording: vi.fn(), + sessionRecordingStarted: vi.fn(() => true), + }, +})) + +import type { AppSession } from '@/lib/auth/session-response' +import { + SessionContext, + type SessionHookResult, + SessionProvider, +} from '@/app/_shell/providers/session-provider' +import { sessionKeys, useSessionQuery } from '@/hooks/queries/session' + +/** Deferred promise: lets a test resolve a mocked async call at a chosen moment. */ +function defer() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** Set the jsdom URL search string before rendering the provider. */ +function setSearch(search: string) { + window.history.replaceState({}, '', `/${search}`) +} + +const STALE_SESSION: AppSession = { + user: { id: 'user-1', email: 'u@x.com', name: 'Stale plan' }, + session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' }, +} + +const FRESH_SESSION: AppSession = { + user: { id: 'user-1', email: 'u@x.com', name: 'Fresh plan' }, + session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' }, +} + +interface Harness { + ctx: () => SessionHookResult | null + queryClient: QueryClient + unmount: () => void +} + +/** + * Mounts SessionProvider in a real React 19 root under jsdom with a real + * QueryClient, capturing the live context value via a probe consumer. + */ +function renderProvider(): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root: Root = createRoot(container) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + let latest: SessionHookResult | null = null + function Probe() { + latest = useContext(SessionContext) + return null + } + + act(() => { + root.render( + + + + + + ) + }) + + return { + ctx: () => latest, + queryClient, + unmount: () => act(() => root.unmount()), + } +} + +/** + * Flush pending work inside an act() boundary. Drains the microtask queue and + * then yields one macrotask tick, so React Query's notifyManager (which can + * schedule observer notifications on a timer) and any deferred renders settle + * deterministically — microtask-only flushing raced the query→render update. + */ +async function flush() { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** Repeatedly flush until `predicate` holds or the budget runs out. */ +async function flushUntil(predicate: () => boolean, attempts = 40) { + for (let i = 0; i < attempts; i++) { + if (predicate()) return + await flush() + } +} + +/** True when the getSession call is the upgrade (disableCookieCache) read. */ +function isUpgradeCall(arg: unknown): boolean { + return Boolean( + arg && + typeof arg === 'object' && + 'query' in (arg as Record) && + (arg as { query?: { disableCookieCache?: boolean } }).query?.disableCookieCache === true + ) +} + +describe('useSessionQuery', () => { + it('uses an all-rooted key factory and a 5-minute staleTime', () => { + expect(sessionKeys.all).toEqual(['session']) + expect(sessionKeys.detail()).toEqual(['session', 'detail']) + // The hook is exported and reads from the same detail key. + expect(typeof useSessionQuery).toBe('function') + }) +}) + +describe('SessionProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + setSearch('') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('exposes the contract context shape and the loaded session on a normal load', async () => { + mockGetSession.mockResolvedValue({ data: STALE_SESSION }) + + const h = renderProvider() + await flushUntil(() => h.ctx()?.data != null) + + const ctx = h.ctx() + expect(ctx).not.toBeNull() + expect(ctx).toMatchObject({ + data: expect.any(Object), + isPending: expect.any(Boolean), + error: null, + }) + expect(typeof ctx?.refetch).toBe('function') + expect(ctx?.data).toEqual(STALE_SESSION) + expect(ctx?.isPending).toBe(false) + + h.unmount() + }) + + it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => { + setSearch('?upgraded=true') + + const mount = defer<{ data: AppSession }>() + const upgrade = defer<{ data: AppSession }>() + + mockGetSession.mockImplementation((arg?: unknown) => { + if (isUpgradeCall(arg)) return upgrade.promise + // Honor the abort signal like the real fetch-backed client: cancelQueries + // aborts the in-flight mount read, so it rejects rather than resolving late. + const signal = (arg as { fetchOptions?: { signal?: AbortSignal } })?.fetchOptions?.signal + signal?.addEventListener('abort', () => + mount.reject(new DOMException('Aborted', 'AbortError')) + ) + return mount.promise + }) + // activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached. + + const h = renderProvider() + await flush() + + // Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before + // setQueryData, cancelling (aborting) the in-flight stale mount query. + await act(async () => { + upgrade.resolve({ data: FRESH_SESSION }) + await Promise.resolve() + }) + await flushUntil(() => h.queryClient.getQueryData(sessionKeys.detail()) != null) + + // Assert on the cache — the contended state cancelQueries + setQueryData + // govern. The fresh value wins; the aborted stale mount read never clobbers it. + expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION) + expect(h.queryClient.getQueryData(sessionKeys.detail())).not.toEqual(STALE_SESSION) + + h.unmount() + }) + + it('upgrade path: a failed fresh read keeps the user signed in and still reconciles plan surfaces', async () => { + setSearch('?upgraded=true') + + const mount = defer<{ data: AppSession }>() + const upgrade = defer<{ data: AppSession }>() + mockGetSession.mockImplementation((arg?: unknown) => + isUpgradeCall(arg) ? upgrade.promise : mount.promise + ) + + const invalidateSpy = vi.spyOn(QueryClient.prototype, 'invalidateQueries') + const invalidatedKeys = () => + invalidateSpy.mock.calls.map(([arg]) => (arg as { queryKey?: unknown[] })?.queryKey) + + const h = renderProvider() + await flush() + + // The fresh disableCookieCache read fails. + await act(async () => { + upgrade.reject(new Error('refresh failed')) + await Promise.resolve() + }) + await flush() + + // The normal cookie-cached mount query lands AFTER the failure. + await act(async () => { + mount.resolve({ data: STALE_SESSION }) + await Promise.resolve() + }) + await flushUntil( + () => + h.queryClient.getQueryData(sessionKeys.detail()) != null && + invalidatedKeys().some((k) => Array.isArray(k) && k[0] === 'subscription') + ) + + // The valid cookie-cached session is still cached — a failed upgrade refresh + // must not sign the user out, and it must not surface as a session error. + expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(STALE_SESSION) + expect(h.queryClient.getQueryState(sessionKeys.detail())?.error ?? null).toBeNull() + + // Plan surfaces read server truth, so they still reconcile after the failure. + expect(invalidatedKeys()).toContainEqual(['organizations']) + expect(invalidatedKeys()).toContainEqual(['subscription']) + + invalidateSpy.mockRestore() + h.unmount() + }) + + it('strips the upgraded param from the URL', async () => { + setSearch('?upgraded=true&keep=1') + mockGetSession.mockResolvedValue({ data: FRESH_SESSION }) + + const h = renderProvider() + await flush() + + expect(window.location.search).not.toContain('upgraded') + expect(window.location.search).toContain('keep=1') + + h.unmount() + }) +}) diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx index b19ee64788..d94c07e48b 100644 --- a/apps/sim/app/_shell/providers/session-provider.tsx +++ b/apps/sim/app/_shell/providers/session-provider.tsx @@ -1,32 +1,17 @@ 'use client' import type React from 'react' -import { createContext, useCallback, useEffect, useMemo, useState } from 'react' +import { createContext, useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations' import { client } from '@/lib/auth/auth-client' -import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response' - -export type AppSession = { - user: { - id: string - email: string - emailVerified?: boolean - name?: string | null - image?: string | null - role?: string - createdAt?: Date - updatedAt?: Date - } | null - session?: { - id?: string - userId?: string - activeOrganizationId?: string - impersonatedBy?: string | null - } -} | null +import { + type AppSession, + extractSessionDataFromAuthClientResult, +} from '@/lib/auth/session-response' +import { sessionKeys, useSessionQuery } from '@/hooks/queries/session' export type SessionHookResult = { data: AppSession @@ -40,56 +25,56 @@ export const SessionContext = createContext(null) const logger = createLogger('SessionProvider') export function SessionProvider({ children }: { children: React.ReactNode }) { - const [data, setData] = useState(null) - const [isPending, setIsPending] = useState(true) - const [error, setError] = useState(null) const queryClient = useQueryClient() - - const loadSession = useCallback(async (bypassCache = false) => { - try { - setIsPending(true) - setError(null) - const res = bypassCache - ? await client.getSession({ query: { disableCookieCache: true } }) - : await client.getSession() - const session = extractSessionDataFromAuthClientResult(res) as AppSession - setData(session) - return session - } catch (e) { - setError(e instanceof Error ? e : new Error('Failed to fetch session')) - return null - } finally { - setIsPending(false) - } - }, []) + const query = useSessionQuery() + const { data, isPending, error, refetch } = query useEffect(() => { let isCancelled = false - // Check if user was redirected after plan upgrade const params = new URLSearchParams(window.location.search) const wasUpgraded = params.get('upgraded') === 'true' - if (wasUpgraded) { - params.delete('upgraded') - const newUrl = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname - window.history.replaceState({}, '', newUrl) + if (!wasUpgraded) { + return + } + + params.delete('upgraded') + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname + window.history.replaceState({}, '', newUrl) + + const refreshAfterUpgrade = async () => { + const res = await client.getSession({ query: { disableCookieCache: true } }) + const fresh = extractSessionDataFromAuthClientResult(res) as AppSession + + if (isCancelled) return null + + await queryClient.cancelQueries({ queryKey: sessionKeys.detail() }) + queryClient.setQueryData(sessionKeys.detail(), fresh) + return fresh } const initializeSession = async () => { - const session = await loadSession(wasUpgraded) + let session: AppSession = null + try { + session = await refreshAfterUpgrade() + } catch (e) { + logger.warn('Failed to refresh session after subscription upgrade', { error: e }) + } - if (!wasUpgraded || isCancelled) { + if (isCancelled) { return } + // Refresh the plan surfaces even if the cookie-bypass read above failed: they + // query server truth (not the session cookie cache), so they still reconcile. queryClient.invalidateQueries({ queryKey: ['organizations'] }) queryClient.invalidateQueries({ queryKey: ['subscription'] }) const activeOrganizationId = session?.session?.activeOrganizationId ?? null - if (activeOrganizationId) { + if (!session || activeOrganizationId) { return } @@ -106,7 +91,12 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { await client.organization.setActive({ organizationId }) if (!isCancelled) { - await loadSession(true) + const res = await client.getSession({ query: { disableCookieCache: true } }) + const fresh = extractSessionDataFromAuthClientResult(res) as AppSession + if (!isCancelled) { + await queryClient.cancelQueries({ queryKey: sessionKeys.detail() }) + queryClient.setQueryData(sessionKeys.detail(), fresh) + } } } catch (error) { logger.warn('Failed to activate organization after subscription upgrade', { error }) @@ -118,7 +108,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { return () => { isCancelled = true } - }, [loadSession, queryClient]) + }, [queryClient]) useEffect(() => { if (isPending) return @@ -150,12 +140,15 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { .catch(() => {}) }, [data, isPending]) - const refetch = useCallback(async () => { - await loadSession() - }, [loadSession]) - const value = useMemo( - () => ({ data, isPending, error, refetch }), + () => ({ + data: data ?? null, + isPending, + error, + refetch: async () => { + await refetch() + }, + }), [data, isPending, error, refetch] ) diff --git a/apps/sim/app/api/guardrails/mask-batch/route.test.ts b/apps/sim/app/api/guardrails/mask-batch/route.test.ts new file mode 100644 index 0000000000..cbb5b12265 --- /dev/null +++ b/apps/sim/app/api/guardrails/mask-batch/route.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckInternalAuth, mockMaskPIIBatch } = vi.hoisted(() => ({ + mockCheckInternalAuth: vi.fn(), + mockMaskPIIBatch: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkInternalAuth: mockCheckInternalAuth, +})) + +vi.mock('@/lib/guardrails/validate_pii', () => ({ + maskPIIBatch: mockMaskPIIBatch, +})) + +import { POST } from '@/app/api/guardrails/mask-batch/route' + +describe('POST /api/guardrails/mask-batch', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInternalAuth.mockResolvedValue({ success: true }) + mockMaskPIIBatch.mockImplementation(async (texts: string[]) => texts.map((t) => `M(${t})`)) + }) + + it('returns 401 without internal auth', async () => { + mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Internal authentication required', + }) + + const res = await POST( + createMockRequest('POST', { texts: ['a@b.com'], entityTypes: ['EMAIL_ADDRESS'] }) + ) + + expect(res.status).toBe(401) + expect(mockMaskPIIBatch).not.toHaveBeenCalled() + }) + + it('masks the batch in-process and preserves order', async () => { + const res = await POST( + createMockRequest('POST', { + texts: ['a@b.com', 'hello'], + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + }) + ) + + expect(res.status).toBe(200) + const json = await res.json() + expect(json.masked).toEqual(['M(a@b.com)', 'M(hello)']) + expect(mockMaskPIIBatch).toHaveBeenCalledWith(['a@b.com', 'hello'], ['EMAIL_ADDRESS'], 'en') + }) + + it('rejects an invalid body with 400', async () => { + const res = await POST(createMockRequest('POST', { texts: 'not-an-array', entityTypes: [] })) + + expect(res.status).toBe(400) + expect(mockMaskPIIBatch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/guardrails/mask-batch/route.ts b/apps/sim/app/api/guardrails/mask-batch/route.ts new file mode 100644 index 0000000000..696b69e749 --- /dev/null +++ b/apps/sim/app/api/guardrails/mask-batch/route.ts @@ -0,0 +1,45 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { guardrailsMaskBatchContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { maskPIIBatch } from '@/lib/guardrails/validate_pii' + +const logger = createLogger('GuardrailsMaskBatchAPI') + +/** + * Internal batch PII masking. The log-redaction persist path runs in both the + * Next.js server and the trigger.dev runtime, but the Presidio sidecars live only + * in the app task — so redaction calls this endpoint server-to-server (internal + * JWT) to keep Presidio centralized here. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(guardrailsMaskBatchContract, request, {}) + if (!parsed.success) return parsed.response + + const { texts, entityTypes, language } = parsed.data.body + + try { + const masked = await maskPIIBatch(texts, entityTypes, language) + logger.info('Masked PII batch', { count: texts.length }) + return NextResponse.json({ masked }) + } catch (error) { + // An unreachable/misconfigured Presidio sidecar makes maskPIIBatch throw; fail + // loudly here (the caller scrubs to REDACTION_FAILED, so PII is never leaked). + logger.error('PII batch masking failed', { + error: getErrorMessage(error), + count: texts.length, + }) + return NextResponse.json( + { error: getErrorMessage(error, 'PII masking failed') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/organizations/[id]/data-retention/route.ts b/apps/sim/app/api/organizations/[id]/data-retention/route.ts index 37fbbaabb9..7d7052a392 100644 --- a/apps/sim/app/api/organizations/[id]/data-retention/route.ts +++ b/apps/sim/app/api/organizations/[id]/data-retention/route.ts @@ -16,6 +16,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { coercePiiLanguage } from '@/lib/guardrails/pii-entities' const logger = createLogger('DataRetentionAPI') @@ -35,7 +36,14 @@ function normalizeConfigured( logRetentionHours: settings?.logRetentionHours ?? null, softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null, taskCleanupHours: settings?.taskCleanupHours ?? null, - piiRedaction: settings?.piiRedaction?.rules ? { rules: settings.piiRedaction.rules } : null, + piiRedaction: settings?.piiRedaction?.rules + ? { + rules: settings.piiRedaction.rules.map((rule) => ({ + ...rule, + language: coercePiiLanguage(rule.language), + })), + } + : null, } } diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts index d6b0c17c4f..5ca3fb8c04 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -39,6 +39,9 @@ vi.mock('@/lib/core/config/env-flags', () => ({ }, })) vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) +vi.mock('@/lib/core/async-jobs/region', () => ({ + resolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), +})) vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTasksTrigger }, task: (config: unknown) => config, @@ -196,7 +199,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { excludeRowIds: ['row_keep'], cutoff: expect.any(String), }), - { tags: ['tableId:tbl_1', 'jobId:job-id-xyz'] } + { tags: ['tableId:tbl_1', 'jobId:job-id-xyz'], region: 'us-east-1' } ) }) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts index 92d3106669..9a525eb54f 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -86,14 +86,15 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Trigger.dev runs the delete outside the web container (survives deploys) and retries — // safe: the keyset + cutoff walk just deletes whatever remains. try { - const [{ tableDeleteTask }, { tasks }] = await Promise.all([ + const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-delete'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger( 'table-delete', { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff: cutoff.toISOString() }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { // A failed dispatch must not leave a ghost `running` job holding the diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts index 0403e62647..9208808e61 100644 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -61,12 +61,14 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const payload: TableExportPayload = { jobId, tableId, workspaceId, format } if (isTriggerDevEnabled) { try { - const [{ tableExportTask }, { tasks }] = await Promise.all([ + const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-export'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-export', payload, { tags: [`tableId:${tableId}`, `jobId:${jobId}`], + region: await resolveTriggerRegion(), }) } catch (error) { // A failed dispatch must not leave a ghost `running` job holding the diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 72b0fa28fd..22f51bfaa9 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -83,12 +83,14 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (isTriggerDevEnabled) { // Trigger.dev runs the import outside the web container, so it survives app deploys. try { - const [{ tableImportTask }, { tasks }] = await Promise.all([ + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-import'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-import', importPayload, { tags: [`tableId:${tableId}`, `jobId:${importId}`], + region: await resolveTriggerRegion(), }) } catch (error) { // A failed dispatch must not leave a ghost `running` job holding the diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 97afd2fd97..bb0d83d168 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -115,12 +115,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isTriggerDevEnabled) { // Trigger.dev runs the import outside the web container, so it survives app deploys. try { - const [{ tableImportTask }, { tasks }] = await Promise.all([ + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-import'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-import', importPayload, { tags: [`tableId:${table.id}`, `jobId:${importId}`], + region: await resolveTriggerRegion(), }) } catch (error) { // A failed dispatch must not leave a ghost `running` job holding the diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index a38cf549ba..4d62dd4940 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -19,6 +19,7 @@ import { agentMailMessageSchema, webhookSvixHeadersSchema, } from '@/lib/api/contracts/webhooks' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { assertContentLengthWithinLimit, @@ -234,6 +235,7 @@ export const POST = withRouteHandler(async (req: Request) => { { taskId }, { tags: [`workspaceId:${result.id}`, `taskId:${taskId}`], + region: await resolveTriggerRegion(), } ) await db diff --git a/apps/sim/app/unsubscribe/unsubscribe.tsx b/apps/sim/app/unsubscribe/unsubscribe.tsx index 3804267ea4..c7fac2f9aa 100644 --- a/apps/sim/app/unsubscribe/unsubscribe.tsx +++ b/apps/sim/app/unsubscribe/unsubscribe.tsx @@ -1,91 +1,38 @@ 'use client' -import { Suspense, useEffect, useState } from 'react' +import { Suspense } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { useSearchParams } from 'next/navigation' import { Loader } from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import type { ContractJsonResponse } from '@/lib/api/contracts' -import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user' +import type { UnsubscribeType } from '@/lib/api/contracts/user' import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' import { InviteLayout } from '@/app/invite/components' - -type UnsubscribeData = ContractJsonResponse +import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe' function UnsubscribeContent() { const searchParams = useSearchParams() - const [loading, setLoading] = useState(true) - const [data, setData] = useState(null) - const [error, setError] = useState(null) - const [processing, setProcessing] = useState(false) - const [unsubscribed, setUnsubscribed] = useState(false) - const email = searchParams.get('email') const token = searchParams.get('token') - useEffect(() => { - if (!email || !token) { - setError('Missing email or token in URL') - setLoading(false) - return - } - - requestJson(unsubscribeGetContract, { query: { email, token } }) - .then((response) => { - setData(response) - }) - .catch((err: unknown) => { - const message = getErrorMessage(err, 'Failed to validate unsubscribe link') - setError(message) - }) - .finally(() => { - setLoading(false) - }) - }, [email, token]) - - const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => { + const hasParams = Boolean(email) && Boolean(token) + const query = useUnsubscribe(email ?? undefined, token ?? undefined) + const unsubscribe = useUnsubscribeMutation() + + const data = query.data ?? null + const loading = hasParams && query.isLoading + const processing = unsubscribe.isPending + const unsubscribed = unsubscribe.isSuccess + const error = !hasParams + ? 'Missing email or token in URL' + : query.isError + ? getErrorMessage(query.error, 'Failed to validate unsubscribe link') + : unsubscribe.isError + ? getErrorMessage(unsubscribe.error, 'Failed to process unsubscribe request') + : null + + const handleUnsubscribe = (type: UnsubscribeType) => { if (!email || !token) return - - setProcessing(true) - - try { - await requestJson(unsubscribePostContract, { - body: { email, token, type }, - }) - - setUnsubscribed(true) - if (data) { - const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const - if (validTypes.includes(type)) { - if (type === 'all') { - setData({ - ...data, - currentPreferences: { - ...data.currentPreferences, - unsubscribeAll: true, - }, - }) - } else { - const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as - | 'unsubscribeMarketing' - | 'unsubscribeUpdates' - | 'unsubscribeNotifications' - setData({ - ...data, - currentPreferences: { - ...data.currentPreferences, - [propertyKey]: true, - }, - }) - } - } - } - } catch (err: unknown) { - const message = getErrorMessage(err, 'Failed to process unsubscribe request') - setError(message) - } finally { - setProcessing(false) - } + unsubscribe.mutate({ email, token, type }) } if (loading) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/error.tsx b/apps/sim/app/workspace/[workspaceId]/files/error.tsx new file mode 100644 index 0000000000..a3e69db7e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function FilesError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx new file mode 100644 index 0000000000..59a072ba7f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function KnowledgeError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx new file mode 100644 index 0000000000..4b1c259bf7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/error.test.tsx @@ -0,0 +1,75 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/components/emcn', () => ({ + Button: ({ children, ...props }: { children: ReactNode } & Record) => ( + + ), +})) + +vi.mock('@/app/workspace/[workspaceId]/components', async () => { + const errorModule = await import('@/app/workspace/[workspaceId]/components/error') + return errorModule +}) + +import LogsError from './error' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() +}) + +function findButtonByText(text: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll('button')).find( + (el) => el.textContent?.trim() === text + ) + if (!button) throw new Error(`Button with text "${text}" not found`) + return button as HTMLButtonElement +} + +describe('LogsError boundary', () => { + it('renders the title and description from the shared ErrorState', () => { + const error = Object.assign(new Error('boom'), { digest: 'abc123' }) + + act(() => { + root.render() + }) + + expect(container.textContent).toContain('Failed to load logs') + expect(container.textContent).toContain( + 'Something went wrong while loading the logs. Please try again.' + ) + }) + + it('calls reset when the refresh action is clicked', () => { + const reset = vi.fn() + const error = Object.assign(new Error('boom'), { digest: 'abc123' }) + + act(() => { + root.render() + }) + + act(() => { + findButtonByText('Refresh').click() + }) + + expect(reset).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/error.tsx b/apps/sim/app/workspace/[workspaceId]/logs/error.tsx new file mode 100644 index 0000000000..7d6310f993 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function LogsError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 2faba4da3b..976119bd96 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -454,6 +454,27 @@ function IconComponent({ return } +const UNSUPPORTED_CUSTOM_TOOL_MESSAGE = 'Custom tools are not supported by this block yet' +const UNSUPPORTED_MCP_TOOL_MESSAGE = 'MCP tools are not supported by this block yet' + +/** + * Trailing "Unavailable" affordance for a tool category the consuming block + * cannot execute. Rendered as the combobox item's suffix so the greyed-out row + * still surfaces a tooltip explaining why on hover. + */ +function UnsupportedToolBadge({ message }: { message: string }) { + return ( + + + Unavailable + + + {message} + + + ) +} + export const ToolInput = memo(function ToolInput({ blockId, subBlockId, @@ -495,6 +516,16 @@ export const ToolInput = memo(function ToolInput({ ? (value as StoredTool[]) : [] + // Tool categories the consuming block can't run (declared on its tool-input + // subBlock): shown in the picker but greyed out with a tooltip instead of added. + const blockType = useWorkflowStore(useCallback((state) => state.blocks[blockId]?.type, [blockId])) + const unsupportedToolTypes = useMemo(() => { + const block = getAllBlocks().find((b) => b.type === blockType) + return block?.subBlocks.find((sb) => sb.id === subBlockId)?.unsupportedToolTypes ?? [] + }, [blockType, subBlockId]) + const mcpUnsupported = unsupportedToolTypes.includes('mcp') + const customUnsupported = unsupportedToolTypes.includes('custom-tool') + // Look up credential type for reactive condition filtering (e.g. service account detection). // Uses canonical resolution so the active field (basic vs advanced) is respected. const toolCredentialId = useMemo(() => { @@ -1346,7 +1377,12 @@ export const ToolInput = memo(function ToolInput({ const groups: ComboboxOptionGroup[] = [] // MCP Server drill-down: when navigated into a server, show only its tools - if (mcpServerDrilldown && !permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + if ( + mcpServerDrilldown && + !permissionConfig.disableMcpTools && + !mcpUnsupported && + mcpToolsByServer.size > 0 + ) { const tools = mcpToolsByServer.get(mcpServerDrilldown) if (tools && tools.length > 0) { const server = mcpServers.find((s) => s.id === mcpServerDrilldown) @@ -1458,7 +1494,10 @@ export const ToolInput = memo(function ToolInput({ setCustomToolModalOpen(true) setOpen(false) }, - disabled: isPreview, + disabled: isPreview || customUnsupported, + suffixElement: customUnsupported ? ( + + ) : undefined, }) } if (!permissionConfig.disableMcpTools) { @@ -1470,14 +1509,17 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) setMcpModalOpen(true) }, - disabled: isPreview, + disabled: isPreview || mcpUnsupported, + suffixElement: mcpUnsupported ? ( + + ) : undefined, }) } if (actionItems.length > 0) { groups.push({ items: actionItems }) } - if (!permissionConfig.disableCustomTools && customTools.length > 0) { + if (!permissionConfig.disableCustomTools && !customUnsupported && customTools.length > 0) { groups.push({ section: 'Custom Tools', items: customTools.map((customTool) => { @@ -1507,7 +1549,7 @@ export const ToolInput = memo(function ToolInput({ } // MCP Servers — root folder view - if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpToolsByServer.size > 0) { const serverItems: ComboboxOption[] = [] for (const [serverId, tools] of mcpToolsByServer) { @@ -1620,6 +1662,8 @@ export const ToolInput = memo(function ToolInput({ handleSelectTool, permissionConfig.disableCustomTools, permissionConfig.disableMcpTools, + mcpUnsupported, + customUnsupported, availableWorkflows, isToolAlreadySelected, ]) diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 42fefcda81..7acd5a8901 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -1,4 +1,5 @@ import { ShieldCheckIcon } from '@/components/icons' +import { PII_ENTITY_GROUPS, PII_LANGUAGES } from '@/lib/guardrails/pii-entities' import type { BlockConfig } from '@/blocks/types' import { getModelOptions, @@ -170,65 +171,15 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, title: 'PII Types to Detect', type: 'grouped-checkbox-list', maxHeight: 400, - options: [ - // Common PII types - { label: 'Person name', id: 'PERSON', group: 'Common' }, - { label: 'Email address', id: 'EMAIL_ADDRESS', group: 'Common' }, - { label: 'Phone number', id: 'PHONE_NUMBER', group: 'Common' }, - { label: 'Location', id: 'LOCATION', group: 'Common' }, - { label: 'Date or time', id: 'DATE_TIME', group: 'Common' }, - { label: 'IP address', id: 'IP_ADDRESS', group: 'Common' }, - { label: 'URL', id: 'URL', group: 'Common' }, - { label: 'Credit card number', id: 'CREDIT_CARD', group: 'Common' }, - { label: 'International bank account number (IBAN)', id: 'IBAN_CODE', group: 'Common' }, - { label: 'Cryptocurrency wallet address', id: 'CRYPTO', group: 'Common' }, - { label: 'Medical license number', id: 'MEDICAL_LICENSE', group: 'Common' }, - { label: 'Nationality / religion / political group', id: 'NRP', group: 'Common' }, - - // USA - { label: 'US bank account number', id: 'US_BANK_NUMBER', group: 'USA' }, - { label: 'US driver license number', id: 'US_DRIVER_LICENSE', group: 'USA' }, - { - label: 'US individual taxpayer identification number (ITIN)', - id: 'US_ITIN', - group: 'USA', - }, - { label: 'US passport number', id: 'US_PASSPORT', group: 'USA' }, - { label: 'US Social Security number', id: 'US_SSN', group: 'USA' }, - - // UK - { label: 'UK National Insurance number', id: 'UK_NINO', group: 'UK' }, - { label: 'UK NHS number', id: 'UK_NHS', group: 'UK' }, - - // Spain - { label: 'Spanish NIF number', id: 'ES_NIF', group: 'Spain' }, - { label: 'Spanish NIE number', id: 'ES_NIE', group: 'Spain' }, - - // Italy - { label: 'Italian fiscal code', id: 'IT_FISCAL_CODE', group: 'Italy' }, - { label: 'Italian driver license', id: 'IT_DRIVER_LICENSE', group: 'Italy' }, - { label: 'Italian identity card', id: 'IT_IDENTITY_CARD', group: 'Italy' }, - { label: 'Italian passport', id: 'IT_PASSPORT', group: 'Italy' }, - - // Poland - { label: 'Polish PESEL', id: 'PL_PESEL', group: 'Poland' }, - - // Singapore - { label: 'Singapore NRIC/FIN', id: 'SG_NRIC_FIN', group: 'Singapore' }, - - // Australia - { label: 'Australian business number (ABN)', id: 'AU_ABN', group: 'Australia' }, - { label: 'Australian company number (ACN)', id: 'AU_ACN', group: 'Australia' }, - { label: 'Australian tax file number (TFN)', id: 'AU_TFN', group: 'Australia' }, - { label: 'Australian Medicare number', id: 'AU_MEDICARE', group: 'Australia' }, - - // India - { label: 'Indian Aadhaar', id: 'IN_AADHAAR', group: 'India' }, - { label: 'Indian PAN', id: 'IN_PAN', group: 'India' }, - { label: 'Indian vehicle registration', id: 'IN_VEHICLE_REGISTRATION', group: 'India' }, - { label: 'Indian voter number', id: 'IN_VOTER', group: 'India' }, - { label: 'Indian passport', id: 'IN_PASSPORT', group: 'India' }, - ], + // Driven by the shared catalog (includes VIN and custom recognizers) so the + // block and the Data Retention settings never drift. + options: PII_ENTITY_GROUPS.flatMap((group) => + group.entities.map((entity) => ({ + label: entity.label, + id: entity.value, + group: group.label, + })) + ), condition: { field: 'validationType', value: ['pii'], @@ -255,13 +206,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, id: 'piiLanguage', title: 'Language', type: 'dropdown', - options: [ - { label: 'English', id: 'en' }, - { label: 'Spanish', id: 'es' }, - { label: 'Italian', id: 'it' }, - { label: 'Polish', id: 'pl' }, - { label: 'Finnish', id: 'fi' }, - ], + options: PII_LANGUAGES.map((language) => ({ label: language.label, id: language.value })), defaultValue: 'en', condition: { field: 'validationType', diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts new file mode 100644 index 0000000000..25f4bb61e9 --- /dev/null +++ b/apps/sim/blocks/blocks/pi.ts @@ -0,0 +1,386 @@ +import { PiIcon } from '@/components/icons' +import { getEnv, isTruthy } from '@/lib/core/config/env' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { + getPiModelOptions, + getProviderCredentialSubBlocks, + PROVIDER_CREDENTIAL_INPUTS, +} from '@/blocks/utils' +import type { ToolResponse } from '@/tools/types' + +interface PiResponse extends ToolResponse { + output: { + content: string + model: string + changedFiles?: string[] + diff?: string + prUrl?: string + branch?: string + tokens?: { + input?: number + output?: number + total?: number + } + cost?: { + input?: number + output?: number + total?: number + } + providerTiming?: { + startTime?: string + endTime?: string + duration?: number + } + } +} + +const CLOUD: { field: 'mode'; value: 'cloud' } = { field: 'mode', value: 'cloud' } +const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } +const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens'] + +export const PiBlock: BlockConfig = { + type: 'pi', + name: 'Pi Coding Agent', + description: 'Run an autonomous coding agent on a repo', + authMode: AuthMode.ApiKey, + longDescription: + 'The Pi Coding Agent runs the Pi harness against a real repository. In Cloud mode it spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a pull request. In Local mode it edits files on your own machine over SSH. Both modes stream progress and reuse your models, skills, and multi-turn memory.', + bestPractices: ` + - Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. + - Use Local mode to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH. + - Cloud mode requires your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox. + `, + category: 'blocks', + integrationType: IntegrationType.AI, + bgColor: '#6E56CF', + icon: PiIcon, + subBlocks: [ + { + id: 'mode', + title: 'Mode', + type: 'dropdown', + // Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled. + value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'), + options: () => { + const options = [ + { + label: 'Local', + id: 'local', + description: 'Edits files on your own machine over SSH', + }, + ] + if (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))) { + options.unshift({ + label: 'Cloud', + id: 'cloud', + description: 'Runs in an isolated sandbox, clones your repo, and opens a PR', + }) + } + return options + }, + }, + { + id: 'task', + title: 'Task', + type: 'long-input', + placeholder: 'Describe what the coding agent should do...', + required: true, + }, + { + id: 'model', + title: 'Model', + type: 'combobox', + placeholder: 'Type or select a model...', + required: true, + defaultValue: 'claude-sonnet-4-6', + options: getPiModelOptions, + commandSearchable: true, + }, + + ...getProviderCredentialSubBlocks(), + + { + id: 'owner', + title: 'Repository Owner', + type: 'short-input', + placeholder: 'e.g., your-org', + required: true, + condition: CLOUD, + }, + { + id: 'repo', + title: 'Repository Name', + type: 'short-input', + placeholder: 'e.g., my-repo', + required: true, + condition: CLOUD, + }, + { + id: 'githubToken', + title: 'GitHub Token', + type: 'short-input', + password: true, + paramVisibility: 'user-only', + placeholder: 'GitHub personal access token (repo scope)', + tooltip: 'Personal access token with repo scope, used to clone, push, and open the PR.', + required: true, + condition: CLOUD, + }, + { + id: 'baseBranch', + title: 'Base Branch', + type: 'short-input', + placeholder: 'e.g., main (defaults to the repository default branch)', + tooltip: 'The branch the pull request is opened against; the repo is cloned from it too.', + condition: CLOUD, + }, + { + id: 'branchName', + title: 'Branch Name', + type: 'short-input', + placeholder: 'Auto-generated when blank', + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'draft', + title: 'Open as Draft PR', + type: 'switch', + defaultValue: true, + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'prTitle', + title: 'PR Title', + type: 'short-input', + placeholder: 'Generated from the run when blank', + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'prBody', + title: 'PR Body', + type: 'long-input', + placeholder: 'Generated from the run when blank', + mode: 'advanced', + condition: CLOUD, + }, + + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'Public hostname from a TCP tunnel (e.g., 2.tcp.ngrok.io)', + tooltip: + 'The machine must be reachable on a public hostname — localhost/LAN addresses are blocked. Use a raw TCP tunnel such as `ngrok tcp 22`.', + required: true, + condition: LOCAL, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'ubuntu, root, or deploy', + required: true, + condition: LOCAL, + }, + { + id: 'authMethod', + title: 'Authentication Method', + type: 'dropdown', + defaultValue: 'password', + options: [ + { label: 'Password', id: 'password' }, + { label: 'Private Key', id: 'privateKey' }, + ], + condition: LOCAL, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + password: true, + paramVisibility: 'user-only', + placeholder: 'Your SSH password', + required: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, + condition: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, + dependsOn: ['authMethod'], + }, + { + id: 'privateKey', + title: 'Private Key', + type: 'code', + paramVisibility: 'user-only', + placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', + required: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + condition: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + dependsOn: ['authMethod'], + }, + { + id: 'repoPath', + title: 'Repository Path', + type: 'short-input', + placeholder: '/home/user/my-repo', + tooltip: 'Absolute path to the repository on the target machine.', + required: true, + condition: LOCAL, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '22', + defaultValue: '22', + mode: 'advanced', + condition: LOCAL, + }, + { + id: 'passphrase', + title: 'Passphrase', + type: 'short-input', + password: true, + paramVisibility: 'user-only', + placeholder: 'Passphrase for encrypted key (optional)', + mode: 'advanced', + condition: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + dependsOn: ['authMethod'], + }, + { + id: 'tools', + title: 'Tools', + type: 'tool-input', + defaultValue: [], + mode: 'advanced', + condition: LOCAL, + unsupportedToolTypes: ['mcp', 'custom-tool'], + }, + + { + id: 'skills', + title: 'Skills', + type: 'skill-input', + defaultValue: [], + mode: 'advanced', + }, + { + id: 'thinkingLevel', + title: 'Thinking Level', + type: 'dropdown', + defaultValue: 'medium', + options: [ + { label: 'none', id: 'none' }, + { label: 'low', id: 'low' }, + { label: 'medium', id: 'medium' }, + { label: 'high', id: 'high' }, + { label: 'max', id: 'max' }, + ], + mode: 'advanced', + }, + { + id: 'memoryType', + title: 'Memory', + type: 'dropdown', + defaultValue: 'none', + options: [ + { label: 'None', id: 'none' }, + { label: 'Conversation', id: 'conversation' }, + { label: 'Sliding window (messages)', id: 'sliding_window' }, + { label: 'Sliding window (tokens)', id: 'sliding_window_tokens' }, + ], + mode: 'advanced', + }, + { + id: 'conversationId', + title: 'Conversation ID', + type: 'short-input', + placeholder: 'e.g., user-123, session-abc', + mode: 'advanced', + required: { field: 'memoryType', value: MEMORY_TYPES }, + condition: { field: 'memoryType', value: MEMORY_TYPES }, + dependsOn: ['memoryType'], + }, + { + id: 'slidingWindowSize', + title: 'Sliding Window Size', + type: 'short-input', + placeholder: 'Enter number of messages (e.g., 10)...', + mode: 'advanced', + condition: { field: 'memoryType', value: ['sliding_window'] }, + dependsOn: ['memoryType'], + }, + { + id: 'slidingWindowTokens', + title: 'Max Tokens', + type: 'short-input', + placeholder: 'Enter max tokens (e.g., 4000)...', + mode: 'advanced', + condition: { field: 'memoryType', value: ['sliding_window_tokens'] }, + dependsOn: ['memoryType'], + }, + ], + tools: { + access: [], + }, + inputs: { + mode: { type: 'string', description: 'Execution mode: cloud or local' }, + task: { type: 'string', description: 'Instruction for the coding agent' }, + model: { type: 'string', description: 'AI model to use' }, + owner: { type: 'string', description: 'GitHub repository owner (cloud mode)' }, + repo: { type: 'string', description: 'GitHub repository name (cloud mode)' }, + githubToken: { type: 'string', description: 'GitHub token override (cloud mode)' }, + baseBranch: { type: 'string', description: 'Base branch for the PR (cloud mode)' }, + branchName: { type: 'string', description: 'Branch to create (cloud mode)' }, + draft: { type: 'boolean', description: 'Open the PR as a draft (cloud mode)' }, + prTitle: { type: 'string', description: 'Pull request title (cloud mode)' }, + prBody: { type: 'string', description: 'Pull request body (cloud mode)' }, + host: { type: 'string', description: 'SSH host (local mode)' }, + port: { type: 'number', description: 'SSH port (local mode)' }, + username: { type: 'string', description: 'SSH username (local mode)' }, + authMethod: { type: 'string', description: 'SSH authentication method (local mode)' }, + password: { type: 'string', description: 'SSH password (local mode)' }, + privateKey: { type: 'string', description: 'SSH private key (local mode)' }, + passphrase: { type: 'string', description: 'SSH key passphrase (local mode)' }, + repoPath: { type: 'string', description: 'Repository path on the target (local mode)' }, + tools: { type: 'json', description: 'Sim tools exposed to the agent (local mode)' }, + skills: { type: 'json', description: 'Selected skills configuration' }, + thinkingLevel: { type: 'string', description: 'Thinking level for the model' }, + memoryType: { type: 'string', description: 'Memory type for multi-turn conversations' }, + conversationId: { type: 'string', description: 'Conversation ID for memory' }, + slidingWindowSize: { type: 'string', description: 'Number of messages for sliding window' }, + slidingWindowTokens: { type: 'string', description: 'Max tokens for token-based window' }, + ...PROVIDER_CREDENTIAL_INPUTS, + }, + outputs: { + content: { type: 'string', description: 'Final agent message / run summary' }, + model: { type: 'string', description: 'Model used for the run' }, + changedFiles: { type: 'json', description: 'Files changed by the agent' }, + diff: { type: 'string', description: 'Unified diff of the changes' }, + prUrl: { + type: 'string', + description: 'URL of the opened pull request', + condition: CLOUD, + }, + branch: { + type: 'string', + description: 'Branch pushed with the changes', + condition: CLOUD, + }, + tokens: { type: 'json', description: 'Token usage statistics' }, + cost: { type: 'json', description: 'Cost of the run' }, + providerTiming: { type: 'json', description: 'Provider timing information' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index a03be20796..8ee9b75e7d 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -226,6 +226,7 @@ import { ParallelBlock, ParallelBlockMeta } from '@/blocks/blocks/parallel' import { PeopleDataLabsBlock, PeopleDataLabsBlockMeta } from '@/blocks/blocks/peopledatalabs' import { PerplexityBlock, PerplexityBlockMeta } from '@/blocks/blocks/perplexity' import { PersonaBlock, PersonaBlockMeta } from '@/blocks/blocks/persona' +import { PiBlock } from '@/blocks/blocks/pi' import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' @@ -530,6 +531,7 @@ const BLOCK_REGISTRY: Record = { peopledatalabs: PeopleDataLabsBlock, perplexity: PerplexityBlock, persona: PersonaBlock, + pi: PiBlock, pinecone: PineconeBlock, pipedrive: PipedriveBlock, polymarket: PolymarketBlock, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 0d6b846aa5..ed5ed73f23 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -416,6 +416,13 @@ export interface SubBlockConfig { blockId: string, optionId: string ) => Promise<{ label: string; id: string } | null> + /** + * tool-input only: tool categories the consuming block cannot execute. They + * stay visible in the picker but are greyed out with a tooltip rather than + * hidden. Block/integration tools always run via `executeTool`, so only the + * non-registry categories (`mcp`, `custom-tool`) can be marked unsupported. + */ + unsupportedToolTypes?: ('mcp' | 'custom-tool')[] } export interface BlockConfig { diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 41cc478ad2..3dc571b9dd 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -57,6 +57,10 @@ vi.mock('@/providers/models', () => ({ getBaseModelProviders: mockGetBaseModelProviders, })) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: vi.fn(() => 'openai'), +})) + vi.mock('@/stores/providers/store', () => ({ useProvidersStore: { getState: () => ({ diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 8fc80b1009..a803bdf8c2 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -15,6 +15,8 @@ import { getProviderModels, orderModelIdsByReleaseDate, } from '@/providers/models' +import { isPiSupportedProvider } from '@/providers/pi-providers' +import { getProviderFromModel } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' export const VERTEX_MODELS = getProviderModels('vertex') @@ -78,6 +80,23 @@ export function getModelOptions() { }) } +/** + * Model options filtered to providers the Pi Coding Agent can run (see + * {@link isPiSupportedProvider}), so the Pi block never offers a model that would + * error at execution. Uses the same `getProviderFromModel` resolution as the Pi + * handler, so the dropdown matches runtime behavior; unresolved/blacklisted + * models (which `getProviderFromModel` can throw on) are excluded. + */ +export function getPiModelOptions() { + return getModelOptions().filter((option) => { + try { + return isPiSupportedProvider(getProviderFromModel(option.id)) + } catch { + return false + } + }) +} + /** * Gets all dependency fields as a flat array. * Handles both simple array format and object format with all/any fields. diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index c920a30428..e25ecb9197 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3439,6 +3439,16 @@ export const DeepseekIcon = (props: SVGProps) => ( ) +export const SakanaIcon = (props: SVGProps) => ( + + Sakana AI + + +) + export function GeminiIcon(props: SVGProps) { const id = useId() const gradientId = `gemini_gradient_${id}` @@ -5288,6 +5298,28 @@ export function SmtpIcon(props: SVGProps) { ) } +export function PiIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function SshIcon(props: SVGProps) { return ( onChange({ ...draft, entityTypes })} /> + + onChange({ ...draft, language: language as PIILanguage })} + options={PII_LANGUAGES.map((l) => ({ value: l.value, label: l.label }))} + align='start' + /> + + +/** A resolved skill (name + full content) made available to Pi. */ +export interface PiSkill { + name: string + content: string +} + +/** SSH connection parameters for local mode (subset of the shared SSH config). */ +export type PiSshConnection = Pick< + SSHConnectionConfig, + 'host' | 'port' | 'username' | 'password' | 'privateKey' | 'passphrase' +> + +/** Result of invoking a tool Pi called. */ +export interface PiToolResult { + text: string + isError: boolean +} + +/** + * A tool exposed to Pi in a backend-neutral shape (the SSH file/bash tools and + * adapted Sim tools both use it). The local backend converts these into Pi + * `customTools`; keeping them Pi-SDK-free keeps this seam typed. + */ +export interface PiToolSpec { + name: string + description: string + parameters: Record + execute: (args: Record) => Promise +} + +interface PiRunBaseParams { + model: string + providerId: string + apiKey: string + isBYOK: boolean + task: string + thinkingLevel?: string + skills: PiSkill[] + initialMessages: PiMessage[] +} + +/** Parameters for a local (SSH) Pi run. */ +export interface PiLocalRunParams extends PiRunBaseParams { + mode: 'local' + ssh: PiSshConnection + repoPath: string + tools: PiToolSpec[] +} + +/** Parameters for a cloud (E2B) Pi run. */ +export interface PiCloudRunParams extends PiRunBaseParams { + mode: 'cloud' + owner: string + repo: string + githubToken: string + baseBranch?: string + branchName?: string + draft: boolean + prTitle?: string + prBody?: string +} + +export type PiRunParams = PiLocalRunParams | PiCloudRunParams + +/** Progress callbacks and cancellation passed into a backend run. */ +export interface PiRunContext { + onEvent: (event: PiEvent) => void + signal?: AbortSignal +} + +/** Final result of a Pi run. */ +export interface PiRunResult { + totals: PiRunTotals + changedFiles?: string[] + diff?: string + prUrl?: string + branch?: string +} + +/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */ +export type PiBackendRun

= ( + params: P, + context: PiRunContext +) => Promise diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts new file mode 100644 index 0000000000..d81ed981c2 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -0,0 +1,281 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVar } = vi.hoisted( + () => ({ + mockRun: vi.fn(), + mockReadFile: vi.fn(), + mockWriteFile: vi.fn(), + mockExecuteTool: vi.fn(), + mockProviderEnvVar: vi.fn(), + }) +) + +vi.mock('@/lib/execution/e2b', () => ({ + withPiSandbox: (fn: (runner: unknown) => unknown) => + fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }), +})) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/executor/handlers/pi/keys', () => ({ + providerApiKeyEnvVar: mockProviderEnvVar, + mapThinkingLevel: () => 'medium', +})) +vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' })) + +import type { PiCloudRunParams } from '@/executor/handlers/pi/backend' +import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' + +function baseParams(overrides: Partial = {}): PiCloudRunParams { + return { + mode: 'cloud', + model: 'claude', + providerId: 'anthropic', + apiKey: 'sk-byok', + isBYOK: true, + task: 'do it', + skills: [], + initialMessages: [], + owner: 'octo', + repo: 'demo', + githubToken: 'ghp_secret', + branchName: 'feature-x', + draft: true, + ...overrides, + } +} + +describe('runCloudPi', () => { + beforeEach(() => { + vi.clearAllMocks() + mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY') + mockReadFile.mockResolvedValue('diff content') + mockExecuteTool.mockResolvedValue({ + success: true, + output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1' } }, + }) + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ + stdout: '__BASE_SHA__=abc123\n__DEFAULT_BRANCH__=main', + stderr: '', + exitCode: 0, + }) + } + if (command.includes('pi -p')) { + options.onStdout?.( + '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}\n' + ) + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '__PUSHED__=1', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + } + ) + }) + + it('isolates secrets per command: token only in clone/push, model key only in the Pi loop', async () => { + const onEvent = vi.fn() + await runCloudPi(baseParams(), { onEvent }) + + const [cloneCmd, cloneOpts] = mockRun.mock.calls[0] + const [piCmd, piOpts] = mockRun.mock.calls[1] + const [prepareCmd, prepareOpts] = mockRun.mock.calls[2] + const [pushCmd, pushOpts] = mockRun.mock.calls[3] + + expect(cloneCmd).toContain('git clone') + expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + expect(piCmd).toContain('pi -p') + expect(piCmd).toContain('--provider') + expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok') + expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined() + expect(piOpts.envs.PI_MODEL).toBe('claude') + expect(piOpts.envs.PI_PROVIDER).toBe('anthropic') + + // PREPARE (add/commit/diff) must NOT carry the token: a repo-config-driven + // program the agent may have planted (clean filter, fsmonitor, textconv) runs + // on these commands and `core.hooksPath` does not stop it, so the credential + // must simply be absent. + expect(prepareCmd).toContain('add -A') + expect(prepareCmd).toContain('core.hooksPath=/dev/null') + expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined() + expect(prepareOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + // PUSH is the only token-bearing command, hardened against planted git-config + // program execution (hooks, credential.helper, fsmonitor). + expect(pushCmd).toContain('push') + expect(pushCmd).toContain('core.hooksPath=/dev/null') + expect(pushCmd).toContain('credential.helper=') + expect(pushCmd).toContain('core.fsmonitor=') + expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'done' }) + }) + + it('delivers the prompt and commit message via files, never the command line', async () => { + await runCloudPi(baseParams(), { onEvent: vi.fn() }) + + // Untrusted text is written through the sandbox FS API, not interpolated into a shell command. + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT') + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: do it') + + const [piCmd, piOpts] = mockRun.mock.calls[1] + // Prompt arrives on stdin from a fixed path; never a CLI arg or env value. + expect(piCmd).toContain('< /workspace/pi-prompt.txt') + expect(piCmd).not.toContain('PROMPT') + expect(piOpts.envs.PI_TASK).toBeUndefined() + + const [prepareCmd, prepareOpts] = mockRun.mock.calls[2] + // Commit message is read from a file, not passed as -m "...". + expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt') + expect(prepareCmd).not.toContain('commit -m') + expect(prepareOpts.envs.COMMIT_MSG).toBeUndefined() + }) + + it('opens a PR from the pushed branch and returns its URL', async () => { + const result = await runCloudPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ + owner: 'octo', + repo: 'demo', + head: 'feature-x', + base: 'main', + draft: true, + apiKey: 'ghp_secret', + }) + ) + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') + expect(result.branch).toBe('feature-x') + expect(result.changedFiles).toEqual(['src/x.ts']) + expect(result.diff).toBe('diff content') + }) + + it('skips the PR when nothing was pushed', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + + const result = await runCloudPi(baseParams(), { onEvent: vi.fn() }) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(result.prUrl).toBeUndefined() + // No changes => the token-bearing push command must never run. + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + + it('rejects a non-BYOK key (no Sim-owned key in the sandbox)', async () => { + await expect(runCloudPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow( + /BYOK/ + ) + }) + + it('rejects providers that cannot run via a single key', async () => { + mockProviderEnvVar.mockReturnValue(null) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/not supported/) + }) + + it('fails when the Pi CLI exits non-zero (no PR opened)', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: 'model not found', exitCode: 1 }) + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + }) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/Pi agent failed/) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('does not commit, push, or open a PR when the run reports an error on a zero exit', async () => { + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + options.onStdout?.('{"type":"error","error":"model exploded"}\n') + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + } + ) + + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + + it('fails (no PR) when finalize reports neither no-changes nor a push', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + // PREPARE aborted before emitting a marker (e.g. the repo dir vanished). + return Promise.resolve({ + stdout: '', + stderr: 'cd: /workspace/repo: No such file or directory', + exitCode: 1, + }) + }) + + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/finalize failed/) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + + it('surfaces the real git push error when the push fails, with the token scrubbed', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + }) + // The push step writes its stderr to a file; the backend reads + scrubs it. + mockReadFile.mockResolvedValue( + "remote: Permission to octo/demo.git denied.\nfatal: unable to access 'https://x-access-token:ghp_secret@github.com/octo/demo.git/': 403" + ) + + const error = (await runCloudPi(baseParams(), { onEvent: vi.fn() }).catch((e) => e)) as Error + expect(error.message).toMatch(/git push failed/) + expect(error.message).toMatch(/Permission to octo\/demo\.git denied/) + expect(error.message).not.toContain('ghp_secret') + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts new file mode 100644 index 0000000000..f635eade0d --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -0,0 +1,353 @@ +/** + * Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned + * GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per + * command (S2/KTD10): the GitHub token is present only for the clone and push + * commands (and stripped from the cloned remote), while the Pi loop runs with a + * BYOK model key only. The model key is never a Sim-owned hosted key (S1). + * + * Untrusted text (the assembled prompt, which folds in workspace-shared skills + * and memory, and the commit message) is never placed on a shell command line. + * It is written into sandbox files via the E2B filesystem API and read back from + * fixed paths (Pi's prompt on stdin, `git commit -F `), so a collaborator- + * authored skill cannot inject shell into the Pi step where the model key lives. + */ + +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { withPiSandbox } from '@/lib/execution/e2b' +import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/context' +import { + applyPiEvent, + createPiTotals, + type PiRunTotals, + parseJsonLine, +} from '@/executor/handlers/pi/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' +import { executeTool } from '@/tools' + +const logger = createLogger('PiCloudBackend') + +const REPO_DIR = '/workspace/repo' +const DIFF_PATH = '/workspace/pi.diff' + +const PROMPT_PATH = '/workspace/pi-prompt.txt' +const COMMIT_MSG_PATH = '/workspace/pi-commit.txt' + +const PUSH_ERR_PATH = '/workspace/pi-push-err.txt' +const CLONE_TIMEOUT_MS = 10 * 60 * 1000 + +const PI_TIMEOUT_MS = getMaxExecutionTimeout() +const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000 +const MAX_DIFF_BYTES = 200_000 +const COMMIT_TITLE_MAX = 72 +const PR_SUMMARY_MAX = 2000 +const PUSH_ERROR_MAX = 1000 + +// The agent only edits files; Sim commits, pushes, and opens the PR after the run. +// Without this, the coding agent tries to git push / open a PR / run the test +// toolchain itself and fails — the sandbox has no GitHub auth (the token is +// stripped from the remote after clone) and may lack the project's tooling. +const CLOUD_GUIDANCE = + 'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' + + 'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' + + 'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' + + "pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " + + 'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.' + +const CLONE_SCRIPT = `set -e +rm -rf ${REPO_DIR} +git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} +cd ${REPO_DIR} +if [ -n "$BASE_BRANCH" ]; then git checkout "$BASE_BRANCH"; fi +git rev-parse HEAD | sed "s/^/__BASE_SHA__=/" +DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed "s#^origin/##" || true) +echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH" +git checkout -b "$BRANCH" +git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"` + +const PI_SCRIPT = `cd ${REPO_DIR} +pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}` + +// Finalize is split so the GitHub token is in scope for ONLY the push. `git add`, +// `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does +// NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor` +// (on add/diff), and `diff.external`/textconv (on diff). The untrusted Pi loop can +// plant `.gitattributes` + `.git/config` to run code during these. Keeping the +// token out of PREPARE's env means a planted program has no credential to steal; +// hooks are disabled too as defense-in-depth. Commit runs unconditionally +// (`|| true` tolerates an empty commit); the push decision is gated on HEAD +// advancing past base, so commits the agent made itself are still pushed. +const PREPARE_SCRIPT = `set -e +cd ${REPO_DIR} +git -c core.hooksPath=/dev/null add -A +git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true +git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/" +git diff "$BASE_SHA" HEAD > ${DIFF_PATH} 2>/dev/null || true +if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "__NEEDS_PUSH__=1"; fi` + +// The only token-bearing command. The agent-planted `.git/config` is still active, +// so neutralize every config key that could run a program during push: hooks +// (pre-push), `credential.helper` (runs during auth), and `core.fsmonitor`. +// Filters/textconv don't run on push (no checkout/add/diff here). +const PUSH_SCRIPT = `cd ${REPO_DIR} +git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"` + +function raceAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise + if (signal.aborted) return Promise.reject(new Error('Pi run aborted')) + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Pi run aborted')) + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function extractMarkerValues(stdout: string, prefix: string): string[] { + return stdout + .split('\n') + .filter((line) => line.startsWith(prefix)) + .map((line) => line.slice(prefix.length).trim()) + .filter(Boolean) +} + +/** + * Redacts the GitHub token from git output before it is surfaced in an error. + * Removes the literal token and any URL userinfo (`//user:token@`), so a failure + * message can quote git's real stderr without leaking the credential. + */ +function scrubGitSecrets(text: string, token: string): string { + const withoutToken = token ? text.split(token).join('***') : text + return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@') +} + +function buildPrBody(task: string, finalText: string): string { + const summary = finalText.trim() + ? truncate(finalText.trim(), PR_SUMMARY_MAX) + : 'Automated changes by the Pi Coding Agent.' + return `## Task\n\n${task}\n\n## Summary\n\n${summary}` +} + +/** The commit message and PR title share one default, derived from the PR title or task. */ +function defaultTitle(params: PiCloudRunParams): string { + return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) +} + +async function openPullRequest( + params: PiCloudRunParams, + branch: string, + detectedBase: string | undefined, + totals: PiRunTotals +): Promise { + const base = params.baseBranch?.trim() || detectedBase + if (!base) { + throw new Error( + `Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.` + ) + } + const title = defaultTitle(params) + const body = params.prBody?.trim() || buildPrBody(params.task, totals.finalText) + + const result = await executeTool('github_create_pr', { + owner: params.owner, + repo: params.repo, + title, + head: branch, + base, + body, + draft: params.draft, + apiKey: params.githubToken, + }) + + if (!result.success) { + throw new Error( + `Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}` + ) + } + + const output = result.output as { metadata?: { html_url?: string } } | undefined + return output?.metadata?.html_url +} + +export const runCloudPi: PiBackendRun = async (params, context) => { + if (!params.isBYOK) { + throw new Error( + 'Cloud mode requires your own provider API key (BYOK). Set one in Settings > BYOK.' + ) + } + const keyEnvVar = providerApiKeyEnvVar(params.providerId) + if (!keyEnvVar) { + throw new Error( + `Provider "${params.providerId}" is not supported in cloud mode. Use a key-based provider or run in local mode.` + ) + } + + const branch = params.branchName?.trim() || `pi/${generateShortId(8)}` + const commitMessage = defaultTitle(params) + const prompt = buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + guidance: CLOUD_GUIDANCE, + }) + const totals = createPiTotals() + const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' + + return withPiSandbox(async (runner) => { + try { + const clone = await raceAbort( + runner.run(CLONE_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BASE_BRANCH: params.baseBranch?.trim() ?? '', + BRANCH: branch, + }, + timeoutMs: CLONE_TIMEOUT_MS, + }), + context.signal + ) + if (clone.exitCode !== 0) { + throw new Error( + `git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}` + ) + } + const baseSha = extractMarkerValues(clone.stdout, '__BASE_SHA__=')[0] + if (!baseSha) { + throw new Error('Clone did not report a base commit') + } + const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0] + + // Deliver the prompt as a file (read back on Pi's stdin), not a CLI + // arg/env, so its skill/memory content can't be parsed by the shell that + // launches the Pi loop. + await runner.writeFile(PROMPT_PATH, prompt) + + let buffer = '' + const handleChunk = (chunk: string) => { + buffer += chunk + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + const event = parseJsonLine(line) + if (!event) continue + applyPiEvent(totals, event) + context.onEvent(event) + } + } + const piRun = await raceAbort( + runner.run(PI_SCRIPT, { + envs: { + [keyEnvVar]: params.apiKey, + PI_PROVIDER: params.providerId, + PI_MODEL: params.model, + PI_THINKING: thinking, + }, + timeoutMs: PI_TIMEOUT_MS, + onStdout: handleChunk, + }), + context.signal + ) + const remaining = buffer.trim() ? parseJsonLine(buffer) : null + if (remaining) { + applyPiEvent(totals, remaining) + context.onEvent(remaining) + } + if (piRun.exitCode !== 0) { + throw new Error( + `Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim() + ) + } + + if (totals.errorMessage) { + throw new Error(`Pi agent failed: ${totals.errorMessage}`) + } + + // Same rationale as the prompt: keep the commit message off the command line. + await runner.writeFile(COMMIT_MSG_PATH, commitMessage) + + // PREPARE stages, commits, and diffs WITHOUT the GitHub token in scope, so a + // repo-config-driven program the agent may have planted can't exfiltrate it. + const prepare = await raceAbort( + runner.run(PREPARE_SCRIPT, { + envs: { BASE_SHA: baseSha }, + timeoutMs: FINALIZE_TIMEOUT_MS, + }), + context.signal + ) + const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=') + const noChanges = prepare.stdout.includes('__NO_CHANGES__=1') + const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1') + // PREPARE (`set -e`) emits exactly one of the two markers on success. Neither + // means the finalize step itself failed (e.g. the repo dir vanished mid-run) — + // surface that rather than silently reporting success with no push. + if (!noChanges && !needsPush) { + const reason = (prepare.stderr || prepare.stdout || 'no status reported').trim() + throw new Error(`Pi finalize failed: ${truncate(reason, PUSH_ERROR_MAX)}`) + } + + let diff: string | undefined + try { + const raw = await runner.readFile(DIFF_PATH) + diff = + raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw + } catch { + diff = undefined + } + + if (noChanges) { + logger.info('Pi cloud run produced no changes to push', { + owner: params.owner, + repo: params.repo, + }) + return { totals, changedFiles, diff } + } + + // PUSH is the only command that carries the token, hardened against any + // git-config program execution the agent may have planted. + const push = await raceAbort( + runner.run(PUSH_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BRANCH: branch, + }, + timeoutMs: FINALIZE_TIMEOUT_MS, + }), + context.signal + ) + if (!push.stdout.includes('__PUSHED__=1')) { + let reason = push.stderr?.trim() + try { + const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim() + if (pushErr) reason = pushErr + } catch {} + const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken) + throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`) + } + + const prUrl = await openPullRequest(params, branch, detectedBase, totals) + return { totals, changedFiles, diff, prUrl, branch } + } catch (error) { + // Aborts propagate as errors so a cancelled/timed-out run is not reported as + // success and no partial memory turn is persisted (local mode mirrors this). + if (context.signal?.aborted) { + logger.info('Pi cloud run aborted', { owner: params.owner, repo: params.repo }) + } + throw error + } + }) +} diff --git a/apps/sim/executor/handlers/pi/context.ts b/apps/sim/executor/handlers/pi/context.ts new file mode 100644 index 0000000000..dfabfd67a5 --- /dev/null +++ b/apps/sim/executor/handlers/pi/context.ts @@ -0,0 +1,118 @@ +/** + * Reuses the Agent block's skills and memory subsystems for Pi runs. Skills + * resolve to full `{ name, content }` entries (so a backend can surface them as + * Pi skills), and multi-turn memory goes through the shared `memoryService` + * keyed by `memoryType`/`conversationId` — seeding the run and persisting the + * user task plus the agent's final message. + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { memoryService } from '@/executor/handlers/agent/memory' +import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver' +import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types' +import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend' +import type { ExecutionContext } from '@/executor/types' + +const logger = createLogger('PiContext') + +/** Memory configuration — the Agent block's memory input fields, reused as-is. */ +export type PiMemoryConfig = Pick< + AgentInputs, + 'memoryType' | 'conversationId' | 'slidingWindowSize' | 'slidingWindowTokens' | 'model' +> + +function isMemoryEnabled(config: PiMemoryConfig): boolean { + return !!config.memoryType && config.memoryType !== 'none' +} + +/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */ +export async function resolvePiSkills( + skillInputs: unknown, + workspaceId: string | undefined +): Promise { + if (!Array.isArray(skillInputs) || !workspaceId) return [] + + const skills: PiSkill[] = [] + for (const input of skillInputs as SkillInput[]) { + if (!input?.skillId) continue + try { + const resolved = await resolveSkillContentById(input.skillId, workspaceId) + if (resolved) skills.push({ name: resolved.name, content: resolved.content }) + } catch (error) { + logger.warn('Failed to resolve skill for Pi', { + skillId: input.skillId, + error: getErrorMessage(error), + }) + } + } + return skills +} + +/** Loads prior conversation messages to seed the Pi run. */ +export async function loadPiMemory( + ctx: ExecutionContext, + config: PiMemoryConfig +): Promise { + if (!isMemoryEnabled(config)) return [] + try { + const messages = await memoryService.fetchMemoryMessages(ctx, config) + return messages.map((message: Message) => ({ role: message.role, content: message.content })) + } catch (error) { + logger.warn('Failed to load Pi memory', { error: getErrorMessage(error) }) + return [] + } +} + +/** + * Builds the prompt: optional operating `guidance` (mode-specific constraints), + * then skills, prior memory, and the task. + */ +export function buildPiPrompt(input: { + skills: PiSkill[] + initialMessages: PiMessage[] + task: string + guidance?: string +}): string { + const parts: string[] = [] + + if (input.guidance) { + parts.push(`# Operating instructions\n${input.guidance}`) + } + + if (input.skills.length > 0) { + parts.push('# Available skills') + for (const skill of input.skills) { + parts.push(`## ${skill.name}\n${skill.content}`) + } + } + + if (input.initialMessages.length > 0) { + parts.push('# Prior conversation') + for (const message of input.initialMessages) { + parts.push(`${message.role}: ${message.content}`) + } + } + + parts.push('# Task') + parts.push(input.task) + return parts.join('\n\n') +} + +/** Persists the user task and the agent's final message to memory. */ +export async function appendPiMemory( + ctx: ExecutionContext, + config: PiMemoryConfig, + task: string, + finalText: string +): Promise { + if (!isMemoryEnabled(config)) return + try { + await memoryService.appendToMemory(ctx, config, { role: 'user', content: task }) + if (finalText) { + await memoryService.appendToMemory(ctx, config, { role: 'assistant', content: finalText }) + } + } catch (error) { + logger.warn('Failed to append Pi memory', { error: getErrorMessage(error) }) + } +} diff --git a/apps/sim/executor/handlers/pi/events.test.ts b/apps/sim/executor/handlers/pi/events.test.ts new file mode 100644 index 0000000000..c34e41549a --- /dev/null +++ b/apps/sim/executor/handlers/pi/events.test.ts @@ -0,0 +1,116 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + applyPiEvent, + createPiTotals, + normalizePiEvent, + parseJsonLine, + streamTextForEvent, +} from '@/executor/handlers/pi/events' + +describe('normalizePiEvent', () => { + it('maps a text_delta message_update to a text event', () => { + expect( + normalizePiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello' }, + }) + ).toEqual({ type: 'text', text: 'hello' }) + }) + + it('maps a thinking_delta message_update to a thinking event', () => { + expect( + normalizePiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' }, + }) + ).toEqual({ type: 'thinking', text: 'hmm' }) + }) + + it('maps tool execution start and end', () => { + expect(normalizePiEvent({ type: 'tool_execution_start', toolName: 'bash' })).toEqual({ + type: 'tool_start', + toolName: 'bash', + }) + expect( + normalizePiEvent({ type: 'tool_execution_end', toolName: 'bash', isError: true }) + ).toEqual({ + type: 'tool_end', + toolName: 'bash', + isError: true, + }) + }) + + it('extracts usage from turn_end via message.usage and direct usage', () => { + expect( + normalizePiEvent({ type: 'turn_end', message: { usage: { input: 5, output: 7 } } }) + ).toEqual({ type: 'usage', inputTokens: 5, outputTokens: 7 }) + expect( + normalizePiEvent({ type: 'turn_end', usage: { prompt_tokens: 3, completion_tokens: 2 } }) + ).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 }) + }) + + it('maps agent_end to final and error to error', () => { + expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' }) + expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({ + type: 'error', + message: 'boom', + }) + }) + + it('returns other for unknown types and null for non-objects', () => { + expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' }) + expect(normalizePiEvent('nope')).toBeNull() + expect(normalizePiEvent(null)).toBeNull() + }) +}) + +describe('parseJsonLine', () => { + it('parses a valid json line', () => { + expect(parseJsonLine('{"type":"agent_end"}')).toEqual({ type: 'final' }) + }) + + it('returns null for blank or malformed lines', () => { + expect(parseJsonLine(' ')).toBeNull() + expect(parseJsonLine('{not json')).toBeNull() + }) +}) + +describe('applyPiEvent', () => { + it('accumulates text, sums usage, records tool calls and errors', () => { + const totals = createPiTotals() + applyPiEvent(totals, { type: 'text', text: 'a' }) + applyPiEvent(totals, { type: 'text', text: 'b' }) + applyPiEvent(totals, { type: 'usage', inputTokens: 3, outputTokens: 4 }) + applyPiEvent(totals, { type: 'usage', inputTokens: 1, outputTokens: 1 }) + applyPiEvent(totals, { type: 'tool_end', toolName: 'read', isError: false }) + applyPiEvent(totals, { type: 'error', message: 'boom' }) + + expect(totals.finalText).toBe('ab') + expect(totals.inputTokens).toBe(4) + expect(totals.outputTokens).toBe(5) + expect(totals.toolCalls).toEqual([{ name: 'read', isError: false }]) + expect(totals.errorMessage).toBe('boom') + }) + + it('uses final text only when no streamed text was seen', () => { + const empty = createPiTotals() + applyPiEvent(empty, { type: 'final', text: 'fallback' }) + expect(empty.finalText).toBe('fallback') + + const streamed = createPiTotals() + applyPiEvent(streamed, { type: 'text', text: 'streamed' }) + applyPiEvent(streamed, { type: 'final', text: 'fallback' }) + expect(streamed.finalText).toBe('streamed') + }) +}) + +describe('streamTextForEvent', () => { + it('returns text for text events and null otherwise', () => { + expect(streamTextForEvent({ type: 'text', text: 'x' })).toBe('x') + expect(streamTextForEvent({ type: 'thinking', text: 'x' })).toBeNull() + expect(streamTextForEvent({ type: 'final' })).toBeNull() + }) +}) diff --git a/apps/sim/executor/handlers/pi/events.ts b/apps/sim/executor/handlers/pi/events.ts new file mode 100644 index 0000000000..3686a23eb5 --- /dev/null +++ b/apps/sim/executor/handlers/pi/events.ts @@ -0,0 +1,160 @@ +/** + * Normalization layer for the Pi agent event stream. Both backends produce the + * same logical events — the local backend via the SDK `session.subscribe` + * callback, the cloud backend via `pi --mode json` stdout lines — so this module + * maps either source into a single {@link PiEvent} union and accumulates the + * run totals (final text, token usage, tool calls) the handler reports. + */ + +/** A single normalized event emitted during a Pi run. */ +export type PiEvent = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool_start'; toolName: string } + | { type: 'tool_end'; toolName: string; isError: boolean } + | { type: 'usage'; inputTokens: number; outputTokens: number } + | { type: 'final'; text?: string } + | { type: 'error'; message: string } + | { type: 'other' } + +/** A tool invocation observed during the run. */ +export interface PiToolCallRecord { + name: string + isError?: boolean +} + +/** Running totals accumulated across a Pi run. */ +export interface PiRunTotals { + finalText: string + inputTokens: number + outputTokens: number + toolCalls: PiToolCallRecord[] + errorMessage?: string +} + +/** Creates an empty totals accumulator. */ +export function createPiTotals(): PiRunTotals { + return { finalText: '', inputTokens: 0, outputTokens: 0, toolCalls: [] } +} + +/** + * Folds a normalized event into the totals. Text deltas accumulate into + * `finalText`; usage events sum (Pi reports per-turn usage on `turn_end`). + */ +export function applyPiEvent(totals: PiRunTotals, event: PiEvent): void { + switch (event.type) { + case 'text': + totals.finalText += event.text + break + case 'final': + if (event.text && totals.finalText.length === 0) { + totals.finalText = event.text + } + break + case 'usage': + totals.inputTokens += event.inputTokens + totals.outputTokens += event.outputTokens + break + case 'tool_end': + totals.toolCalls.push({ name: event.toolName, isError: event.isError }) + break + case 'error': + totals.errorMessage = event.message + break + default: + break + } +} + +/** Returns the text to enqueue onto the content stream for an event, if any. */ +export function streamTextForEvent(event: PiEvent): string | null { + return event.type === 'text' ? event.text : null +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function asNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** + * Extracts token usage from an event, tolerating the field names Pi and common + * provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`, + * `prompt_tokens`/`completion_tokens`), checked on the event and on a nested + * `message`/`usage` object. + */ +function extractUsage( + ev: Record +): { inputTokens: number; outputTokens: number } | null { + const candidates: Array> = [] + const direct = asRecord(ev.usage) + if (direct) candidates.push(direct) + const message = asRecord(ev.message) + if (message) { + const messageUsage = asRecord(message.usage) + if (messageUsage) candidates.push(messageUsage) + } + + for (const usage of candidates) { + const input = + asNumber(usage.input) || asNumber(usage.inputTokens) || asNumber(usage.prompt_tokens) + const output = + asNumber(usage.output) || asNumber(usage.outputTokens) || asNumber(usage.completion_tokens) + if (input > 0 || output > 0) { + return { inputTokens: input, outputTokens: output } + } + } + + return null +} + +/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */ +export function normalizePiEvent(raw: unknown): PiEvent | null { + const ev = asRecord(raw) + if (!ev) return null + + switch (asString(ev.type)) { + case 'message_update': { + const assistantEvent = asRecord(ev.assistantMessageEvent) + const deltaType = assistantEvent ? asString(assistantEvent.type) : '' + const delta = assistantEvent ? asString(assistantEvent.delta) : '' + if (deltaType === 'text_delta') return { type: 'text', text: delta } + if (deltaType === 'thinking_delta') return { type: 'thinking', text: delta } + return { type: 'other' } + } + case 'tool_execution_start': + return { type: 'tool_start', toolName: asString(ev.toolName) } + case 'tool_execution_end': + return { type: 'tool_end', toolName: asString(ev.toolName), isError: ev.isError === true } + case 'turn_end': { + const usage = extractUsage(ev) + return usage ? { type: 'usage', ...usage } : { type: 'other' } + } + case 'agent_end': + return { type: 'final' } + case 'error': + return { + type: 'error', + message: asString(ev.error) || asString(ev.message) || 'Pi run failed', + } + default: + return { type: 'other' } + } +} + +/** Parses one `pi --mode json` stdout line into a {@link PiEvent}. */ +export function parseJsonLine(line: string): PiEvent | null { + const trimmed = line.trim() + if (!trimmed) return null + try { + return normalizePiEvent(JSON.parse(trimmed)) + } catch { + return null + } +} diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts new file mode 100644 index 0000000000..17b407cc0a --- /dev/null +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetApiKeyWithBYOK, + mockGetBYOKKey, + mockGetProviderFromModel, + mockCalculateCost, + mockShouldBill, + mockResolveVertex, +} = vi.hoisted(() => ({ + mockGetApiKeyWithBYOK: vi.fn(), + mockGetBYOKKey: vi.fn(), + mockGetProviderFromModel: vi.fn(), + mockCalculateCost: vi.fn(), + mockShouldBill: vi.fn(), + mockResolveVertex: vi.fn(), +})) + +vi.mock('@/lib/api-key/byok', () => ({ + getApiKeyWithBYOK: mockGetApiKeyWithBYOK, + getBYOKKey: mockGetBYOKKey, +})) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: mockGetProviderFromModel, + calculateCost: mockCalculateCost, + shouldBillModelUsage: mockShouldBill, +})) +vi.mock('@/executor/utils/vertex-credential', () => ({ + resolveVertexCredential: mockResolveVertex, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 })) + +import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys' + +describe('providerApiKeyEnvVar', () => { + it('maps key-based providers and rejects unsupported ones', () => { + expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY') + expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY') + expect(providerApiKeyEnvVar('vertex')).toBeNull() + expect(providerApiKeyEnvVar('bedrock')).toBeNull() + expect(providerApiKeyEnvVar('something-else')).toBeNull() + }) +}) + +describe('computePiCost', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns zero cost for BYOK keys without billing', () => { + expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 }) + expect(mockCalculateCost).not.toHaveBeenCalled() + }) + + it('returns zero cost for non-billable models', () => { + mockShouldBill.mockReturnValue(false) + expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 }) + expect(mockCalculateCost).not.toHaveBeenCalled() + }) + + it('computes billed cost with the cost multiplier', () => { + mockShouldBill.mockReturnValue(true) + mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 }) + expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 }) + expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2) + }) +}) + +describe('resolvePiModelKey', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves Vertex credentials when the provider is vertex', async () => { + mockGetProviderFromModel.mockReturnValue('vertex') + mockResolveVertex.mockResolvedValue('vertex-token') + + const result = await resolvePiModelKey({ + model: 'gemini-pro', + mode: 'local', + userId: 'user-1', + vertexCredential: 'cred-1', + }) + + expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true }) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + + it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false }) + + const result = await resolvePiModelKey({ + model: 'claude', + mode: 'local', + workspaceId: 'ws-1', + apiKey: 'sk-test', + }) + + expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false }) + expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test') + }) + + it('cloud mode uses the block API Key field directly as a BYOK key', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + + const result = await resolvePiModelKey({ + model: 'claude', + mode: 'cloud', + workspaceId: 'ws-1', + apiKey: 'sk-user', + }) + + expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true }) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + expect(mockGetBYOKKey).not.toHaveBeenCalled() + }) + + it('cloud mode falls back to a stored workspace key when the field is empty', async () => { + mockGetProviderFromModel.mockReturnValue('openai') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true }) + + const result = await resolvePiModelKey({ + model: 'gpt-5', + mode: 'cloud', + workspaceId: 'ws-1', + }) + + expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true }) + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai') + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + + it('cloud mode rejects when no user key is available (never a hosted key)', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + mockGetBYOKKey.mockResolvedValue(null) + + await expect( + resolvePiModelKey({ model: 'claude', mode: 'cloud', workspaceId: 'ws-1' }) + ).rejects.toThrow(/your own provider API key/) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts new file mode 100644 index 0000000000..9d85eb8a4e --- /dev/null +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -0,0 +1,127 @@ +/** + * Model, provider-key, and cost resolution shared by both Pi backends. Local + * mode mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a + * Sim-hosted key may be used and billed. Cloud mode requires the user's own key + * (the block's API Key field, or a stored workspace BYOK key) and never a hosted + * key, since the key is handed to an untrusted sandbox. Vertex resolves through + * `resolveVertexCredential`; cost uses the billing multiplier and is zeroed for + * BYOK / non-billable models. + */ + +import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' +import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { resolveVertexCredential } from '@/executor/utils/vertex-credential' +import { isPiSupportedProvider, type PiSupportedProvider } from '@/providers/pi-providers' +import { calculateCost, getProviderFromModel, shouldBillModelUsage } from '@/providers/utils' +import type { BYOKProviderId } from '@/tools/types' + +/** Resolved provider, key, and BYOK flag for a Pi run. */ +export interface PiKeyResolution { + providerId: string + apiKey: string + isBYOK: boolean +} + +interface ResolvePiModelKeyParams { + model: string + mode: 'cloud' | 'local' + workspaceId?: string + userId?: string + apiKey?: string + vertexCredential?: string +} + +/** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */ +const WORKSPACE_BYOK_PROVIDERS = new Set(['anthropic', 'openai', 'google', 'mistral']) + +/** Resolves the provider and a usable API key for the selected model. */ +export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise { + const providerId = getProviderFromModel(params.model) + + if (providerId === 'vertex' && params.vertexCredential) { + const apiKey = await resolveVertexCredential( + params.vertexCredential, + params.userId, + 'vertex-pi' + ) + return { providerId, apiKey, isBYOK: true } + } + + // Cloud hands the model key to an untrusted sandbox, so it must be the user's + // own key — never a Sim-hosted/rotating key. Prefer the block's API Key field, + // then a stored workspace BYOK key; refuse to fall back to a hosted key. + if (params.mode === 'cloud') { + if (params.apiKey) { + return { providerId, apiKey: params.apiKey, isBYOK: true } + } + if (params.workspaceId && WORKSPACE_BYOK_PROVIDERS.has(providerId)) { + const byok = await getBYOKKey(params.workspaceId, providerId as BYOKProviderId) + if (byok) { + return { providerId, apiKey: byok.apiKey, isBYOK: true } + } + } + throw new Error( + WORKSPACE_BYOK_PROVIDERS.has(providerId) + ? 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.' + : 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field.' + ) + } + + const { apiKey, isBYOK } = await getApiKeyWithBYOK( + providerId, + params.model, + params.workspaceId, + params.apiKey + ) + return { providerId, apiKey, isBYOK } +} + +/** Run cost, zeroed for BYOK keys and models Sim does not bill. */ +export function computePiCost( + model: string, + inputTokens: number, + outputTokens: number, + isBYOK: boolean +) { + if (isBYOK || !shouldBillModelUsage(model)) { + return { input: 0, output: 0, total: 0 } + } + const multiplier = getCostMultiplier() + return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier) +} + +/** + * Env var the Pi CLI reads each provider's key from in the cloud sandbox. Keyed + * by {@link PiSupportedProvider}, so this map and the shared support set (which + * also drives the block's model dropdown) cannot drift — adding a provider to the + * set forces adding its env var here. + */ +const PROVIDER_API_KEY_ENV_VARS: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + google: 'GEMINI_API_KEY', + xai: 'XAI_API_KEY', + deepseek: 'DEEPSEEK_API_KEY', + mistral: 'MISTRAL_API_KEY', + groq: 'GROQ_API_KEY', + cerebras: 'CEREBRAS_API_KEY', + openrouter: 'OPENROUTER_API_KEY', +} + +/** + * Env var name a provider's API key is exposed under for the Pi CLI in the cloud + * sandbox, or `null` when Pi cannot run the provider via a single key. The cloud + * backend rejects `null` providers with a clear error rather than guessing. + */ +export function providerApiKeyEnvVar(providerId: string): string | null { + return isPiSupportedProvider(providerId) ? PROVIDER_API_KEY_ENV_VARS[providerId] : null +} + +/** Maps a Sim thinking level to Pi's `ThinkingLevel` (shared by both backends). */ +export function mapThinkingLevel(level?: string): CreateAgentSessionOptions['thinkingLevel'] { + if (!level || level === 'none') return 'off' + if (level === 'max') return 'xhigh' + if (level === 'low' || level === 'medium' || level === 'high') return level + return undefined +} diff --git a/apps/sim/executor/handlers/pi/local-backend.ts b/apps/sim/executor/handlers/pi/local-backend.ts new file mode 100644 index 0000000000..930dc2b2f9 --- /dev/null +++ b/apps/sim/executor/handlers/pi/local-backend.ts @@ -0,0 +1,204 @@ +/** + * Local-mode backend: runs the Pi harness embedded in Sim with its built-in + * tools disabled and replaced by SSH-backed file/bash tools (plus any adapted + * Sim tools), all over a single reused SSH connection. The provider key stays in + * Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash + * operations cross to the target machine. + * + * The Pi SDK is imported dynamically and externalized from the bundle, mirroring + * how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent' +import { createLogger } from '@sim/logger' +import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/context' +import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events' +import { mapThinkingLevel } from '@/executor/handlers/pi/keys' +import { + buildSshToolSpecs, + captureRepoChanges, + openSshSession, +} from '@/executor/handlers/pi/ssh-tools' + +const logger = createLogger('PiLocalBackend') + +const MAX_DIFF_BYTES = 200_000 + +// Local mode edits in place and reports the working-tree diff. The agent must not +// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR. +const LOCAL_GUIDANCE = + 'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' + + 'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' + + 'working tree; Sim reports them after you finish.' + +/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */ +type PiSdk = typeof import('@earendil-works/pi-coding-agent') + +let sdkPromise: Promise | undefined + +function loadPiSdk(): Promise { + if (!sdkPromise) { + // A static specifier (not a variable) is required so Next's dependency tracer + // copies the package + its transitive deps into the standalone Docker output, + // the same way `@e2b/code-interpreter` is handled. Clear the cache on failure + // so a transient import error doesn't permanently break later local runs. + sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => { + sdkPromise = undefined + throw error + }) + } + return sdkPromise +} + +function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition { + return sdk.defineTool({ + name: spec.name, + label: spec.name, + description: spec.description, + // double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema + parameters: spec.parameters as unknown as ToolDefinition['parameters'], + execute: async (_toolCallId, params) => { + const result = await spec.execute(params as Record) + return { + content: [{ type: 'text', text: result.text }], + details: { isError: result.isError }, + } + }, + }) +} + +/** + * Builds a model definition for a provider Pi supports but whose bundled catalog + * doesn't list this exact id (e.g. a newer model Pi wires to a different + * provider). Mirrors the cloud CLI's passthrough: clone one of the provider's + * models as a template, swap in the requested id, and force reasoning when a + * thinking level is requested. Returns undefined only when the provider has no + * models at all, so even passthrough can't route it. + */ +function buildPiFallbackModel( + modelRegistry: ModelRegistry, + provider: string, + modelId: string, + thinkingLevel: ReturnType +) { + const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider) + if (providerModels.length === 0) return undefined + const fallback = { ...providerModels[0], id: modelId, name: modelId } + return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback +} + +export const runLocalPi: PiBackendRun = async (params, context) => { + // Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader + // from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings. + const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-')) + // Clean up the scratch dir if the SSH connection fails — the try/finally below + // is only entered once the session is open, so an early handshake failure would + // otherwise orphan the directory. + const session = await openSshSession(params.ssh).catch(async (error) => { + await rm(isolatedDir, { recursive: true, force: true }).catch(() => {}) + throw error + }) + + try { + const sdk = await loadPiSdk() + + const authStorage = sdk.AuthStorage.create() + authStorage.setRuntimeApiKey(params.providerId, params.apiKey) + + const modelRegistry = sdk.ModelRegistry.create(authStorage) + const thinkingLevel = mapThinkingLevel(params.thinkingLevel) + // Parity with cloud: when the model isn't in Pi's bundled catalog under the + // resolved provider, pass it through on that provider instead of failing. + const model = + modelRegistry.find(params.providerId, params.model) ?? + buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel) + if (!model) { + throw new Error( + `Pi has no models for provider "${params.providerId}" (cannot run ${params.model})` + ) + } + + const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools] + const customTools = specs.map((spec) => toPiTool(sdk, spec)) + + const { session: agentSession } = await sdk.createAgentSession({ + cwd: isolatedDir, + agentDir: isolatedDir, + model, + thinkingLevel, + noTools: 'builtin', + customTools, + authStorage, + modelRegistry, + sessionManager: sdk.SessionManager.inMemory(isolatedDir), + }) + + const totals = createPiTotals() + const unsubscribe = agentSession.subscribe((raw) => { + const event = normalizePiEvent(raw) + if (!event) return + applyPiEvent(totals, event) + context.onEvent(event) + }) + + const onAbort = () => { + void agentSession.abort() + } + if (context.signal?.aborted) { + onAbort() + } else { + context.signal?.addEventListener('abort', onAbort, { once: true }) + } + + let runErrorMessage: string | undefined + try { + await agentSession.prompt( + buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + guidance: LOCAL_GUIDANCE, + }) + ) + // Pi has no error event; a failed run surfaces on the agent state. Capture + // it before `dispose()` so the failure can't be missed by a later read. + runErrorMessage = agentSession.agent.state.errorMessage + } finally { + unsubscribe() + context.signal?.removeEventListener('abort', onAbort) + try { + agentSession.dispose() + } catch (error) { + logger.warn('Failed to dispose Pi session', { error }) + } + } + + // Aborts propagate as errors so a cancelled/timed-out run is not reported as + // success and no partial memory turn is persisted (cloud mode mirrors this). + // Pi resolves `prompt()` on abort rather than rejecting, so check explicitly. + if (context.signal?.aborted) { + throw new Error('Pi run aborted') + } + + if (runErrorMessage) { + totals.errorMessage = runErrorMessage + return { totals } + } + + // Local mode edits in place (no PR), so report what changed via the repo's + // working-tree diff over the same SSH session. + const { changedFiles, diff } = await captureRepoChanges( + session, + params.repoPath, + MAX_DIFF_BYTES + ) + return { totals, changedFiles, diff } + } finally { + session.close() + await rm(isolatedDir, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts new file mode 100644 index 0000000000..3e1f951f64 --- /dev/null +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({ + mockRunLocal: vi.fn(), + mockRunCloud: vi.fn(), + mockResolveKey: vi.fn(), +})) + +vi.mock('@/executor/handlers/pi/keys', () => ({ + resolvePiModelKey: mockResolveKey, + computePiCost: () => ({ input: 0, output: 0, total: 0 }), +})) +vi.mock('@/executor/handlers/pi/context', () => ({ + resolvePiSkills: vi.fn().mockResolvedValue([]), + loadPiMemory: vi.fn().mockResolvedValue([]), + appendPiMemory: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/executor/handlers/pi/sim-tools', () => ({ + buildSimToolSpecs: vi.fn().mockResolvedValue([]), +})) +vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal })) +vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud })) +vi.mock('@/blocks/utils', () => ({ + parseOptionalNumberInput: (value: unknown) => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + }, +})) + +import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler' +import type { ExecutionContext, StreamingExecution } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const block = { id: 'blk', metadata: { id: 'pi' } } as unknown as SerializedBlock + +function ctx(overrides: Partial = {}): ExecutionContext { + return { + workflowId: 'wf', + workspaceId: 'ws', + userId: 'user', + ...overrides, + } as ExecutionContext +} + +function localInputs(extra: Record = {}) { + return { + mode: 'local', + task: 'do the thing', + model: 'claude', + host: 'box.example.com', + username: 'deploy', + authMethod: 'password', + password: 'pw', + repoPath: '/srv/repo', + ...extra, + } +} + +describe('PiBlockHandler', () => { + const handler = new PiBlockHandler() + + beforeEach(() => { + vi.clearAllMocks() + mockResolveKey.mockResolvedValue({ providerId: 'anthropic', apiKey: 'k', isBYOK: true }) + mockRunLocal.mockResolvedValue({ + totals: { finalText: 'hi', inputTokens: 1, outputTokens: 2, toolCalls: [] }, + }) + mockRunCloud.mockResolvedValue({ + totals: { finalText: 'done', inputTokens: 0, outputTokens: 0, toolCalls: [] }, + prUrl: 'https://github.com/o/r/pull/1', + branch: 'pi/abc', + changedFiles: ['a.ts'], + diff: 'diff', + }) + }) + + it('canHandle matches the pi block type', () => { + expect(handler.canHandle(block)).toBe(true) + expect( + handler.canHandle({ id: 'x', metadata: { id: 'agent' } } as unknown as SerializedBlock) + ).toBe(false) + }) + + it('throws when the task is missing', async () => { + await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/) + }) + + it('routes local mode to the local backend with SSH params', async () => { + const output = await handler.execute(ctx(), block, localInputs()) + expect(mockRunLocal).toHaveBeenCalledTimes(1) + expect(mockRunCloud).not.toHaveBeenCalled() + const params = mockRunLocal.mock.calls[0][0] + expect(params.mode).toBe('local') + expect(params.ssh.host).toBe('box.example.com') + expect(params.repoPath).toBe('/srv/repo') + expect((output as Record).content).toBe('hi') + }) + + it('routes cloud mode to the cloud backend and surfaces PR output', async () => { + const output = (await handler.execute(ctx(), block, { + mode: 'cloud', + task: 'do it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + })) as Record + expect(mockRunCloud).toHaveBeenCalledTimes(1) + expect(output.prUrl).toBe('https://github.com/o/r/pull/1') + expect(output.branch).toBe('pi/abc') + }) + + it('requires SSH fields in local mode', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' }) + ).rejects.toThrow(/Local mode requires/) + }) + + it('requires repo + token in cloud mode', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' }) + ).rejects.toThrow(/Cloud mode requires/) + }) + + it('streams text when the block is selected for streaming output', async () => { + mockRunLocal.mockImplementation(async (_params, runCtx) => { + runCtx.onEvent({ type: 'text', text: 'streamed' }) + return { totals: { finalText: 'streamed', inputTokens: 0, outputTokens: 0, toolCalls: [] } } + }) + + const result = (await handler.execute( + ctx({ stream: true, selectedOutputs: ['blk'] }), + block, + localInputs() + )) as StreamingExecution + + expect('stream' in result).toBe(true) + + const reader = result.stream.getReader() + const decoder = new TextDecoder() + let text = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value) + } + expect(text).toContain('streamed') + expect(result.execution.output.content).toBe('streamed') + }) +}) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts new file mode 100644 index 0000000000..961859c712 --- /dev/null +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -0,0 +1,262 @@ +/** + * Executor handler for the Pi Coding Agent block. Resolves the model key, + * skills, and memory, selects a backend by `mode`, and runs it — streaming the + * agent's text to the client when the block is selected for streaming output, + * otherwise returning a plain block output. The handler depends only on the + * {@link PiBackendRun} seam and never reaches into backend internals. + */ + +import { createLogger } from '@sim/logger' +import type { BlockOutput } from '@/blocks/types' +import { parseOptionalNumberInput } from '@/blocks/utils' +import { BlockType } from '@/executor/constants' +import type { + PiBackendRun, + PiCloudRunParams, + PiLocalRunParams, + PiRunParams, + PiRunResult, +} from '@/executor/handlers/pi/backend' +import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import { + appendPiMemory, + loadPiMemory, + type PiMemoryConfig, + resolvePiSkills, +} from '@/executor/handlers/pi/context' +import { streamTextForEvent } from '@/executor/handlers/pi/events' +import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys' +import { runLocalPi } from '@/executor/handlers/pi/local-backend' +import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' +import type { + BlockHandler, + ExecutionContext, + NormalizedBlockOutput, + StreamingExecution, +} from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('PiBlockHandler') +const DEFAULT_MODEL = 'claude-sonnet-4-6' + +function asOptString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +function asRawString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined +} + +export class PiBlockHandler implements BlockHandler { + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === BlockType.PI + } + + async execute( + ctx: ExecutionContext, + block: SerializedBlock, + inputs: Record + ): Promise { + const task = asOptString(inputs.task) + if (!task) throw new Error('Task is required') + const model = asOptString(inputs.model) ?? DEFAULT_MODEL + + // Validate the mode up front so an invalid value reports a mode error rather + // than a misattributed credential error from key resolution below. + if (inputs.mode !== 'cloud' && inputs.mode !== 'local') { + throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) + } + const mode: 'cloud' | 'local' = inputs.mode + + const { providerId, apiKey, isBYOK } = await resolvePiModelKey({ + model, + mode, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + apiKey: asRawString(inputs.apiKey), + vertexCredential: asOptString(inputs.vertexCredential), + }) + + const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId) + const memoryConfig: PiMemoryConfig = { + memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'], + conversationId: asOptString(inputs.conversationId), + slidingWindowSize: asOptString(inputs.slidingWindowSize), + slidingWindowTokens: asOptString(inputs.slidingWindowTokens), + model, + } + const initialMessages = await loadPiMemory(ctx, memoryConfig) + + const base = { + model, + providerId, + apiKey, + isBYOK, + task, + thinkingLevel: asOptString(inputs.thinkingLevel), + skills, + initialMessages, + } + + if (mode === 'local') { + const host = asOptString(inputs.host) + const username = asOptString(inputs.username) + const repoPath = asOptString(inputs.repoPath) + if (!host || !username || !repoPath) { + throw new Error('Local mode requires host, username, and repository path') + } + const usePrivateKey = inputs.authMethod === 'privateKey' + const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22 + const tools = await buildSimToolSpecs(ctx, inputs.tools) + const params: PiLocalRunParams = { + ...base, + mode: 'local', + repoPath, + tools, + ssh: { + host, + port, + username, + password: usePrivateKey ? undefined : asRawString(inputs.password), + privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined, + passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined, + }, + } + return this.runPi(ctx, block, runLocalPi, params, memoryConfig) + } + + if (mode === 'cloud') { + const owner = asOptString(inputs.owner) + const repo = asOptString(inputs.repo) + const githubToken = asRawString(inputs.githubToken) + if (!owner || !repo || !githubToken) { + throw new Error('Cloud mode requires repository owner, name, and a GitHub token') + } + const params: PiCloudRunParams = { + ...base, + mode: 'cloud', + owner, + repo, + githubToken, + baseBranch: asOptString(inputs.baseBranch), + branchName: asOptString(inputs.branchName), + draft: inputs.draft !== false, + prTitle: asOptString(inputs.prTitle), + prBody: asOptString(inputs.prBody), + } + return this.runPi(ctx, block, runCloudPi, params, memoryConfig) + } + + throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) + } + + private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean { + if (!ctx.stream) return false + return ( + ctx.selectedOutputs?.some((outputId) => { + if (outputId === block.id) return true + return outputId === `${block.id}.content` || outputId === `${block.id}_content` + }) ?? false + ) + } + + private buildOutput( + result: PiRunResult, + model: string, + isBYOK: boolean, + startTime: number, + startTimeISO: string + ): NormalizedBlockOutput { + const { totals } = result + const endTime = Date.now() + return { + content: totals.finalText, + model, + changedFiles: result.changedFiles ?? [], + diff: result.diff ?? '', + ...(result.prUrl ? { prUrl: result.prUrl } : {}), + ...(result.branch ? { branch: result.branch } : {}), + tokens: { + input: totals.inputTokens, + output: totals.outputTokens, + total: totals.inputTokens + totals.outputTokens, + }, + cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK), + providerTiming: { + startTime: startTimeISO, + endTime: new Date(endTime).toISOString(), + duration: endTime - startTime, + }, + } + } + + private async runPi

( + ctx: ExecutionContext, + block: SerializedBlock, + backend: PiBackendRun

, + params: P, + memoryConfig: PiMemoryConfig + ): Promise { + const startTime = Date.now() + const startTimeISO = new Date(startTime).toISOString() + + logger.info('Executing Pi block', { + blockId: block.id, + mode: params.mode, + model: params.model, + workflowId: ctx.workflowId, + executionId: ctx.executionId, + }) + + if (this.isContentSelectedForStreaming(ctx, block)) { + const output: NormalizedBlockOutput = { content: '', model: params.model } + const stream = new ReadableStream({ + start: async (controller) => { + const encoder = new TextEncoder() + try { + const result = await backend(params, { + onEvent: (event) => { + const text = streamTextForEvent(event) + if (text) controller.enqueue(encoder.encode(text)) + }, + signal: ctx.abortSignal, + }) + if (result.totals.errorMessage) { + controller.error(new Error(result.totals.errorMessage)) + return + } + Object.assign( + output, + this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + ) + await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText) + controller.close() + } catch (error) { + controller.error(error) + } + }, + }) + + return { + stream, + execution: { + success: true, + output, + blockId: block.id, + logs: [], + metadata: { startTime: startTimeISO, duration: 0 }, + isStreaming: true, + } as StreamingExecution['execution'] & { blockId: string }, + } + } + + const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal }) + if (result.totals.errorMessage) { + throw new Error(result.totals.errorMessage) + } + await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText) + return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + } +} diff --git a/apps/sim/executor/handlers/pi/sim-tools.test.ts b/apps/sim/executor/handlers/pi/sim-tools.test.ts new file mode 100644 index 0000000000..123ce9d3ae --- /dev/null +++ b/apps/sim/executor/handlers/pi/sim-tools.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({ + mockTransformBlockTool: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) +vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() })) + +import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' +import type { ExecutionContext } from '@/executor/types' + +const ctx = { workspaceId: 'ws-1' } as ExecutionContext + +describe('buildSimToolSpecs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('names the Pi tool with the snake_case tool id, not the human label', async () => { + // transformBlockTool returns a human label with a space, which the model + // provider rejects (tool names must match /^[a-zA-Z0-9_-]{1,128}$/). + mockTransformBlockTool.mockResolvedValue({ + id: 'exa_search', + name: 'Exa Search', + description: 'Search the web', + params: {}, + parameters: { type: 'object', properties: {} }, + }) + + const specs = await buildSimToolSpecs(ctx, [ + { type: 'exa', operation: 'exa_search', usageControl: 'auto' }, + ]) + + expect(specs).toHaveLength(1) + expect(specs[0].name).toBe('exa_search') + expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/) + }) + + it('skips mcp, custom, and usage-none tools without adapting them', async () => { + const specs = await buildSimToolSpecs(ctx, [ + { type: 'mcp', usageControl: 'auto' }, + { type: 'custom-tool', usageControl: 'auto' }, + { type: 'exa', usageControl: 'none' }, + ]) + + expect(specs).toHaveLength(0) + expect(mockTransformBlockTool).not.toHaveBeenCalled() + }) + + it('forwards a trusted _context that an LLM-supplied _context cannot override', async () => { + mockTransformBlockTool.mockResolvedValue({ + id: 'exa_search', + name: 'Exa Search', + description: 'Search the web', + params: { apiKey: 'k' }, + parameters: { type: 'object', properties: {} }, + }) + mockExecuteTool.mockResolvedValue({ success: true, output: 'ok' }) + const trustedCtx = { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-1', + } as ExecutionContext + + const [spec] = await buildSimToolSpecs(trustedCtx, [ + { type: 'exa', operation: 'exa_search', usageControl: 'auto' }, + ]) + // An attacker-influenced tool arg tries to spoof the execution context. + await spec.execute({ query: 'cats', _context: { userId: 'attacker', workspaceId: 'evil' } }) + + const [toolId, callParams] = mockExecuteTool.mock.calls[0] + expect(toolId).toBe('exa_search') + expect(callParams._context.userId).toBe('user-1') + expect(callParams._context.workspaceId).toBe('ws-1') + expect(callParams._context.workflowId).toBe('wf-1') + }) +}) diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts new file mode 100644 index 0000000000..0fb6a3b632 --- /dev/null +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -0,0 +1,107 @@ +/** + * Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that + * Pi can call in local mode. Each spec carries the tool's JSON-schema parameters + * and an `execute` that runs the real Sim tool through `executeTool`, so the + * agent's calls go through the same credential-access checks as any block. + * + * MCP and custom tools are skipped in v1; block/integration tools are supported. + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { getAllBlocks } from '@/blocks/registry' +import type { ToolInput } from '@/executor/handlers/agent/types' +import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' +import type { ExecutionContext } from '@/executor/types' +import { transformBlockTool } from '@/providers/utils' +import { executeTool } from '@/tools' +import type { ToolResponse } from '@/tools/types' +import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' + +const logger = createLogger('PiSimTools') + +function toToolResult(result: ToolResponse): PiToolResult { + if (result.success) { + const text = + typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {}) + return { text, isError: false } + } + return { text: result.error || 'Tool execution failed', isError: true } +} + +/** + * Builds the Sim tool specs exposed to Pi for a local run. Only tools the user + * added to the block are included, and `usageControl: 'none'` tools are dropped. + */ +export async function buildSimToolSpecs( + ctx: ExecutionContext, + inputTools: unknown +): Promise { + if (!Array.isArray(inputTools)) return [] + + const specs: PiToolSpec[] = [] + + for (const tool of inputTools as ToolInput[]) { + if ((tool.usageControl || 'auto') === 'none') continue + if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue + + try { + const provider = await transformBlockTool(tool, { + selectedOperation: tool.operation, + getAllBlocks, + getTool, + getToolAsync, + }) + + if (!provider?.id) continue + + const toolId = provider.id + const preseededParams = provider.params || {} + + specs.push({ + name: toolId, + description: provider.description || '', + parameters: (provider.parameters as Record) || { + type: 'object', + properties: {}, + }, + execute: async (args) => { + try { + const result = await executeTool( + toolId, + { + ...preseededParams, + ...args, + // Trusted execution context, spread last so an LLM-supplied + // `_context` arg can't override it. executeTool reads this directly + // for OAuth-credential resolution and internal-route identity, the + // same way the Agent block's tool calls do. + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, + callChain: ctx.callChain, + }, + }, + { executionContext: ctx } + ) + return toToolResult(result) + } catch (error) { + return { text: getErrorMessage(error, 'Tool execution failed'), isError: true } + } + }, + }) + } catch (error) { + logger.warn('Failed to adapt Sim tool for Pi', { + type: tool.type, + error: getErrorMessage(error), + }) + } + } + + return specs +} diff --git a/apps/sim/executor/handlers/pi/ssh-tools.test.ts b/apps/sim/executor/handlers/pi/ssh-tools.test.ts new file mode 100644 index 0000000000..ff70cdb266 --- /dev/null +++ b/apps/sim/executor/handlers/pi/ssh-tools.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteSSHCommand } = vi.hoisted(() => ({ + mockExecuteSSHCommand: vi.fn(), +})) + +vi.mock('@/app/api/tools/ssh/utils', () => ({ + createSSHConnection: vi.fn(), + executeSSHCommand: mockExecuteSSHCommand, + escapeShellArg: (value: string) => value.replace(/'/g, "'\\''"), + sanitizeCommand: (value: string) => value, + sanitizePath: (value: string) => { + if (value.split(/[/\\]/).includes('..')) { + throw new Error('Path contains invalid path traversal sequences') + } + return value.trim() + }, +})) + +import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools' +import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools' + +function createSession(files: Record): PiSshSession { + const sftp = { + readFile: (path: string, cb: (err: Error | undefined, data: Buffer) => void) => { + if (!(path in files)) { + cb(new Error(`no such file: ${path}`), Buffer.from('')) + return + } + cb(undefined, Buffer.from(files[path])) + }, + writeFile: (path: string, data: string, cb: (err?: Error) => void) => { + files[path] = data + cb(undefined) + }, + } + return { + client: {} as PiSshSession['client'], + sftp: sftp as unknown as PiSshSession['sftp'], + close: vi.fn(), + } +} + +function getTool(repoPath: string, files: Record, name: string) { + const tools = buildSshToolSpecs(createSession(files), repoPath) + const tool = tools.find((t) => t.name === name) + if (!tool) throw new Error(`tool not found: ${name}`) + return tool +} + +describe('buildSshToolSpecs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads a file resolved against repoPath', async () => { + const read = getTool('/repo', { '/repo/a.txt': 'contents' }, 'read') + expect(await read.execute({ path: 'a.txt' })).toEqual({ text: 'contents', isError: false }) + }) + + it('writes a file resolved against repoPath', async () => { + const files: Record = {} + const write = getTool('/repo', files, 'write') + const result = await write.execute({ path: 'b.txt', content: 'hello' }) + expect(result.isError).toBe(false) + expect(files['/repo/b.txt']).toBe('hello') + }) + + it('edits the first occurrence of old_string', async () => { + const files = { '/repo/c.txt': 'foo bar foo' } + const edit = getTool('/repo', files, 'edit') + const result = await edit.execute({ path: 'c.txt', old_string: 'foo', new_string: 'baz' }) + expect(result.isError).toBe(false) + expect(files['/repo/c.txt']).toBe('baz bar foo') + }) + + it('reports an error when old_string is absent', async () => { + const edit = getTool('/repo', { '/repo/c.txt': 'nothing here' }, 'edit') + const result = await edit.execute({ path: 'c.txt', old_string: 'missing', new_string: 'x' }) + expect(result.isError).toBe(true) + }) + + it('runs bash scoped to the repo directory', async () => { + mockExecuteSSHCommand.mockResolvedValue({ stdout: 'out', stderr: '', exitCode: 0 }) + const bash = getTool('/repo', {}, 'bash') + const result = await bash.execute({ command: 'ls -la' }) + expect(result).toEqual({ text: 'out', isError: false }) + expect(mockExecuteSSHCommand).toHaveBeenCalledWith(expect.anything(), "cd '/repo' && ls -la") + }) + + it('marks a non-zero bash exit as an error', async () => { + mockExecuteSSHCommand.mockResolvedValue({ stdout: '', stderr: 'boom', exitCode: 2 }) + const bash = getTool('/repo', {}, 'bash') + const result = await bash.execute({ command: 'false' }) + expect(result.isError).toBe(true) + }) + + it('rejects path traversal and paths outside the repo', async () => { + const read = getTool('/repo', {}, 'read') + expect((await read.execute({ path: '../etc/passwd' })).isError).toBe(true) + expect((await read.execute({ path: '/outside/repo' })).isError).toBe(true) + }) +}) diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/ssh-tools.ts new file mode 100644 index 0000000000..c625ba7bcb --- /dev/null +++ b/apps/sim/executor/handlers/pi/ssh-tools.ts @@ -0,0 +1,229 @@ +/** + * SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2` + * connection is opened per run and reused across every tool call: `read`/`write`/ + * `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory. + * All paths are sanitized and confined to the configured `repoPath` (S4). + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { Client, SFTPWrapper } from 'ssh2' +import { + createSSHConnection, + escapeShellArg, + executeSSHCommand, + sanitizeCommand, + sanitizePath, +} from '@/app/api/tools/ssh/utils' +import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' + +const logger = createLogger('PiSshTools') + +/** An open SSH session reused for the duration of a local Pi run. */ +export interface PiSshSession { + client: Client + sftp: SFTPWrapper + close: () => void +} + +/** Opens one SSH connection plus an SFTP channel for the run. */ +export async function openSshSession(connection: PiSshConnection): Promise { + const client = await createSSHConnection({ + host: connection.host, + port: connection.port, + username: connection.username, + password: connection.password ?? null, + privateKey: connection.privateKey ?? null, + passphrase: connection.passphrase ?? null, + }) + + const close = () => { + try { + client.end() + } catch (error) { + logger.warn('Failed to close SSH session', { error: getErrorMessage(error) }) + } + } + + // The TCP/SSH connection is already open here, so close it if opening the SFTP + // channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise + // the connection is orphaned when this function throws. + try { + const sftp = await new Promise((resolve, reject) => { + client.sftp((err, channel) => (err ? reject(err) : resolve(channel))) + }) + return { client, sftp, close } + } catch (error) { + close() + throw error + } +} + +function readRemoteFile(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8')))) + }) +} + +function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise { + return new Promise((resolve, reject) => { + sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve())) + }) +} + +/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */ +function resolveRepoPath(repoPath: string, candidate: string): string { + const clean = sanitizePath(candidate) + const root = repoPath.replace(/\/+$/, '') + if (clean.startsWith('/')) { + if (clean !== root && !clean.startsWith(`${root}/`)) { + throw new Error(`Path is outside the repository: ${candidate}`) + } + return clean + } + return `${root}/${clean}` +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +async function guard(run: () => Promise): Promise { + try { + return await run() + } catch (error) { + return { text: getErrorMessage(error, 'SSH tool failed'), isError: true } + } +} + +/** + * Best-effort working-tree snapshot of the repo over the run's SSH session, for + * the block's `changedFiles`/`diff` outputs — Local mode edits in place rather + * than opening a PR. `changedFiles` covers both tracked modifications and untracked + * (newly created) files so files the agent created are reported; `diff` reflects + * tracked changes against HEAD. Returns empty on any failure (not a git repo, git + * missing, non-zero exit). + */ +export async function captureRepoChanges( + session: PiSshSession, + repoPath: string, + maxDiffBytes: number +): Promise<{ changedFiles: string[]; diff: string }> { + const scoped = `cd '${escapeShellArg(repoPath)}'` + try { + const tracked = await executeSSHCommand( + session.client, + `${scoped} && git diff --name-only HEAD` + ) + const untracked = await executeSSHCommand( + session.client, + `${scoped} && git ls-files --others --exclude-standard` + ) + const fileSet = new Set() + for (const result of [tracked, untracked]) { + if (result.exitCode !== 0) continue + for (const line of result.stdout.split('\n')) { + const file = line.trim() + if (file) fileSet.add(file) + } + } + const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`) + const out = raw.exitCode === 0 ? raw.stdout : '' + const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out + return { changedFiles: [...fileSet], diff } + } catch { + return { changedFiles: [], diff: '' } + } +} + +/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */ +export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] { + const { client, sftp } = session + + return [ + { + name: 'read', + description: 'Read the full contents of a file in the repository.', + parameters: { + type: 'object', + properties: { path: { type: 'string', description: 'File path within the repository' } }, + required: ['path'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path)) + return { text: content, isError: false } + }), + }, + { + name: 'write', + description: 'Write (create or overwrite) a file in the repository.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the repository' }, + content: { type: 'string', description: 'Full file contents to write' }, + }, + required: ['path', 'content'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const resolved = resolveRepoPath(repoPath, path) + await writeRemoteFile(sftp, resolved, asString(args.content)) + return { text: `Wrote ${resolved}`, isError: false } + }), + }, + { + name: 'edit', + description: 'Replace the first occurrence of old_string with new_string in a file.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the repository' }, + old_string: { type: 'string', description: 'Exact text to replace' }, + new_string: { type: 'string', description: 'Replacement text' }, + }, + required: ['path', 'old_string', 'new_string'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const oldString = asString(args.old_string) + const resolved = resolveRepoPath(repoPath, path) + const current = await readRemoteFile(sftp, resolved) + if (!current.includes(oldString)) { + return { text: `old_string not found in ${resolved}`, isError: true } + } + const updated = current.replace(oldString, asString(args.new_string)) + await writeRemoteFile(sftp, resolved, updated) + return { text: `Edited ${resolved}`, isError: false } + }), + }, + { + name: 'bash', + description: 'Run a shell command in the repository directory and return its output.', + parameters: { + type: 'object', + properties: { command: { type: 'string', description: 'Shell command to run' } }, + required: ['command'], + }, + execute: (args) => + guard(async () => { + const command = asString(args.command) + if (!command) return { text: 'command is required', isError: true } + const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}` + const result = await executeSSHCommand(client, scoped) + const text = [result.stdout, result.stderr].filter(Boolean).join('\n') + return { + text: text || `Exited with code ${result.exitCode}`, + isError: result.exitCode !== 0, + } + }), + }, + ] +} diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index f2b3c29202..cd8c57d1c6 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -14,6 +14,7 @@ import { FunctionBlockHandler } from '@/executor/handlers/function/function-hand import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' +import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler' @@ -39,6 +40,7 @@ export function createBlockHandlers(): BlockHandler[] { new HumanInTheLoopBlockHandler(), new AgentBlockHandler(), new MothershipBlockHandler(), + new PiBlockHandler(), new VariablesBlockHandler(), new WorkflowBlockHandler(), new WaitBlockHandler(), diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts new file mode 100644 index 0000000000..e41d1db7a7 --- /dev/null +++ b/apps/sim/hooks/queries/session.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query' +import { client } from '@/lib/auth/auth-client' +import { + type AppSession, + extractSessionDataFromAuthClientResult, +} from '@/lib/auth/session-response' + +export const sessionKeys = { + all: ['session'] as const, + detail: () => [...sessionKeys.all, 'detail'] as const, +} + +async function fetchSession(signal?: AbortSignal): Promise { + const res = await client.getSession({ fetchOptions: { signal } }) + return extractSessionDataFromAuthClientResult(res) as AppSession +} + +/** + * Reads the current Better Auth session via the client SDK. + * + * This is the Better Auth client SDK (not a same-origin `requestJson` contract), + * so a plain `useQuery` is correct — there is no boundary contract to bind. + * + * `retry: false` preserves the prior fail-fast contract: an auth failure (expired + * token, startup network partition) surfaces immediately rather than retrying a + * request that won't succeed. + */ +export function useSessionQuery() { + return useQuery({ + queryKey: sessionKeys.detail(), + queryFn: ({ signal }) => fetchSession(signal), + staleTime: 5 * 60 * 1000, + retry: false, + }) +} diff --git a/apps/sim/hooks/queries/unsubscribe.test.tsx b/apps/sim/hooks/queries/unsubscribe.test.tsx new file mode 100644 index 0000000000..97a7c49227 --- /dev/null +++ b/apps/sim/hooks/queries/unsubscribe.test.tsx @@ -0,0 +1,205 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { requestJson } from '@/lib/api/client/request' +import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user' +import { + unsubscribeKeys, + useUnsubscribe, + useUnsubscribeMutation, +} from '@/hooks/queries/unsubscribe' + +const EMAIL = 'person@example.com' +const TOKEN = 'tok-123' + +const getResponse = { + success: true as const, + email: EMAIL, + token: TOKEN, + emailType: 'marketing', + isTransactional: false, + currentPreferences: { + unsubscribeAll: false, + unsubscribeMarketing: false, + unsubscribeUpdates: false, + unsubscribeNotifications: false, + }, +} + +/** + * Minimal dependency-free hook harness (the repo has no `@testing-library/react`). + * Mounts the hook in a real React 19 root under jsdom, wrapped in a real + * `QueryClientProvider`, so query/mutation lifecycles run exactly as in the app. + */ +function renderHookWithClient(useHook: () => T): { + result: () => T + queryClient: QueryClient + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest: T + + function Probe() { + latest = useHook() + return null + } + + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + + act(() => { + root.render( + + + + ) + }) + + return { + result: () => latest, + queryClient, + unmount: () => act(() => root.unmount()), + } +} + +/** Flush pending microtasks and the macrotask queue (query observer scheduling) inside act(). */ +async function flush() { + await act(async () => { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + } + }) +} + +describe('useUnsubscribe', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('is disabled and does not fetch when email or token is missing', async () => { + const missingToken = renderHookWithClient(() => useUnsubscribe(EMAIL, undefined)) + const missingEmail = renderHookWithClient(() => useUnsubscribe(undefined, TOKEN)) + const missingBoth = renderHookWithClient(() => useUnsubscribe(undefined, undefined)) + await flush() + + expect(missingToken.result().fetchStatus).toBe('idle') + expect(missingEmail.result().fetchStatus).toBe('idle') + expect(missingBoth.result().fetchStatus).toBe('idle') + expect(mockRequestJson).not.toHaveBeenCalled() + + missingToken.unmount() + missingEmail.unmount() + missingBoth.unmount() + }) + + it('fetches when both params are present and surfaces the contract data', async () => { + mockRequestJson.mockResolvedValueOnce(getResponse) + + const { result, unmount } = renderHookWithClient(() => useUnsubscribe(EMAIL, TOKEN)) + await flush() + + expect(requestJson).toHaveBeenCalledTimes(1) + expect(requestJson).toHaveBeenCalledWith( + unsubscribeGetContract, + expect.objectContaining({ query: { email: EMAIL, token: TOKEN } }) + ) + expect(result().isSuccess).toBe(true) + expect(result().data).toEqual(getResponse) + expect(result().data?.isTransactional).toBe(false) + + unmount() + }) +}) + +describe('useUnsubscribeMutation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('calls requestJson with the post contract and flips the cached preference flag on success', async () => { + mockRequestJson.mockResolvedValueOnce({ + success: true as const, + message: 'Unsubscribed', + email: EMAIL, + type: 'marketing' as const, + emailType: 'marketing', + }) + + const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation()) + const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN) + queryClient.setQueryData(detailKey, getResponse) + + await act(async () => { + await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'marketing' }) + }) + await flush() + + expect(result().isSuccess).toBe(true) + expect(requestJson).toHaveBeenCalledTimes(1) + expect(requestJson).toHaveBeenCalledWith( + unsubscribePostContract, + expect.objectContaining({ body: { email: EMAIL, token: TOKEN, type: 'marketing' } }) + ) + + const reconciled = queryClient.getQueryData(detailKey) + expect(reconciled?.currentPreferences.unsubscribeMarketing).toBe(true) + expect(reconciled?.currentPreferences.unsubscribeAll).toBe(false) + expect(reconciled?.currentPreferences.unsubscribeUpdates).toBe(false) + + unmount() + }) + + it('flips unsubscribeAll when type is "all"', async () => { + mockRequestJson.mockResolvedValueOnce({ + success: true as const, + message: 'Unsubscribed', + email: EMAIL, + type: 'all' as const, + emailType: 'marketing', + }) + + const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation()) + const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN) + queryClient.setQueryData(detailKey, getResponse) + + await act(async () => { + await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'all' }) + }) + await flush() + + expect(result().isSuccess).toBe(true) + const reconciled = queryClient.getQueryData(detailKey) + expect(reconciled?.currentPreferences.unsubscribeAll).toBe(true) + + unmount() + }) +}) diff --git a/apps/sim/hooks/queries/unsubscribe.ts b/apps/sim/hooks/queries/unsubscribe.ts new file mode 100644 index 0000000000..29116ffe82 --- /dev/null +++ b/apps/sim/hooks/queries/unsubscribe.ts @@ -0,0 +1,76 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type UnsubscribeActionResponse, + type UnsubscribeData, + type UnsubscribeType, + unsubscribeGetContract, + unsubscribePostContract, +} from '@/lib/api/contracts/user' + +export const unsubscribeKeys = { + all: ['unsubscribe'] as const, + details: () => [...unsubscribeKeys.all, 'detail'] as const, + detail: (email?: string, token?: string) => + [...unsubscribeKeys.details(), email ?? '', token ?? ''] as const, +} + +async function fetchUnsubscribe( + email: string, + token: string, + signal?: AbortSignal +): Promise { + return requestJson(unsubscribeGetContract, { query: { email, token }, signal }) +} + +/** + * Validates an unsubscribe link and loads the recipient's current email preferences. + * Auto-runs on mount once both `email` and `token` are present. + */ +export function useUnsubscribe(email?: string, token?: string) { + return useQuery({ + queryKey: unsubscribeKeys.detail(email, token), + queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal), + enabled: Boolean(email) && Boolean(token), + staleTime: 5 * 60 * 1000, + retry: false, + }) +} + +interface UnsubscribeVariables { + email: string + token: string + type: UnsubscribeType +} + +/** + * Submits an unsubscribe action and reconciles the cached preferences so the + * affected option immediately reflects the unsubscribed state. + */ +export function useUnsubscribeMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ email, token, type }) => + requestJson(unsubscribePostContract, { body: { email, token, type } }), + onSuccess: (_data, { email, token, type }) => { + const key = unsubscribeKeys.detail(email, token) + queryClient.setQueryData(key, (previous) => { + if (!previous) return previous + const preferenceKey = + type === 'all' + ? 'unsubscribeAll' + : (`unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as + | 'unsubscribeMarketing' + | 'unsubscribeUpdates' + | 'unsubscribeNotifications') + return { + ...previous, + currentPreferences: { + ...previous.currentPreferences, + [preferenceKey]: true, + }, + } + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts new file mode 100644 index 0000000000..71dca0258b --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +vi.mock('@/lib/api/contracts/workflows', () => ({ + getWorkflowStateContract: { __contract: 'getWorkflowState' }, +})) + +import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' + +describe('fetchWorkflowEnvelope', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the unwrapped envelope from the contract response', async () => { + const envelope = { + id: 'wf-1', + isDeployed: true, + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + } + mockRequestJson.mockResolvedValue({ data: envelope }) + + const result = await fetchWorkflowEnvelope('wf-1') + + expect(result).toBe(envelope) + }) + + it('forwards params.id and signal to requestJson against the contract', async () => { + mockRequestJson.mockResolvedValue({ data: { id: 'wf-2' } }) + const controller = new AbortController() + + await fetchWorkflowEnvelope('wf-2', controller.signal) + + expect(mockRequestJson).toHaveBeenCalledTimes(1) + expect(mockRequestJson).toHaveBeenCalledWith( + { __contract: 'getWorkflowState' }, + { params: { id: 'wf-2' }, signal: controller.signal } + ) + }) +}) diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts new file mode 100644 index 0000000000..d7a55d1c57 --- /dev/null +++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts @@ -0,0 +1,31 @@ +import { requestJson } from '@/lib/api/client/request' +import { + type GetWorkflowResponseData, + getWorkflowStateContract, +} from '@/lib/api/contracts/workflows' + +/** + * Fetches the full workflow envelope (in-state slice, deployment status, + * variables, and row metadata) for a single workflow from GET + * `/api/workflows/[id]`. + * + * Single source of truth for the `workflowKeys.state(id)` cache entry: the + * registry store hydrates it via `fetchQuery` (always-fresh, in-flight + * deduped) and `useWorkflowState`/`useWorkflowStates` project the mapped + * `WorkflowState` out of the same entry with `select`, so this endpoint has + * exactly one cache entry across the store and the hooks. + * + * Lives in a standalone util (rather than `hooks/queries/workflows.ts`) so the + * registry store can import it without creating a store ↔ query-hook import + * cycle. + */ +export async function fetchWorkflowEnvelope( + workflowId: string, + signal?: AbortSignal +): Promise { + const { data } = await requestJson(getWorkflowStateContract, { + params: { id: workflowId }, + signal, + }) + return data +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 865b66bec6..55df6d469a 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -18,7 +18,7 @@ import { createWorkflowContract, deleteWorkflowContract, duplicateWorkflowContract, - getWorkflowStateContract, + type GetWorkflowResponseData, type ImportWorkflowAsSuperuserBody, type ImportWorkflowAsSuperuserResponse, importWorkflowAsSuperuserContract, @@ -28,6 +28,7 @@ import { } from '@/lib/api/contracts/workflows' import { deploymentKeys } from '@/hooks/queries/deployments' import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state' +import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' @@ -49,14 +50,11 @@ const logger = createLogger('WorkflowQueries') export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' -async function fetchWorkflowState( - workflowId: string, - signal?: AbortSignal -): Promise { - const { data } = await requestJson(getWorkflowStateContract, { - params: { id: workflowId }, - signal, - }) +/** + * Projects the in-state slice of the workflow envelope into the canvas-facing + * `WorkflowState` shape consumed by preview/editor surfaces. + */ +function mapWorkflowState(data: GetWorkflowResponseData): WorkflowState { const wireState = data.state return { ...wireState, @@ -70,11 +68,16 @@ async function fetchWorkflowState( * Fetches the full workflow state for a single workflow. * Used by workflow blocks to show a preview of the child workflow * and as a base query for input fields extraction. + * + * Derives the mapped `WorkflowState` from the shared envelope query via + * `select`, so it shares one cache entry (and one request) with the registry + * store's hydration and with `useWorkflowStates`. */ export function useWorkflowState(workflowId: string | undefined) { return useQuery({ queryKey: workflowKeys.state(workflowId), - queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken, + queryFn: workflowId ? ({ signal }) => fetchWorkflowEnvelope(workflowId, signal) : skipToken, + select: mapWorkflowState, staleTime: 30 * 1000, }) } @@ -93,7 +96,8 @@ export function useWorkflowStates( const results = useQueries({ queries: uniqueIds.map((id) => ({ queryKey: workflowKeys.state(id), - queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowState(id, signal), + queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal), + select: mapWorkflowState, staleTime: 30 * 1000, })), }) diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts index 016e993c4a..53cf6a50ac 100644 --- a/apps/sim/lib/a2a/push-notifications.ts +++ b/apps/sim/lib/a2a/push-notifications.ts @@ -111,13 +111,15 @@ export async function notifyTaskStateChange(taskId: string, state: TaskState): P if (isTriggerDevEnabled) { try { - const { a2aPushNotificationTask } = await import( - '@/background/a2a-push-notification-delivery' - ) + const [{ a2aPushNotificationTask }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/a2a-push-notification-delivery'), + import('@/lib/core/async-jobs/region'), + ]) await a2aPushNotificationTask.trigger( { taskId, state }, { tags: [`taskId:${taskId}`], + region: await resolveTriggerRegion(), } ) logger.info('Push notification queued to trigger.dev', { taskId, state }) diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 6c280898c3..897c99fad5 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -45,6 +45,34 @@ export const guardrailsValidateContract = defineRouteContract({ }, }) +const guardrailsMaskBatchBodySchema = z.object({ + texts: z.array(z.string()).max(100_000), + entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200), + language: z.string().min(1).max(20).optional(), +}) + +const guardrailsMaskBatchResponseSchema = z.object({ + masked: z.array(z.string()), +}) + +/** + * Internal batch PII masking. Called server-to-server (internal JWT) from the + * log-redaction persist path so Presidio always runs in the app container, + * including for async executions that persist inside the trigger.dev runtime. + */ +export const guardrailsMaskBatchContract = defineRouteContract({ + method: 'POST', + path: '/api/guardrails/mask-batch', + body: guardrailsMaskBatchBodySchema, + response: { + mode: 'json', + schema: guardrailsMaskBatchResponseSchema, + }, +}) + +export type GuardrailsMaskBatchBody = z.input +export type GuardrailsMaskBatchResult = z.output + const chatMessageSchema = z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string(), diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index e3e6148402..2b0d598d1a 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { PII_LANGUAGE_CODES } from '@/lib/guardrails/pii-entities' export const unknownRecordSchema = z.record(z.string(), z.unknown()) @@ -93,6 +94,8 @@ export const piiRedactionRuleSchema = z.object({ entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100), /** null = all workspaces; otherwise the single targeted workspace. */ workspaceId: z.string().min(1).nullable(), + /** Language whose Presidio recognizers apply; defaults to English. */ + language: z.enum(PII_LANGUAGE_CODES).optional(), }) export type PiiRedactionRule = z.output diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index a100cb45af..030c851551 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' +import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { isSameOrigin } from '@/lib/core/utils/validation' export const userProfileSchema = z.object({ @@ -259,6 +259,11 @@ export const unsubscribePostContract = defineRouteContract({ }, }) +export type UnsubscribeData = ContractJsonResponse +export type UnsubscribeActionResponse = ContractJsonResponse +export type UnsubscribeBody = z.input +export type UnsubscribeType = NonNullable + export const usageLogsQuerySchema = z.object({ source: z.enum(['workflow', 'wand', 'copilot']).optional(), workspaceId: z.string().optional(), diff --git a/apps/sim/lib/auth/session-response.ts b/apps/sim/lib/auth/session-response.ts index 262cc9a1bc..f41fccce32 100644 --- a/apps/sim/lib/auth/session-response.ts +++ b/apps/sim/lib/auth/session-response.ts @@ -1,3 +1,27 @@ +/** + * The app-facing session shape derived from the Better Auth client response. + * Lives here (the module that produces it) so both the `useSessionQuery` hook + * and the `SessionProvider` can import it without a provider ↔ hook import cycle. + */ +export type AppSession = { + user: { + id: string + email: string + emailVerified?: boolean + name?: string | null + image?: string | null + role?: string + createdAt?: Date + updatedAt?: Date + } | null + session?: { + id?: string + userId?: string + activeOrganizationId?: string + impersonatedBy?: string | null + } +} | null + export function extractSessionDataFromAuthClientResult(result: unknown): unknown | null { if (!result || typeof result !== 'object') { return null diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index cb1704f0d8..b36cb42c68 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -10,6 +10,7 @@ import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers' import { chunkArray } from '@/lib/cleanup/batch-delete' import { getJobQueue } from '@/lib/core/async-jobs' import { shouldExecuteInline } from '@/lib/core/async-jobs/config' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy' @@ -314,6 +315,7 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ if (batch.length === 0) return const currentBatch = batch batch = [] + const region = await resolveTriggerRegion() const batchResult = await tasks.batchTrigger( jobType, currentBatch.map((payload) => ({ @@ -321,6 +323,7 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{ options: { tags: [`plan:${payload.plan}`, `jobType:${jobType}`], concurrencyKey: getCleanupConcurrencyKey(jobType), + region, }, })) ) diff --git a/apps/sim/lib/billing/retention.test.ts b/apps/sim/lib/billing/retention.test.ts index 2852cb6a64..15714bc046 100644 --- a/apps/sim/lib/billing/retention.test.ts +++ b/apps/sim/lib/billing/retention.test.ts @@ -21,7 +21,11 @@ describe('resolveEffectivePiiRedaction', () => { orgSettings: settings([allRule]), workspaceId: 'ws-1', }) - expect(result).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'] }) + expect(result).toEqual({ + enabled: true, + entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'], + language: 'en', + }) }) it('lets a workspace-specific rule override the all rule', () => { @@ -29,7 +33,27 @@ describe('resolveEffectivePiiRedaction', () => { orgSettings: settings([allRule, { id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-1' }]), workspaceId: 'ws-1', }) - expect(result).toEqual({ enabled: true, entityTypes: ['US_SSN'] }) + expect(result).toEqual({ enabled: true, entityTypes: ['US_SSN'], language: 'en' }) + }) + + it('carries the rule language through (defaults to en)', () => { + const result = resolveEffectivePiiRedaction({ + orgSettings: settings([ + { id: 'r-es', entityTypes: ['ES_NIF'], workspaceId: 'ws-1', language: 'es' }, + ]), + workspaceId: 'ws-1', + }) + expect(result).toEqual({ enabled: true, entityTypes: ['ES_NIF'], language: 'es' }) + }) + + it('falls back to en when a stored language is unsupported/stale', () => { + const result = resolveEffectivePiiRedaction({ + orgSettings: settings([ + { id: 'r-de', entityTypes: ['EMAIL_ADDRESS'], workspaceId: 'ws-1', language: 'de' }, + ]), + workspaceId: 'ws-1', + }) + expect(result).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' }) }) it('exempts a workspace when its specific rule has no entity types', () => { @@ -37,7 +61,7 @@ describe('resolveEffectivePiiRedaction', () => { orgSettings: settings([allRule, { id: 'r-1', entityTypes: [], workspaceId: 'ws-1' }]), workspaceId: 'ws-1', }) - expect(result).toEqual({ enabled: false, entityTypes: [] }) + expect(result).toEqual({ enabled: false, entityTypes: [], language: 'en' }) }) it('is disabled when no rule matches and there is no all rule', () => { @@ -45,16 +69,17 @@ describe('resolveEffectivePiiRedaction', () => { orgSettings: settings([{ id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-2' }]), workspaceId: 'ws-1', }) - expect(result).toEqual({ enabled: false, entityTypes: [] }) + expect(result).toEqual({ enabled: false, entityTypes: [], language: 'en' }) }) it('is disabled when there are no rules', () => { expect( resolveEffectivePiiRedaction({ orgSettings: settings([]), workspaceId: 'ws-1' }) - ).toEqual({ enabled: false, entityTypes: [] }) + ).toEqual({ enabled: false, entityTypes: [], language: 'en' }) expect(resolveEffectivePiiRedaction({ orgSettings: null, workspaceId: 'ws-1' })).toEqual({ enabled: false, entityTypes: [], + language: 'en', }) }) }) diff --git a/apps/sim/lib/billing/retention.ts b/apps/sim/lib/billing/retention.ts index 183dbb280e..dafb9e3a78 100644 --- a/apps/sim/lib/billing/retention.ts +++ b/apps/sim/lib/billing/retention.ts @@ -1,14 +1,18 @@ import type { DataRetentionSettings } from '@sim/db/schema' +import { coercePiiLanguage, DEFAULT_PII_LANGUAGE } from '@/lib/guardrails/pii-entities' export interface EffectivePiiRedaction { enabled: boolean /** Presidio entity types to mask. Empty = redact all detected PII. */ entityTypes: string[] + /** Language whose Presidio recognizers apply when masking. */ + language: string } export const DEFAULT_PII_REDACTION: EffectivePiiRedaction = { enabled: false, entityTypes: [], + language: DEFAULT_PII_LANGUAGE, } /** @@ -34,5 +38,6 @@ export function resolveEffectivePiiRedaction(params: { ? rule.entityTypes.filter((t): t is string => typeof t === 'string') : [] if (types.length === 0) return DEFAULT_PII_REDACTION - return { enabled: true, entityTypes: types } + const language = coercePiiLanguage(rule?.language) ?? DEFAULT_PII_LANGUAGE + return { enabled: true, entityTypes: types, language } } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index c02948bf9a..ef1a8fed1b 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -134,12 +134,14 @@ function shouldImportInBackground(record: { name: string; size: number }): boole async function dispatchImportJob(payload: TableImportPayload): Promise { if (isTriggerDevEnabled) { try { - const [{ tableImportTask }, { tasks }] = await Promise.all([ + const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-import'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-import', payload, { tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`], + region: await resolveTriggerRegion(), }) } catch (error) { await releaseJobClaim(payload.tableId, payload.importId).catch(() => {}) @@ -166,14 +168,15 @@ async function dispatchDeleteJob(params: { const { jobId, tableId, workspaceId, filter, cutoff, maxRows } = params if (isTriggerDevEnabled) { try { - const [{ tableDeleteTask }, { tasks }] = await Promise.all([ + const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-delete'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger( 'table-delete', { jobId, tableId, workspaceId, filter, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { await releaseJobClaim(tableId, jobId).catch(() => {}) @@ -208,14 +211,15 @@ async function dispatchUpdateJob(params: { const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params if (isTriggerDevEnabled) { try { - const [{ tableUpdateTask }, { tasks }] = await Promise.all([ + const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-update'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger( 'table-update', { jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { await releaseJobClaim(tableId, jobId).catch(() => {}) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 6e9cbd063b..233be772e9 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { taskContext } from '@trigger.dev/core/v3' import { runs, type TriggerOptions, tasks } from '@trigger.dev/sdk' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { type EnqueueOptions, JOB_STATUS, @@ -84,6 +85,7 @@ export class TriggerDevJobQueue implements JobQueueBackend { if (options?.delayMs && options.delayMs > 0) { triggerOptions.delay = new Date(Date.now() + options.delayMs) } + triggerOptions.region = await resolveTriggerRegion() const handle = await tasks.trigger(taskId, enrichedPayload, triggerOptions) logger.debug('Enqueued job via trigger.dev', { jobId: handle.id, type, taskId, tags }) @@ -125,6 +127,7 @@ export class TriggerDevJobQueue implements JobQueueBackend { const taskId = JOB_TYPE_TO_TASK_ID[type] if (!taskId) throw new Error(`Unknown job type: ${type}`) + const region = await resolveTriggerRegion() const batchItems = items.map(({ payload, options }) => { const enrichedPayload = options?.metadata && typeof payload === 'object' && payload !== null @@ -133,12 +136,12 @@ export class TriggerDevJobQueue implements JobQueueBackend { const tags = buildTags(options) const batchItem: { payload: unknown - options?: { concurrencyKey?: string; tags?: string[] } + options?: { concurrencyKey?: string; tags?: string[]; region?: string } } = { payload: enrichedPayload } - const batchOpts: { concurrencyKey?: string; tags?: string[] } = {} + const batchOpts: { concurrencyKey?: string; tags?: string[]; region?: string } = { region } if (options?.concurrencyKey) batchOpts.concurrencyKey = options.concurrencyKey if (tags.length > 0) batchOpts.tags = tags - if (Object.keys(batchOpts).length > 0) batchItem.options = batchOpts + batchItem.options = batchOpts return batchItem }) diff --git a/apps/sim/lib/core/async-jobs/region.test.ts b/apps/sim/lib/core/async-jobs/region.test.ts new file mode 100644 index 0000000000..b1b571594d --- /dev/null +++ b/apps/sim/lib/core/async-jobs/region.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsFeatureEnabled } = vi.hoisted(() => ({ + mockIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})) + +import { + resolveTriggerRegion, + TRIGGER_REGION_EU_CENTRAL, + TRIGGER_REGION_US_EAST, +} from '@/lib/core/async-jobs/region' + +describe('resolveTriggerRegion', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns eu-central-1 when the flag is enabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(true) + expect(await resolveTriggerRegion()).toBe(TRIGGER_REGION_EU_CENTRAL) + expect(mockIsFeatureEnabled).toHaveBeenCalledWith('trigger-eu-region') + }) + + it('returns us-east-1 when the flag is disabled', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + expect(await resolveTriggerRegion()).toBe(TRIGGER_REGION_US_EAST) + }) + + it('evaluates globally, passing no gating context', async () => { + mockIsFeatureEnabled.mockResolvedValue(false) + await resolveTriggerRegion() + expect(mockIsFeatureEnabled).toHaveBeenCalledTimes(1) + expect(mockIsFeatureEnabled.mock.calls[0]).toEqual(['trigger-eu-region']) + }) +}) diff --git a/apps/sim/lib/core/async-jobs/region.ts b/apps/sim/lib/core/async-jobs/region.ts new file mode 100644 index 0000000000..94c42bf331 --- /dev/null +++ b/apps/sim/lib/core/async-jobs/region.ts @@ -0,0 +1,21 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + +/** Default Trigger.dev region — the project default when the eu-central flag is off. */ +export const TRIGGER_REGION_US_EAST = 'us-east-1' + +/** Target region when the `trigger-eu-region` flag is enabled. */ +export const TRIGGER_REGION_EU_CENTRAL = 'eu-central-1' + +/** + * Resolve which Trigger.dev region a run should execute in. Gated globally by the + * `trigger-eu-region` feature flag (all-or-nothing — no per-user/org targeting): + * `eu-central-1` when enabled, otherwise `us-east-1`. + * + * The result is passed as the `region` option to `tasks.trigger` / `batchTrigger`, + * overriding the project's dashboard default per run. + */ +export async function resolveTriggerRegion(): Promise { + return (await isFeatureEnabled('trigger-eu-region')) + ? TRIGGER_REGION_EU_CENTRAL + : TRIGGER_REGION_US_EAST +} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 09c2e4fe51..89924eb568 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -74,6 +74,7 @@ export const env = createEnv({ TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI + TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth) // Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans. FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5) @@ -311,6 +312,7 @@ export const env = createEnv({ PORT: z.number().optional(), // Main application port INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000) ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins + PII_URL: z.string().optional(), // Presidio PII sidecar base URL serving /analyze + /anonymize (default http://localhost:5001) // OAuth Integration Credentials - All optional, enables third-party integrations GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services @@ -390,6 +392,7 @@ export const env = createEnv({ E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path + E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Pi Coding Agent cloud mode) // Credential Sets (Email Polling) - for self-hosted deployments CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index a38f434063..5c366af622 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -63,6 +63,7 @@ describe('getFeatureFlags', () => { expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) + expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -90,6 +91,7 @@ describe('getFeatureFlags', () => { expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(flags['pii-redaction']).toEqual({ enabled: false }) + expect(flags['trigger-eu-region']).toEqual({ enabled: false }) }) it('degrades gracefully on a malformed document', async () => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 6fa8ec0ebe..658b57105e 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -90,6 +90,13 @@ const FEATURE_FLAGS = { 'agree.', fallback: 'PII_REDACTION', }, + 'trigger-eu-region': { + description: + 'Route Trigger.dev runs to eu-central-1 instead of the default us-east-1. Global on/off ' + + 'only — resolved without user/org context at every task-trigger call site via ' + + 'resolveTriggerRegion, so the whole deployment switches regions together.', + fallback: 'TRIGGER_EU_REGION', + }, } satisfies Record /** diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 697fc5992d..ccefb86cf9 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -107,7 +107,7 @@ async function writeSandboxInputs( }) } -async function createE2BSandbox(kind: 'code' | 'shell' | 'doc'): Promise { +async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) { throw new Error('E2B_API_KEY is required when E2B is enabled') @@ -120,8 +120,18 @@ async function createE2BSandbox(kind: 'code' | 'shell' | 'doc'): Promise + timeoutMs: number + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void + } + ): Promise + readFile(path: string): Promise + /** + * Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK, + * never a shell, so untrusted content (the assembled prompt, a commit message) + * is delivered without any shell parsing — callers reference it by a fixed path. + */ + writeFile(path: string, content: string): Promise +} + +/** + * Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned + * repo persists across the clone -> agent -> push commands), streams command + * output, and always kills the sandbox afterward. Per-command envs are isolated, + * so secrets handed to one command never leak into the next. + */ +export async function withPiSandbox(fn: (runner: PiSandboxRunner) => Promise): Promise { + const sandbox = await createE2BSandbox('pi') + const sandboxId = sandbox.sandboxId + logger.info('Started Pi sandbox', { sandboxId }) + + const runner: PiSandboxRunner = { + run: async (command, options) => { + try { + const result = await sandbox.commands.run(command, { + envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH }, + timeoutMs: options.timeoutMs, + user: 'root', + onStdout: options.onStdout, + onStderr: options.onStderr, + }) + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode } + } catch (error) { + const failure = error as { + stdout?: string + stderr?: string + message?: string + exitCode?: number + } + return { + stdout: failure.stdout ?? '', + stderr: failure.stderr ?? failure.message ?? getErrorMessage(error), + exitCode: failure.exitCode ?? 1, + } + } + }, + readFile: (path) => sandbox.files.read(path), + writeFile: async (path, content) => { + await sandbox.files.write(path, content) + }, + } + + try { + return await fn(runner) + } finally { + try { + await sandbox.kill() + } catch {} + } +} diff --git a/apps/sim/lib/guardrails/.gitignore b/apps/sim/lib/guardrails/.gitignore deleted file mode 100644 index 3485e9bdf6..0000000000 --- a/apps/sim/lib/guardrails/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -# Python virtual environment -venv/ - -# Python cache -__pycache__/ -*.pyc -*.pyo -*.pyd -.Python - -# Presidio cache -.presidio/ - diff --git a/apps/sim/lib/guardrails/README.md b/apps/sim/lib/guardrails/README.md index 6ce7802d22..6c0a5df970 100644 --- a/apps/sim/lib/guardrails/README.md +++ b/apps/sim/lib/guardrails/README.md @@ -19,22 +19,29 @@ For **hallucination detection**, you'll need: - A knowledge base with documents - An LLM provider API key (or use hosted models) -### Python Validators (PII Detection) +### PII Detection (Presidio sidecar) -For **PII detection**, you need to set up a Python virtual environment and install Microsoft Presidio: +PII detection runs against **one** long-lived Presidio sidecar — a combined service (built from +`docker/pii.Dockerfile`, source in `apps/pii/server.py`) that constructs a warm `AnalyzerEngine` + +`AnonymizerEngine` once and exposes both `/analyze` and `/anonymize` (plus `/health`) on a single +port. In deployment it runs alongside the app container in the same ECS task; locally, build and run +it: ```bash -cd apps/sim/lib/guardrails -./setup.sh +docker build -f docker/pii.Dockerfile -t sim-pii . +docker run -d -p 5001:5001 sim-pii ``` -This will: -1. Create a Python virtual environment in `apps/sim/lib/guardrails/venv` -2. Install required dependencies: - - `presidio-analyzer` - PII detection engine - - `presidio-anonymizer` - PII masking/anonymization +Point the app at it (default shown): -The TypeScript wrapper will automatically use the virtual environment's Python interpreter. +```bash +PII_URL=http://localhost:5001 +``` + +The image bakes in the recognizers itself — a check-digit-validated **VIN** recognizer and +multi-language NLP models (en/es/it/pl/fi) — so the app is a thin HTTP client (`validate_pii.ts`) with +no Python or local venv. The redaction language is configured per rule (Data Retention) and defaults +to English. ## Usage @@ -93,10 +100,8 @@ See [Presidio documentation](https://microsoft.github.io/presidio/supported_enti - `validate_json.ts` - JSON validation (TypeScript) - `validate_regex.ts` - Regex validation (TypeScript) - `validate_hallucination.ts` - Hallucination detection with RAG + LLM scoring (TypeScript) -- `validate_pii.ts` - PII detection TypeScript wrapper (TypeScript) -- `validate_pii.py` - PII detection using Microsoft Presidio (Python) +- `validate_pii.ts` - PII detection client: calls the Presidio sidecar's /analyze + /anonymize (TypeScript) +- `pii-entities.ts` - Client-safe PII entity + language catalog (shared by the block and Data Retention) +- `mask-client.ts` - Internal HTTP client for batch PII masking from the log-redaction persist path - `validate.test.ts` - Test suite for JSON and regex validators -- `validate_hallucination.py` - Legacy Python hallucination detector (deprecated) -- `requirements.txt` - Python dependencies for PII detection (and legacy hallucination) -- `setup.sh` - Legacy installation script (deprecated) diff --git a/apps/sim/lib/guardrails/mask-client.test.ts b/apps/sim/lib/guardrails/mask-client.test.ts new file mode 100644 index 0000000000..d1c4ad5b84 --- /dev/null +++ b/apps/sim/lib/guardrails/mask-client.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockToken, mockBaseUrl } = vi.hoisted(() => ({ + mockToken: vi.fn(), + mockBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) +vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: mockBaseUrl })) + +import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client' + +describe('maskPIIBatchViaHttp', () => { + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockToken.mockResolvedValue('tok') + mockBaseUrl.mockReturnValue('http://app.internal:3000') + fetchMock = vi.fn(async (_url: string, init: { body: string }) => { + const { texts } = JSON.parse(init.body) as { texts: string[] } + return new Response(JSON.stringify({ masked: texts.map((t) => `M(${t})`) }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('masks a small batch in a single request, with an abort timeout', async () => { + const out = await maskPIIBatchViaHttp(['a', 'b', 'c'], ['EMAIL_ADDRESS']) + + expect(out).toEqual(['M(a)', 'M(b)', 'M(c)']) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal) + }) + + it('splits by count into multiple requests, preserving global order', async () => { + const texts = Array.from({ length: 5000 }, (_, i) => `t${i}`) + + const out = await maskPIIBatchViaHttp(texts, []) + + expect(out).toHaveLength(5000) + expect(out[0]).toBe('M(t0)') + expect(out[4999]).toBe('M(t4999)') + expect(fetchMock).toHaveBeenCalledTimes(3) // 2000-per-request cap + }) + + it('throws on a non-2xx response so the caller can scrub', async () => { + fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })) + + await expect(maskPIIBatchViaHttp(['a'], [])).rejects.toThrow(/mask-batch request failed/) + }) + + it('returns [] without any request for empty input', async () => { + const out = await maskPIIBatchViaHttp([], []) + + expect(out).toEqual([]) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/guardrails/mask-client.ts b/apps/sim/lib/guardrails/mask-client.ts new file mode 100644 index 0000000000..3fb818a3c7 --- /dev/null +++ b/apps/sim/lib/guardrails/mask-client.ts @@ -0,0 +1,99 @@ +import type { GuardrailsMaskBatchResult } from '@/lib/api/contracts' +import { generateInternalToken } from '@/lib/auth/internal' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +/** + * Per-request limits. A chunk is flushed when it hits either bound, keeping each + * request small enough for one short Presidio pass under a tight timeout and far + * below the contract's 100k-entry cap — so large executions split across + * requests instead of failing validation. + */ +const REQUEST_MAX_BYTES = 256 * 1024 +const REQUEST_MAX_COUNT = 2_000 +/** Bounds one mask-batch request; an unreachable/stuck Presidio sidecar aborts so the caller scrubs. */ +const REQUEST_TIMEOUT_MS = 45_000 + +/** + * Mask PII across many strings via the internal app-container endpoint. + * + * The Presidio sidecars run only in the app task, but the log-redaction persist + * path also runs inside the trigger.dev runtime — so redaction always routes + * through HTTP, the same way the guardrails tool does. + * Strings are grouped into byte/count-budgeted chunks; order is preserved, so + * the returned array matches `texts` length. + * + * Rejects on any non-2xx, timeout, or shape mismatch so the caller can apply + * its own fail-safe (scrubbing rather than leaking). + */ +export async function maskPIIBatchViaHttp( + texts: string[], + entityTypes: string[], + language?: string +): Promise { + if (texts.length === 0) return [] + + const url = `${getInternalApiBaseUrl()}/api/guardrails/mask-batch` + + const masked: string[] = [] + let batch: string[] = [] + let batchBytes = 0 + + const flush = async () => { + if (batch.length === 0) return + const out = await postChunk(url, batch, entityTypes, language) + if (out.length !== batch.length) { + throw new Error('PII mask-batch returned an unexpected result') + } + for (const item of out) masked.push(item) + batch = [] + batchBytes = 0 + } + + for (const text of texts) { + const bytes = Buffer.byteLength(text, 'utf8') + if ( + batch.length > 0 && + (batch.length >= REQUEST_MAX_COUNT || batchBytes + bytes > REQUEST_MAX_BYTES) + ) { + await flush() + } + batch.push(text) + batchBytes += bytes + } + await flush() + + return masked +} + +async function postChunk( + url: string, + texts: string[], + entityTypes: string[], + language: string | undefined +): Promise { + // Mint per request: a single token (5min TTL) can expire mid-batch when a + // large execution fans out into many sequential chunk requests. + const token = await generateInternalToken() + + // boundary-raw-fetch: internal server-to-server call to the app container (internal JWT auth, configurable base URL) + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ texts, entityTypes, language }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`PII mask-batch request failed (${response.status}): ${detail.slice(0, 200)}`) + } + + const data = (await response.json()) as GuardrailsMaskBatchResult + if (!Array.isArray(data.masked)) { + throw new Error('PII mask-batch returned an unexpected result') + } + return data.masked +} diff --git a/apps/sim/lib/guardrails/pii-entities.ts b/apps/sim/lib/guardrails/pii-entities.ts index 0e67fe22ff..c26e7dc0b9 100644 --- a/apps/sim/lib/guardrails/pii-entities.ts +++ b/apps/sim/lib/guardrails/pii-entities.ts @@ -51,8 +51,6 @@ export const SUPPORTED_PII_ENTITIES = { IN_VOTER: 'Indian voter ID', IN_PASSPORT: 'Indian passport', FI_PERSONAL_IDENTITY_CODE: 'Finnish Personal Identity Code', - KR_RRN: 'Korean Resident Registration Number', - TH_TNIN: 'Thai National ID Number', } as const export type PIIEntityType = keyof typeof SUPPORTED_PII_ENTITIES @@ -115,8 +113,6 @@ export const PII_ENTITY_GROUPS: ReadonlyArray<{ 'IN_VOTER', 'IN_PASSPORT', 'FI_PERSONAL_IDENTITY_CODE', - 'KR_RRN', - 'TH_TNIN', ], }, ].map((group) => ({ @@ -126,3 +122,37 @@ export const PII_ENTITY_GROUPS: ReadonlyArray<{ label: SUPPORTED_PII_ENTITIES[value as PIIEntityType], })), })) + +/** + * Languages the Presidio image has NLP models for. The analyzer only recognizes a + * language's entities when its model is loaded, so this set must match the image. + */ +export const PII_LANGUAGES = [ + { value: 'en', label: 'English' }, + { value: 'es', label: 'Spanish' }, + { value: 'it', label: 'Italian' }, + { value: 'pl', label: 'Polish' }, + { value: 'fi', label: 'Finnish' }, +] as const + +export type PIILanguage = (typeof PII_LANGUAGES)[number]['value'] + +/** Non-empty tuple of language codes for schema/enum use. */ +export const PII_LANGUAGE_CODES = PII_LANGUAGES.map((l) => l.value) as [ + PIILanguage, + ...PIILanguage[], +] + +/** Default redaction language when a rule doesn't set one. */ +export const DEFAULT_PII_LANGUAGE: PIILanguage = 'en' + +/** + * Narrow a loosely-typed (stored/legacy) language to a supported code. Unknown or + * stale values (e.g. a dropped locale) return `undefined` so callers fall back to + * the default rather than forwarding an unsupported language to Presidio. + */ +export function coercePiiLanguage(value: string | undefined): PIILanguage | undefined { + return value && (PII_LANGUAGE_CODES as readonly string[]).includes(value) + ? (value as PIILanguage) + : undefined +} diff --git a/apps/sim/lib/guardrails/requirements.txt b/apps/sim/lib/guardrails/requirements.txt deleted file mode 100644 index 135efae05b..0000000000 --- a/apps/sim/lib/guardrails/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Microsoft Presidio for PII detection -presidio-analyzer>=2.2.0 -presidio-anonymizer>=2.2.0 - diff --git a/apps/sim/lib/guardrails/setup.sh b/apps/sim/lib/guardrails/setup.sh deleted file mode 100755 index 233e9a51a2..0000000000 --- a/apps/sim/lib/guardrails/setup.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -# Setup script for guardrails validators -# This creates a virtual environment and installs Python dependencies - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VENV_DIR="$SCRIPT_DIR/venv" - -echo "Setting up Python environment for guardrails..." - -# Check if Python 3 is available -if ! command -v python3 &> /dev/null; then - echo "Error: python3 is not installed. Please install Python 3 first." - exit 1 -fi - -# Create virtual environment if it doesn't exist -if [ ! -d "$VENV_DIR" ]; then - echo "Creating virtual environment..." - python3 -m venv "$VENV_DIR" -else - echo "Virtual environment already exists." -fi - -# Activate virtual environment and install dependencies -echo "Installing Python dependencies..." -source "$VENV_DIR/bin/activate" -pip install --upgrade pip -pip install -r "$SCRIPT_DIR/requirements.txt" - -echo "" -echo "✅ Setup complete! Guardrails validators are ready to use." -echo "" -echo "Virtual environment created at: $VENV_DIR" - diff --git a/apps/sim/lib/guardrails/validate_pii.py b/apps/sim/lib/guardrails/validate_pii.py deleted file mode 100644 index d475b96e23..0000000000 --- a/apps/sim/lib/guardrails/validate_pii.py +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env python3 -""" -PII Detection Validator using Microsoft Presidio - -Detects personally identifiable information (PII) in text and either: -- Blocks the request if PII is detected (block mode) -- Masks the PII and returns the masked text (mask mode) -""" - -import sys -import json -from typing import List, Dict, Any - -try: - from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer - from presidio_anonymizer import AnonymizerEngine - from presidio_anonymizer.entities import OperatorConfig -except ImportError: - print(json.dumps({ - "passed": False, - "error": "Presidio not installed. Run: pip install presidio-analyzer presidio-anonymizer", - "detectedEntities": [] - })) - sys.exit(0) - - -class VinRecognizer(PatternRecognizer): - """ - Recognizes Vehicle Identification Numbers (17 chars, A-Z/0-9 excluding - I/O/Q) and validates the ISO 3779 check digit (position 9). Validation makes - accidental matches on arbitrary 17-char codes (request ids, SKUs, tokens) - extremely unlikely. Note: some non-North-American VINs don't use the check - digit and will be skipped — an intentional bias toward precision. - """ - - _TRANSLIT = { - **{str(d): d for d in range(10)}, - "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8, - "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9, - "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9, - } - _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2] - - def validate_result(self, pattern_text: str): - vin = pattern_text.upper() - if len(vin) != 17: - return False - try: - total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS)) - except KeyError: - return False - check = total % 11 - expected = "X" if check == 10 else str(check) - return vin[8] == expected - - -def build_analyzer() -> "AnalyzerEngine": - """ - AnalyzerEngine with custom recognizers registered on top of the Presidio - defaults. Adds a check-digit-validated VIN recognizer. - """ - analyzer = AnalyzerEngine() - vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7) - vin_recognizer = VinRecognizer( - supported_entity="VIN", - patterns=[vin_pattern], - context=["vin", "vehicle", "chassis"], - ) - analyzer.registry.add_recognizer(vin_recognizer) - return analyzer - - -def detect_pii( - text: str, - entity_types: List[str], - mode: str = "block", - language: str = "en" -) -> Dict[str, Any]: - """ - Detect PII in text using Presidio - - Args: - text: Input text to analyze - entity_types: List of PII entity types to detect (e.g., ["PERSON", "EMAIL_ADDRESS"]) - mode: "block" to fail validation if PII found, "mask" to return masked text - language: Language code (default: "en") - - Returns: - Dictionary with validation result - """ - try: - # Initialize Presidio engines - analyzer = build_analyzer() - - # Analyze text for PII - results = analyzer.analyze( - text=text, - entities=entity_types if entity_types else None, # None = detect all - language=language - ) - - # Extract detected entities - detected_entities = [] - for result in results: - detected_entities.append({ - "type": result.entity_type, - "start": result.start, - "end": result.end, - "score": result.score, - "text": text[result.start:result.end] - }) - - # If no PII detected, validation passes - if not results: - return { - "passed": True, - "detectedEntities": [], - "maskedText": None - } - - # Block mode: fail validation if PII detected - if mode == "block": - entity_summary = {} - for entity in detected_entities: - entity_type = entity["type"] - entity_summary[entity_type] = entity_summary.get(entity_type, 0) + 1 - - summary_str = ", ".join([f"{count} {etype}" for etype, count in entity_summary.items()]) - - return { - "passed": False, - "error": f"PII detected: {summary_str}", - "detectedEntities": detected_entities, - "maskedText": None - } - - # Mask mode: anonymize PII and return masked text - elif mode == "mask": - anonymizer = AnonymizerEngine() - - # Use as the replacement pattern - operators = {} - for entity_type in set([r.entity_type for r in results]): - operators[entity_type] = OperatorConfig("replace", {"new_value": f"<{entity_type}>"}) - - anonymized_result = anonymizer.anonymize( - text=text, - analyzer_results=results, - operators=operators - ) - - return { - "passed": True, - "detectedEntities": detected_entities, - "maskedText": anonymized_result.text - } - - else: - return { - "passed": False, - "error": f"Invalid mode: {mode}. Must be 'block' or 'mask'", - "detectedEntities": [] - } - - except Exception as e: - return { - "passed": False, - "error": f"PII detection failed: {str(e)}", - "detectedEntities": [] - } - - -def mask_batch( - texts: List[str], - entity_types: List[str], - language: str = "en" -) -> Dict[str, Any]: - """ - Mask PII across many strings in a single process, reusing one analyzer + - anonymizer instance (engine construction loads the spaCy model and is the - dominant cost). Returns masked text per input, in input order; strings with - no detected PII are returned unchanged so callers can substitute directly. - """ - analyzer = build_analyzer() - anonymizer = AnonymizerEngine() - entities = entity_types if entity_types else None - - results = [] - for text in texts: - if not text: - results.append({"maskedText": text}) - continue - analyzer_results = analyzer.analyze(text=text, entities=entities, language=language) - if not analyzer_results: - results.append({"maskedText": text}) - continue - operators = { - entity_type: OperatorConfig("replace", {"new_value": f"<{entity_type}>"}) - for entity_type in set([r.entity_type for r in analyzer_results]) - } - anonymized = anonymizer.anonymize( - text=text, - analyzer_results=analyzer_results, - operators=operators - ) - results.append({"maskedText": anonymized.text}) - - return {"passed": True, "results": results} - - -def main(): - """Main entry point for CLI usage""" - try: - # Read input from stdin - input_data = sys.stdin.read() - data = json.loads(input_data) - - entity_types = data.get("entityTypes", []) - language = data.get("language", "en") - - # Batch mask mode: an array of texts processed with one warm engine pair. - if "texts" in data: - texts = data.get("texts", []) - result = mask_batch(texts, entity_types, language) - print(f"__SIM_RESULT__={json.dumps(result)}") - return - - text = data.get("text", "") - mode = data.get("mode", "block") - - # Validate inputs - if not text: - result = { - "passed": False, - "error": "No text provided", - "detectedEntities": [] - } - else: - result = detect_pii(text, entity_types, mode, language) - - # Output result with marker for parsing - print(f"__SIM_RESULT__={json.dumps(result)}") - - except json.JSONDecodeError as e: - print(f"__SIM_RESULT__={json.dumps({ - 'passed': False, - 'error': f'Invalid JSON input: {str(e)}', - 'detectedEntities': [] - })}") - except Exception as e: - print(f"__SIM_RESULT__={json.dumps({ - 'passed': False, - 'error': f'Unexpected error: {str(e)}', - 'detectedEntities': [] - })}") - - -if __name__ == "__main__": - main() - diff --git a/apps/sim/lib/guardrails/validate_pii.test.ts b/apps/sim/lib/guardrails/validate_pii.test.ts new file mode 100644 index 0000000000..0ba1c585bc --- /dev/null +++ b/apps/sim/lib/guardrails/validate_pii.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii' + +interface Span { + entity_type: string + start: number + end: number + score: number +} + +/** Mimic the Presidio anonymizer's default `replace`: each span → ``. */ +function applyReplace(text: string, results: Span[]): string { + let out = text + for (const s of [...results].sort((a, b) => b.start - a.start)) { + out = `${out.slice(0, s.start)}<${s.entity_type}>${out.slice(s.end)}` + } + return out +} + +/** Analyzer mock: flags `a@b.com` as EMAIL_ADDRESS when that entity is in scope. */ +function emailSpans(text: string, entities: string[] | undefined): Span[] { + if (entities && !entities.includes('EMAIL_ADDRESS')) return [] + const idx = text.indexOf('a@b.com') + return idx === -1 ? [] : [{ entity_type: 'EMAIL_ADDRESS', start: idx, end: idx + 7, score: 0.9 }] +} + +describe('validate_pii (Presidio sidecar)', () => { + let analyzeBodies: Array<{ text: string; language: string; entities?: string[] }> + let fetchMock: ReturnType + + beforeEach(() => { + analyzeBodies = [] + fetchMock = vi.fn(async (url: string, init: { body: string }) => { + const body = JSON.parse(init.body) + if (url.includes('/analyze')) { + analyzeBodies.push({ text: body.text, language: body.language, entities: body.entities }) + return new Response(JSON.stringify(emailSpans(body.text, body.entities)), { status: 200 }) + } + // /anonymize + return new Response( + JSON.stringify({ text: applyReplace(body.text, body.analyzer_results) }), + { + status: 200, + } + ) + }) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => vi.unstubAllGlobals()) + + describe('maskPIIBatch', () => { + it('masks detected entities, preserving input order', async () => { + const out = await maskPIIBatch(['email a@b.com', 'nothing here'], []) + expect(out[0]).toBe('email ') + expect(out[1]).toBe('nothing here') + }) + + it('forwards entityTypes (and language) to the analyzer; empty ⇒ omitted (all)', async () => { + await maskPIIBatch(['mail a@b.com'], ['EMAIL_ADDRESS', 'PERSON'], 'es') + expect(analyzeBodies[0].entities).toEqual(['EMAIL_ADDRESS', 'PERSON']) + expect(analyzeBodies[0].language).toBe('es') + + analyzeBodies.length = 0 + await maskPIIBatch(['mail a@b.com'], []) + expect(analyzeBodies[0].entities).toBeUndefined() + }) + + it('returns [] for empty input and leaves empty strings untouched', async () => { + expect(await maskPIIBatch([], [])).toEqual([]) + expect(await maskPIIBatch([''], [])).toEqual(['']) + }) + + it('throws on a sidecar failure so the caller can scrub', async () => { + fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 })) + await expect(maskPIIBatch(['email a@b.com'], [])).rejects.toThrow(/Presidio analyze failed/) + }) + }) + + describe('validatePII', () => { + it('block mode fails with a summary when PII is detected', async () => { + const res = await validatePII({ + text: 'reach me at a@b.com', + entityTypes: [], + mode: 'block', + requestId: 'r1', + }) + expect(res.passed).toBe(false) + expect(res.error).toContain('EMAIL_ADDRESS') + expect(res.detectedEntities).toHaveLength(1) + }) + + it('mask mode returns masked text', async () => { + const res = await validatePII({ + text: 'mail a@b.com', + entityTypes: [], + mode: 'mask', + requestId: 'r2', + }) + expect(res.passed).toBe(true) + expect(res.maskedText).toBe('mail ') + }) + + it('passes clean text', async () => { + const res = await validatePII({ + text: 'nothing to see', + entityTypes: [], + mode: 'block', + requestId: 'r3', + }) + expect(res.passed).toBe(true) + expect(res.detectedEntities).toHaveLength(0) + }) + }) +}) diff --git a/apps/sim/lib/guardrails/validate_pii.ts b/apps/sim/lib/guardrails/validate_pii.ts index ba6886bb92..a24c8f880e 100644 --- a/apps/sim/lib/guardrails/validate_pii.ts +++ b/apps/sim/lib/guardrails/validate_pii.ts @@ -1,17 +1,18 @@ -import { spawn } from 'child_process' -import fs from 'fs' -import path from 'path' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' const logger = createLogger('PIIValidator') -const DEFAULT_TIMEOUT = 30000 // 30 seconds -/** - * Max total bytes of text sent to a single Presidio subprocess. spaCy NER is the - * bottleneck, so large payloads are split into multiple short calls instead of - * one that risks the 30s timeout. - */ -const PII_CHUNK_MAX_BYTES = 256 * 1024 +/** Just above the analyzer's spaCy NER budget so a stuck sidecar aborts gracefully. */ +const REQUEST_TIMEOUT_MS = 45_000 + +/** Concurrent per-string sidecar calls within one batch; the warm model handles parallelism. */ +const MASK_CONCURRENCY = 8 + +/** Single Presidio sidecar serving both /analyze and /anonymize (VIN is native there). */ +const PII_URL = env.PII_URL || 'http://localhost:5001' export interface PIIValidationInput { text: string @@ -36,12 +37,65 @@ export interface PIIValidationResult { maskedText?: string } +interface AnalyzerSpan { + entity_type: string + start: number + end: number + score: number +} + /** - * Validate text for PII using Microsoft Presidio + * Detect PII spans via the Presidio analyzer. An empty `entityTypes` ⇒ detect all. + * Throws on transport/HTTP failure so callers can apply their own fail-safe. + */ +async function analyze( + text: string, + entityTypes: string[], + language: string +): Promise { + const entities = entityTypes.length > 0 ? entityTypes : undefined + + // boundary-raw-fetch: internal call to the Presidio analyzer sidecar over localhost + const response = await fetch(`${PII_URL}/analyze`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text, language, ...(entities ? { entities } : {}) }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`) + } + return (await response.json()) as AnalyzerSpan[] +} + +/** + * Mask spans via the Presidio anonymizer sidecar. Omitting `anonymizers` uses the + * default `replace` operator, which yields ``. Throws on failure. + */ +async function anonymize(text: string, spans: AnalyzerSpan[]): Promise { + if (spans.length === 0) return text + + // boundary-raw-fetch: internal call to the Presidio anonymizer sidecar over localhost + const response = await fetch(`${PII_URL}/anonymize`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text, analyzer_results: spans }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`) + } + const data = (await response.json()) as { text: string } + return data.text +} + +/** + * Validate text for PII using the Presidio sidecar. * - * Supports two modes: - * - block: Fails validation if any PII is detected - * - mask: Passes validation and returns masked text with PII replaced + * - block: fails validation if any PII is detected + * - mask: passes and returns masked text with PII replaced by `` */ export async function validatePII(input: PIIValidationInput): Promise { const { text, entityTypes, mode, language = 'en', requestId } = input @@ -54,41 +108,60 @@ export async function validatePII(input: PIIValidationInput): Promise ({ + type: s.entity_type, + start: s.start, + end: s.end, + score: s.score, + text: text.slice(s.start, s.end), + })) + + if (spans.length === 0) { + logger.info(`[${requestId}] PII validation completed`, { passed: true, detectedCount: 0 }) + return { passed: true, detectedEntities: [], maskedText: mode === 'mask' ? text : undefined } + } - logger.info(`[${requestId}] PII validation completed`, { - passed: result.passed, - detectedCount: result.detectedEntities.length, - hasMaskedText: !!result.maskedText, - }) + if (mode === 'block') { + const counts = new Map() + for (const e of detectedEntities) counts.set(e.type, (counts.get(e.type) ?? 0) + 1) + const summary = Array.from(counts.entries()) + .map(([type, count]) => `${count} ${type}`) + .join(', ') + logger.info(`[${requestId}] PII validation completed`, { + passed: false, + detectedCount: detectedEntities.length, + }) + return { passed: false, error: `PII detected: ${summary}`, detectedEntities } + } - return result - } catch (error: any) { - logger.error(`[${requestId}] PII validation failed`, { - error: error.message, + // mask mode: the anonymizer replaces every span with ``. + const maskedText = await anonymize(text, spans) + logger.info(`[${requestId}] PII validation completed`, { + passed: true, + detectedCount: detectedEntities.length, + hasMaskedText: true, }) - + return { passed: true, detectedEntities, maskedText } + } catch (error) { + logger.error(`[${requestId}] PII validation failed`, { error: getErrorMessage(error) }) return { passed: false, - error: `PII validation failed: ${error.message}`, + error: `PII validation failed: ${getErrorMessage(error)}`, detectedEntities: [], } } } -interface PIIMaskBatchResult { - passed: boolean - error?: string - results?: { maskedText: string }[] -} - /** - * Mask PII across many strings, preserving input order. Strings are grouped into - * byte-budgeted chunks so no single subprocess exceeds {@link PII_CHUNK_MAX_BYTES} - * (keeping each call well under the 30s timeout). One Presidio engine pair is - * reused per subprocess invocation. Rejects on any subprocess failure so callers - * can apply their own fail-safe. + * Mask PII across many strings via the Presidio sidecar, preserving input order. + * Each string runs analyze → anonymize; strings with no detected PII are returned + * unchanged. Calls run with bounded concurrency: the sidecar's model is warm, so + * the bottleneck is round-trip latency, and a batch of thousands of small leaves + * would otherwise exceed the caller's request timeout if run strictly sequentially. + * Rejects on any sidecar failure (which fails the whole batch) so callers can apply + * their own fail-safe (scrub). */ export async function maskPIIBatch( texts: string[], @@ -97,223 +170,10 @@ export async function maskPIIBatch( ): Promise { if (texts.length === 0) return [] - const chunks: string[][] = [] - let current: string[] = [] - let currentBytes = 0 - for (const text of texts) { - const bytes = Buffer.byteLength(text, 'utf8') - if (current.length > 0 && currentBytes + bytes > PII_CHUNK_MAX_BYTES) { - chunks.push(current) - current = [] - currentBytes = 0 - } - current.push(text) - currentBytes += bytes - } - if (current.length > 0) chunks.push(current) - - const masked: string[] = [] - for (const chunk of chunks) { - const result = await runPythonScript({ - texts: chunk, - entityTypes, - mode: 'mask', - language, - }) - if (!result.passed || !result.results || result.results.length !== chunk.length) { - throw new Error(result.error || 'PII batch masking returned an unexpected result') - } - for (const item of result.results) masked.push(item.maskedText) - } - - return masked -} - -/** - * Spawn the Presidio Python script, write the payload to stdin as JSON, and parse - * the `__SIM_RESULT__=` marker from stdout. Rejects on non-zero exit, timeout, - * spawn failure, or a missing/unparseable marker. - */ -function runPythonScript(payload: Record): Promise { - return new Promise((resolve, reject) => { - const guardrailsDir = path.join(process.cwd(), 'lib/guardrails') - const scriptPath = path.join(guardrailsDir, 'validate_pii.py') - const venvPython = path.join(guardrailsDir, 'venv/bin/python3') - const pythonCmd = fs.existsSync(venvPython) ? venvPython : 'python3' - - const python = spawn(pythonCmd, [scriptPath]) - let stdout = '' - let stderr = '' - - const timeout = setTimeout(() => { - python.kill() - reject(new Error('PII processing timeout')) - }, DEFAULT_TIMEOUT) - - // stdin errors (e.g. EPIPE when the child exits before draining the payload — - // chunks can exceed the OS pipe buffer) emit on stdin, not the process. Without - // a listener Node throws an unhandled 'error' and crashes; funnel it into the - // promise so the caller's fail-safe scrub path handles it. - python.stdin.on('error', (error: Error) => { - clearTimeout(timeout) - reject(new Error(`PII script stdin error: ${error.message}`)) - }) - python.stdin.write(JSON.stringify(payload)) - python.stdin.end() - python.stdout.on('data', (data) => { - stdout += data.toString() - }) - python.stderr.on('data', (data) => { - stderr += data.toString() - }) - - python.on('close', (code) => { - clearTimeout(timeout) - if (code !== 0) { - reject(new Error(stderr || `PII script exited with code ${code}`)) - return - } - const prefix = '__SIM_RESULT__=' - const marker = stdout.split('\n').find((l) => l.startsWith(prefix)) - if (!marker) { - reject(new Error(`No result marker in PII script output: ${stdout.substring(0, 200)}`)) - return - } - try { - resolve(JSON.parse(marker.slice(prefix.length)) as T) - } catch (error: any) { - reject(new Error(`Failed to parse PII script result: ${error.message}`)) - } - }) - - python.on('error', (error) => { - clearTimeout(timeout) - reject( - new Error( - `Failed to execute Python: ${error.message}. Make sure Python 3 and Presidio are installed.` - ) - ) - }) - }) -} - -/** - * Execute Python PII detection script - */ -async function executePythonPIIDetection( - text: string, - entityTypes: string[], - mode: string, - language: string, - requestId: string -): Promise { - return new Promise((resolve, reject) => { - // Use path relative to project root - // In Next.js, process.cwd() returns the project root - const guardrailsDir = path.join(process.cwd(), 'lib/guardrails') - const scriptPath = path.join(guardrailsDir, 'validate_pii.py') - const venvPython = path.join(guardrailsDir, 'venv/bin/python3') - - // Use venv Python if it exists, otherwise fall back to system python3 - const pythonCmd = fs.existsSync(venvPython) ? venvPython : 'python3' - - const python = spawn(pythonCmd, [scriptPath]) - - let stdout = '' - let stderr = '' - - const timeout = setTimeout(() => { - python.kill() - reject(new Error('PII validation timeout')) - }, DEFAULT_TIMEOUT) - - // Write input to stdin as JSON - const inputData = JSON.stringify({ - text, - entityTypes, - mode, - language, - }) - // See runPythonScript: stdin errors (EPIPE on early child exit) must be - // caught here or Node throws an unhandled 'error' and crashes the process. - python.stdin.on('error', (error: Error) => { - clearTimeout(timeout) - reject(new Error(`Failed to write to Python: ${error.message}`)) - }) - python.stdin.write(inputData) - python.stdin.end() - - python.stdout.on('data', (data) => { - stdout += data.toString() - }) - - python.stderr.on('data', (data) => { - stderr += data.toString() - }) - - python.on('close', (code) => { - clearTimeout(timeout) - - if (code !== 0) { - logger.error(`[${requestId}] Python PII detection failed`, { - code, - stderr, - }) - resolve({ - passed: false, - error: stderr || 'PII detection failed', - detectedEntities: [], - }) - return - } - - // Parse result from stdout - try { - const prefix = '__SIM_RESULT__=' - const lines = stdout.split('\n') - const marker = lines.find((l) => l.startsWith(prefix)) - - if (marker) { - const jsonPart = marker.slice(prefix.length) - const result = JSON.parse(jsonPart) - resolve(result) - } else { - logger.error(`[${requestId}] No result marker found`, { - stdout, - stderr, - stdoutLines: lines, - }) - resolve({ - passed: false, - error: `No result marker found in output. stdout: ${stdout.substring(0, 200)}, stderr: ${stderr.substring(0, 200)}`, - detectedEntities: [], - }) - } - } catch (error: any) { - logger.error(`[${requestId}] Failed to parse Python result`, { - error: error.message, - stdout, - stderr, - }) - resolve({ - passed: false, - error: `Failed to parse result: ${error.message}. stdout: ${stdout.substring(0, 200)}`, - detectedEntities: [], - }) - } - }) - - python.on('error', (error) => { - clearTimeout(timeout) - logger.error(`[${requestId}] Failed to spawn Python process`, { - error: error.message, - }) - reject( - new Error( - `Failed to execute Python: ${error.message}. Make sure Python 3 and Presidio are installed.` - ) - ) - }) + return mapWithConcurrency(texts, MASK_CONCURRENCY, async (text) => { + if (!text) return text + const spans = await analyze(text, entityTypes, language) + return anonymize(text, spans) }) } diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index c3423b455d..cbd18b4333 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -12,6 +12,7 @@ import { generateId } from '@sim/utils/id' import { randomInt } from '@sim/utils/random' import { and, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import type { DocumentData } from '@/lib/knowledge/documents/service' import { @@ -325,7 +326,7 @@ export async function dispatchSync( fullSync: options?.fullSync, requestId, }, - { tags } + { tags, region: await resolveTriggerRegion() } ) logger.info(`Dispatched connector sync to Trigger.dev`, { connectorId, requestId }) } else { diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index bce2932ebf..fde428f467 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -32,6 +32,7 @@ import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { env, envNumber } from '@/lib/core/config/env' import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags' import { processDocument } from '@/lib/knowledge/documents/document-processor' @@ -447,6 +448,7 @@ async function dispatchViaBatchTrigger( ): Promise { let dispatched = 0 const batchIds: string[] = [] + const region = await resolveTriggerRegion() for (let i = 0; i < jobPayloads.length; i += TRIGGER_BATCH_SIZE) { const chunk = jobPayloads.slice(i, i + TRIGGER_BATCH_SIZE) try { @@ -462,6 +464,7 @@ async function dispatchViaBatchTrigger( `knowledgeBaseId:${payload.knowledgeBaseId}`, `documentId:${payload.documentId}`, ], + region, }, })) ) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 9a531b7293..e68ad3100f 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -620,7 +620,10 @@ export class ExecutionLogger implements IExecutionLoggerService { const config = resolveEffectivePiiRedaction({ orgSettings: row.orgSettings, workspaceId }) if (!config.enabled) return payload - return redactPIIFromExecution(payload, { entityTypes: config.entityTypes }) + return redactPIIFromExecution(payload, { + entityTypes: config.entityTypes, + language: config.language, + }) } async completeWorkflowExecution(params: { diff --git a/apps/sim/lib/logs/execution/pii-redaction.test.ts b/apps/sim/lib/logs/execution/pii-redaction.test.ts index dccbc59cc3..5a2da7a599 100644 --- a/apps/sim/lib/logs/execution/pii-redaction.test.ts +++ b/apps/sim/lib/logs/execution/pii-redaction.test.ts @@ -7,8 +7,8 @@ const { mockMaskPIIBatch } = vi.hoisted(() => ({ mockMaskPIIBatch: vi.fn(), })) -vi.mock('@/lib/guardrails/validate_pii', () => ({ - maskPIIBatch: mockMaskPIIBatch, +vi.mock('@/lib/guardrails/mask-client', () => ({ + maskPIIBatchViaHttp: mockMaskPIIBatch, })) import { REDACTION_FAILED_MARKER, redactPIIFromExecution } from '@/lib/logs/execution/pii-redaction' diff --git a/apps/sim/lib/logs/execution/pii-redaction.ts b/apps/sim/lib/logs/execution/pii-redaction.ts index 7b4794fd48..8cd0fac532 100644 --- a/apps/sim/lib/logs/execution/pii-redaction.ts +++ b/apps/sim/lib/logs/execution/pii-redaction.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client' const logger = createLogger('PiiRedaction') @@ -158,11 +159,9 @@ export async function redactPIIFromExecution( masked = collected.map(() => REDACTION_FAILED_MARKER) } else { try { - // Lazy import keeps the Python-spawning guardrails module (child_process + - // a `lib/guardrails` dir reference) out of the static middleware/RSC graph; - // it's only loaded at runtime on the Node log-persist path. - const { maskPIIBatch } = await import('@/lib/guardrails/validate_pii') - masked = await maskPIIBatch(collected, entityTypes, language) + // Presidio runs only in the app container; the persist path also runs in + // the trigger.dev runtime, so masking always goes over HTTP to the app. + masked = await maskPIIBatchViaHttp(collected, entityTypes, language) } catch (error) { logger.error('PII masking failed; scrubbing text to avoid leaking PII', { error: getErrorMessage(error), diff --git a/apps/sim/lib/messaging/lifecycle.ts b/apps/sim/lib/messaging/lifecycle.ts index 95b109d9cb..f4a871cd87 100644 --- a/apps/sim/lib/messaging/lifecycle.ts +++ b/apps/sim/lib/messaging/lifecycle.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' +import { resolveTriggerRegion } from '@/lib/core/async-jobs/region' import { env } from '@/lib/core/config/env' import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' @@ -41,6 +42,7 @@ export async function scheduleLifecycleEmail({ { delay: delayUntil, idempotencyKey: `lifecycle-${type}-${userId}`, + region: await resolveTriggerRegion(), } ) diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts index 387c89e145..b368e8ca0a 100644 --- a/apps/sim/lib/table/backfill-runner.ts +++ b/apps/sim/lib/table/backfill-runner.ts @@ -319,12 +319,14 @@ export async function maybeBackfillGroupOutputs(opts: { } if (isTriggerDevEnabled) { try { - const [{ tableBackfillTask }, { tasks }] = await Promise.all([ + const [{ tableBackfillTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-backfill'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger('table-backfill', payload, { tags: [`tableId:${table.id}`, `jobId:${jobId}`], + region: await resolveTriggerRegion(), }) } catch (error) { // Release the claim so a ghost `running` job doesn't block imports/deletes. diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index bfa7de1a36..c60a04efb4 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -764,14 +764,15 @@ export async function runWorkflowColumn(opts: { if (isTriggerDevEnabled) { // Trigger.dev runs `tableRunDispatcherTask`, which loops `dispatcherStep` // until done with CRIU-checkpointed waits between windows. - const [{ tableRunDispatcherTask }, { tasks }] = await Promise.all([ + const [{ tableRunDispatcherTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ import('@/background/table-run-dispatcher'), import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), ]) await tasks.trigger( 'table-run-dispatcher', { dispatchId }, - { concurrencyKey: dispatchId } + { concurrencyKey: dispatchId, region: await resolveTriggerRegion() } ) } else { // Local / no-trigger.dev: drive the same loop in-process, fire-and-forget diff --git a/apps/sim/lib/tokenization/constants.ts b/apps/sim/lib/tokenization/constants.ts index a10b1995da..484a397f84 100644 --- a/apps/sim/lib/tokenization/constants.ts +++ b/apps/sim/lib/tokenization/constants.ts @@ -56,6 +56,11 @@ export const TOKENIZATION_CONFIG = { confidence: 'medium', supportedMethods: ['heuristic', 'fallback'], }, + sakana: { + avgCharsPerToken: 4, + confidence: 'medium', + supportedMethods: ['heuristic', 'fallback'], + }, ollama: { avgCharsPerToken: 4, confidence: 'low', diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 892fa4d391..24d265a580 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -84,6 +84,7 @@ const nextConfig: NextConfig = { 'isolated-vm', '@e2b/code-interpreter', 'e2b', + '@earendil-works/pi-coding-agent', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], diff --git a/apps/sim/package.json b/apps/sim/package.json index a030bf172a..88e9575836 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -62,6 +62,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 6be9fb6b91..b87307ea81 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -35,6 +35,7 @@ export type AttachmentProvider = | 'xai' | 'deepseek' | 'cerebras' + | 'sakana' export interface PreparedProviderAttachment { file: UserFile @@ -118,7 +119,7 @@ const BEDROCK_DOCUMENT_FORMATS = new Set([ const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp']) const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm']) -const UNSUPPORTED_FILE_PROVIDERS = new Set(['deepseek', 'cerebras']) +const UNSUPPORTED_FILE_PROVIDERS = new Set(['deepseek', 'cerebras', 'sakana']) const PROVIDER_SUPPORTED_LABELS: Record = { openai: 'images and documents through the Responses API input_image/input_file parts', @@ -137,6 +138,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = { xai: 'images through image_url message parts on Grok vision models', deepseek: 'no file attachments in the current API adapter', cerebras: 'no file attachments in the current API adapter', + sakana: 'no file attachments in the current API adapter', } export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null { @@ -156,6 +158,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme if (providerId === 'xai') return 'xai' if (providerId === 'deepseek') return 'deepseek' if (providerId === 'cerebras') return 'cerebras' + if (providerId === 'sakana') return 'sakana' return null } @@ -303,6 +306,7 @@ function isMimeTypeSupportedByProvider( return isImageMimeType(mimeType) case 'deepseek': case 'cerebras': + case 'sakana': return false default: { const _exhaustive: never = provider diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts index ca9af8a07c..b3b16b54bc 100644 --- a/apps/sim/providers/models.test.ts +++ b/apps/sim/providers/models.test.ts @@ -102,3 +102,35 @@ describe('orderModelIdsByReleaseDate', () => { expect([...ordered].sort()).toEqual([...input].sort()) }) }) + +describe('sakana provider definition', () => { + const sakana = PROVIDER_DEFINITIONS.sakana + + it('is registered with fugu as the default model', () => { + expect(sakana).toBeDefined() + expect(sakana.id).toBe('sakana') + expect(sakana.defaultModel).toBe('fugu') + expect(sakana.modelPatterns).toEqual([/^fugu/]) + }) + + it('exposes fugu and fugu-ultra with a 1M context window', () => { + expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra']) + for (const model of sakana.models) { + expect(model.contextWindow).toBe(1000000) + } + }) + + it('prices both models at the documented fugu-ultra ceiling', () => { + for (const model of sakana.models) { + expect(model.pricing.input).toBe(5) + expect(model.pricing.output).toBe(30) + expect(model.pricing.cachedInput).toBe(0.5) + } + }) + + it('routes bare fugu model IDs to the sakana provider', () => { + const baseModels = getBaseModelProviders() + expect(baseModels.fugu).toBe('sakana') + expect(baseModels['fugu-ultra']).toBe('sakana') + }) +}) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 99aaf203cf..3666033af1 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -23,6 +23,7 @@ import { OllamaIcon, OpenAIIcon, OpenRouterIcon, + SakanaIcon, TogetherIcon, VertexIcon, VllmIcon, @@ -2197,6 +2198,47 @@ export const PROVIDER_DEFINITIONS: Record = { }, ], }, + sakana: { + id: 'sakana', + name: 'Sakana AI', + description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + defaultModel: 'fugu', + modelPatterns: [/^fugu/], + icon: SakanaIcon, + color: '#E60000', + capabilities: { + temperature: { min: 0, max: 2 }, + toolUsageControl: true, + }, + models: [ + { + id: 'fugu', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + updatedAt: '2026-06-22', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-06-15', + speedOptimized: true, + }, + { + id: 'fugu-ultra', + pricing: { + input: 5, + cachedInput: 0.5, + output: 30, + updatedAt: '2026-06-22', + }, + capabilities: {}, + contextWindow: 1000000, + releaseDate: '2026-06-15', + recommended: true, + }, + ], + }, mistral: { id: 'mistral', name: 'Mistral AI', diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts new file mode 100644 index 0000000000..af9fd305a4 --- /dev/null +++ b/apps/sim/providers/pi-providers.ts @@ -0,0 +1,29 @@ +/** + * Providers the Pi Coding Agent can run with a single API key. This list is the + * single source of truth for both the cloud env-var mapping (Pi handler) and the + * Pi block's model dropdown (UI), so the block only offers Pi-runnable models. + * + * Excludes providers Pi's key-based flow can't drive: ones needing richer config + * (Vertex OAuth, Bedrock IAM, Azure endpoint+key) and base-URL providers + * (Ollama, vLLM, LiteLLM, Together, Baseten, Ollama Cloud). + */ +export const PI_SUPPORTED_PROVIDER_IDS = [ + 'anthropic', + 'openai', + 'google', + 'xai', + 'deepseek', + 'mistral', + 'groq', + 'cerebras', + 'openrouter', +] as const + +export type PiSupportedProvider = (typeof PI_SUPPORTED_PROVIDER_IDS)[number] + +const PI_SUPPORTED_PROVIDER_SET = new Set(PI_SUPPORTED_PROVIDER_IDS) + +/** Whether the Pi Coding Agent can run a given provider via a single API key. */ +export function isPiSupportedProvider(providerId: string): providerId is PiSupportedProvider { + return PI_SUPPORTED_PROVIDER_SET.has(providerId) +} diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index 5e65e92796..cb7d1a9cd0 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -16,6 +16,7 @@ import { ollamaProvider } from '@/providers/ollama' import { ollamaCloudProvider } from '@/providers/ollama-cloud' import { openaiProvider } from '@/providers/openai' import { openRouterProvider } from '@/providers/openrouter' +import { sakanaProvider } from '@/providers/sakana' import { togetherProvider } from '@/providers/together' import type { ProviderConfig, ProviderId } from '@/providers/types' import { vertexProvider } from '@/providers/vertex' @@ -34,6 +35,7 @@ const providerRegistry: Record = { xai: xAIProvider, cerebras: cerebrasProvider, groq: groqProvider, + sakana: sakanaProvider, vllm: vllmProvider, litellm: litellmProvider, mistral: mistralProvider, diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts new file mode 100644 index 0000000000..3988d3e96d --- /dev/null +++ b/apps/sim/providers/sakana/index.ts @@ -0,0 +1,632 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import OpenAI from 'openai' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { formatMessagesForProvider } from '@/providers/attachments' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' +import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { ProviderError } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + prepareToolsWithUsageControl, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('SakanaProvider') + +const SAKANA_BASE_URL = 'https://api.sakana.ai/v1' + +export const sakanaProvider: ProviderConfig = { + id: 'sakana', + name: 'Sakana AI', + description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API", + version: '1.0.0', + models: getProviderModels('sakana'), + defaultModel: getProviderDefaultModel('sakana'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.apiKey) { + throw new Error('API key is required for Sakana AI') + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const sakana = new OpenAI({ + apiKey: request.apiKey, + baseURL: SAKANA_BASE_URL, + }) + + const allMessages = [] + + if (request.systemPrompt) { + allMessages.push({ + role: 'system', + content: request.systemPrompt, + }) + } + + if (request.context) { + allMessages.push({ + role: 'user', + content: request.context, + }) + } + + if (request.messages) { + allMessages.push(...request.messages) + } + const formattedMessages = formatMessagesForProvider(allMessages, 'sakana') + + const tools = request.tools?.length + ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) + : undefined + + const payload: any = { + model: request.model, + messages: formattedMessages, + } + + if (request.temperature !== undefined) payload.temperature = request.temperature + if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + + const responseFormatPayload = request.responseFormat + ? { + type: 'json_schema' as const, + json_schema: { + name: request.responseFormat.name || 'response_schema', + schema: request.responseFormat.schema || request.responseFormat, + strict: request.responseFormat.strict !== false, + }, + } + : undefined + + let preparedTools: ReturnType | null = null + let hasActiveTools = false + + if (tools?.length) { + preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') + const { tools: filteredTools, toolChoice } = preparedTools + + if (filteredTools?.length && toolChoice) { + payload.tools = filteredTools + payload.tool_choice = toolChoice + hasActiveTools = true + + logger.info('Sakana request configuration:', { + toolCount: filteredTools.length, + toolChoice: + typeof toolChoice === 'string' + ? toolChoice + : toolChoice.type === 'function' + ? `force:${toolChoice.function.name}` + : 'unknown', + model: request.model, + }) + } + } + + // Structured output and tool calling cannot be sent together — OpenAI-compatible + // backends reject a request that carries both `response_format` and active + // `tools`/`tool_choice`. Defer the schema until after the tool loop completes. + const deferResponseFormat = !!responseFormatPayload && hasActiveTools + if (responseFormatPayload && !deferResponseFormat) { + payload.response_format = responseFormatPayload + } + + if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { + logger.info('Using streaming response for Sakana request (no tools)') + + const streamResponse = await sakana.chat.completions.create( + { + ...payload, + stream: true, + stream_options: { include_usage: true }, + }, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: request.model }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + }), + }) + + return streamingResult + } + + const initialCallTime = Date.now() + const originalToolChoice = payload.tool_choice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + let currentResponse = await sakana.chat.completions.create( + payload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + const firstResponseTime = Date.now() - initialCallTime + + let content = currentResponse.choices[0]?.message?.content || '' + + const tokens = { + input: currentResponse.usage?.prompt_tokens || 0, + output: currentResponse.usage?.completion_tokens || 0, + total: currentResponse.usage?.total_tokens || 0, + } + const toolCalls = [] + const toolResults: Record[] = [] + const currentMessages = [...formattedMessages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: request.model, + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + if ( + typeof originalToolChoice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + toolCallsInResponse, + { model: request.model, provider: 'sakana' } + ) + + if (!toolCallsInResponse || toolCallsInResponse.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { + const toolCallStartTime = Date.now() + const toolName = toolCall.function.name + + try { + const toolArgs = JSON.parse(toolCall.function.arguments) + const tool = request.tools?.find((t) => t.id === toolName) + + // Every tool_call in the assistant message must be answered by a matching + // `tool` message, or the next request violates the OpenAI message contract. + // Emit an error result for an unknown tool rather than dropping it. + if (!tool) { + const toolCallEndTime = Date.now() + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: `Tool "${toolName}" is not available`, + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + + return { + toolCall, + toolName, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolCall, + toolName, + toolParams: {}, + result: { + success: false, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + currentMessages.push({ + role: 'assistant', + content: null, + tool_calls: toolCallsInResponse.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }) + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = + settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime: startTime, + endTime: endTime, + duration: duration, + toolCallId: toolCall.id, + }) + + let resultContent: any + if (result.success && result.output) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration: duration, + result: resultContent, + success: result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(resultContent), + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + const nextPayload = { + ...payload, + messages: currentMessages, + } + + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextPayload.tool_choice = { + type: 'function', + function: { name: remainingTools[0] }, + } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextPayload.tool_choice = 'auto' + logger.info('All forced tools have been used, switching to auto tool_choice') + } + } + + const nextModelStartTime = Date.now() + currentResponse = await sakana.chat.completions.create( + nextPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + if ( + typeof nextPayload.tool_choice === 'object' && + currentResponse.choices[0]?.message?.tool_calls + ) { + const toolCallsResponse = currentResponse.choices[0].message.tool_calls + const result = trackForcedToolUsage( + toolCallsResponse, + nextPayload.tool_choice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = result.hasUsedForcedTool + usedForcedTools = result.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.choices[0]?.message?.content) { + content = currentResponse.choices[0].message.content + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + iterationCount++ + } + + if (iterationCount === MAX_TOOL_ITERATIONS) { + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'sakana' } + ) + } + } catch (error) { + logger.error('Error in Sakana request:', { error }) + throw error + } + + if (request.stream) { + logger.info('Using streaming for final Sakana response after tool processing') + + // The tool loop is complete: this final pass only produces the textual answer. + // Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the + // text-only stream adapter would silently drop. + const streamingPayload: any = { + ...payload, + messages: currentMessages, + tool_choice: 'none', + stream: true, + stream_options: { include_usage: true }, + } + if (deferResponseFormat && responseFormatPayload) { + streamingPayload.response_format = responseFormatPayload + streamingPayload.parallel_tool_calls = false + } + + const streamResponse = await sakana.chat.completions.create( + streamingPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) + + const streamingResult = createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + initialTokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + initialCost: { + input: accumulatedCost.input, + output: accumulatedCost.output, + toolCost: undefined as number | undefined, + total: accumulatedCost.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + isStreaming: true, + createStream: ({ output }) => + createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } + + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + }), + }) + + return streamingResult + } + + // Tools were active, so `response_format` was withheld from the loop. Make one final + // tool-free call to obtain the structured response now that the tool work is done. + if (deferResponseFormat && responseFormatPayload) { + logger.info('Applying deferred JSON schema response format after tool processing') + + const finalFormatStartTime = Date.now() + const finalPayload: any = { + ...payload, + messages: currentMessages, + response_format: responseFormatPayload, + tool_choice: 'none', + parallel_tool_calls: false, + } + + currentResponse = await sakana.chat.completions.create( + finalPayload, + request.abortSignal ? { signal: request.abortSignal } : undefined + ) + + const finalFormatEndTime = Date.now() + timeSegments.push({ + type: 'model', + name: request.model, + startTime: finalFormatStartTime, + endTime: finalFormatEndTime, + duration: finalFormatEndTime - finalFormatStartTime, + }) + modelTime += finalFormatEndTime - finalFormatStartTime + + const formattedContent = currentResponse.choices[0]?.message?.content + if (formattedContent) { + content = formattedContent + } + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.prompt_tokens || 0 + tokens.output += currentResponse.usage.completion_tokens || 0 + tokens.total += currentResponse.usage.total_tokens || 0 + } + + enrichLastModelSegmentFromChatCompletions( + timeSegments, + currentResponse, + currentResponse.choices[0]?.message?.tool_calls, + { model: request.model, provider: 'sakana' } + ) + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + return { + content, + model: request.model, + tokens, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime: modelTime, + toolsTime: toolsTime, + firstResponseTime: firstResponseTime, + iterations: iterationCount + 1, + timeSegments: timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in Sakana request:', { + error, + duration: totalDuration, + }) + + throw new ProviderError(toError(error).message, { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + }) + } + }, +} diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts new file mode 100644 index 0000000000..ede98301a1 --- /dev/null +++ b/apps/sim/providers/sakana/utils.ts @@ -0,0 +1,14 @@ +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createOpenAICompatibleStream } from '@/providers/utils' + +/** + * Creates a ReadableStream from a Sakana AI streaming response. + * Uses the shared OpenAI-compatible streaming utility. + */ +export function createReadableStreamFromSakanaStream( + sakanaStream: AsyncIterable, + onComplete?: (content: string, usage: CompletionUsage) => void +): ReadableStream { + return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete) +} diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index f5ab7a812a..d13c236977 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -11,6 +11,7 @@ export type ProviderId = | 'xai' | 'cerebras' | 'groq' + | 'sakana' | 'mistral' | 'ollama' | 'ollama-cloud' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 2c22c865e4..9d8e5dce84 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -151,6 +151,7 @@ export const providers: Record = { xai: buildProviderMetadata('xai'), cerebras: buildProviderMetadata('cerebras'), groq: buildProviderMetadata('groq'), + sakana: buildProviderMetadata('sakana'), mistral: buildProviderMetadata('mistral'), bedrock: buildProviderMetadata('bedrock'), openrouter: buildProviderMetadata('openrouter'), diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts new file mode 100644 index 0000000000..24ab4a0910 --- /dev/null +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env bun + +/** + * Builds the E2B sandbox template that powers the Pi Coding Agent cloud mode. + * + * Layers the `pi` CLI plus git onto E2B's `code-interpreter` base (which already + * ships node + python). The cloud backend runs `pi` and `git clone/commit/push` + * inside this sandbox, so both must resolve on PATH — the global npm bin and + * `/usr/bin` both are. + * + * Usage: + * E2B_API_KEY=... bun run apps/sim/scripts/build-pi-e2b-template.ts [--name ] [--no-cache] + * + * After it builds, set the printed value in the Sim app's .env: + * E2B_PI_TEMPLATE_ID= + * `Sandbox.create` resolves by template name, so use the name (not the ID). + */ + +import { defaultBuildLogger, Template } from '@e2b/code-interpreter' + +const DEFAULT_TEMPLATE_NAME = 'sim-pi' + +const piTemplate = Template() + .fromTemplate('code-interpreter-v1') + // git (+ ssh/certs) for clone/commit/push; ripgrep/fd give the agent fast + // file search from its bash tool; gh enables richer GitHub workflows. + .aptInstall(['git', 'gh', 'openssh-client', 'ca-certificates', 'ripgrep', 'fd-find']) + // The `pi` CLI the cloud backend invokes. + .npmInstall(['@earendil-works/pi-coding-agent'], { g: true }) + +async function main() { + if (!process.env.E2B_API_KEY) { + console.error('E2B_API_KEY is required') + process.exit(1) + } + + const args = process.argv.slice(2) + const nameIdx = args.indexOf('--name') + const templateName = nameIdx !== -1 ? args[nameIdx + 1] : DEFAULT_TEMPLATE_NAME + const skipCache = args.includes('--no-cache') + + console.log(`Building Pi E2B template: ${templateName}`) + console.log(skipCache ? 'Cache: disabled\n' : 'Cache: enabled\n') + + const result = await Template.build(piTemplate, templateName, { + onBuildLogs: defaultBuildLogger(), + ...(skipCache ? { skipCache: true } : {}), + }) + + console.log(`\nDone. Template ID: ${result.templateId}`) + console.log(`Set in .env: E2B_PI_TEMPLATE_ID=${templateName}`) +} + +main().catch((error) => { + console.error('Build failed:', error) + process.exit(1) +}) diff --git a/apps/sim/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts new file mode 100644 index 0000000000..77e4fd8a02 --- /dev/null +++ b/apps/sim/stores/workflows/registry/store.test.ts @@ -0,0 +1,211 @@ +/** + * @vitest-environment node + * + * Focused tests for the registry store's `loadWorkflowState` after the + * workflow-state cache collapse: it hydrates the shared + * `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh, + * `staleTime: 0`) and projects the envelope into the workflow / sub-block / + * variables / deployment stores, guarding against superseded responses. + */ +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + sharedQueryClient: { current: null as unknown }, +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: () => sharedQueryClient.current as QueryClient, +})) + +const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted( + () => ({ + replaceWorkflowState: vi.fn(), + initializeFromWorkflow: vi.fn(), + setVariablesState: vi.fn(), + clearError: vi.fn(), + }) +) + +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: { + getState: () => ({ replaceWorkflowState, blocks: {} }), + setState: vi.fn(), + }, +})) + +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: { + getState: () => ({ initializeFromWorkflow }), + setState: vi.fn(), + }, +})) + +vi.mock('@/stores/variables/store', () => ({ + useVariablesStore: { + getState: () => ({ variables: {} }), + setState: (updater: unknown) => setVariablesState(updater), + }, +})) + +vi.mock('@/stores/operation-queue/store', () => ({ + useOperationQueueStore: { + getState: () => ({ clearError }), + }, +})) + +vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({ + invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/stores/workflows/utils', () => ({ + getUniqueBlockName: vi.fn(), + regenerateBlockIds: vi.fn(), +})) + +vi.mock('@/lib/workflows/autolayout/constants', () => ({ + DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 }, +})) + +vi.mock('@/hooks/queries/deployments', () => ({ + deploymentKeys: { + infos: () => ['deployments', 'info'], + info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''], + }, +})) + +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +function makeEnvelope(overrides: Record = {}) { + return { + id: 'wf-1', + isDeployed: true, + deployedAt: new Date('2026-01-01T00:00:00.000Z'), + isPublicApi: false, + state: { + blocks: { b1: { id: 'b1' } }, + edges: [], + loops: {}, + parallels: {}, + }, + variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } }, + ...overrides, + } +} + +describe('registry store loadWorkflowState (collapsed cache)', () => { + beforeEach(() => { + vi.clearAllMocks() + // The store dispatches an `active-workflow-changed` CustomEvent on the + // window; provide a minimal stub under the node environment. + vi.stubGlobal('window', { dispatchEvent: vi.fn() }) + sharedQueryClient.current = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + // Reset store to a clean state with a workspace scope so loadWorkflowState + // does not bail on the missing-workspace guard. + useWorkflowRegistry.setState({ + activeWorkflowId: null, + error: null, + hydration: { + phase: 'idle', + workspaceId: 'ws-1', + workflowId: null, + requestId: null, + error: null, + }, + }) + }) + + it('projects envelope state, variables, and deployment info into the stores', async () => { + mockRequestJson.mockResolvedValue({ data: makeEnvelope() }) + + await useWorkflowRegistry.getState().loadWorkflowState('wf-1') + + expect(replaceWorkflowState).toHaveBeenCalledTimes(1) + expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({ + currentWorkflowId: 'wf-1', + blocks: { b1: { id: 'b1' } }, + edges: [], + }) + expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } }) + expect(setVariablesState).toHaveBeenCalledTimes(1) + + const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([ + 'deployments', + 'info', + 'wf-1', + ]) + expect(deploymentInfo).toMatchObject({ + isDeployed: true, + isPublicApi: false, + deployedAt: '2026-01-01T00:00:00.000Z', + }) + + expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1') + expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready') + }) + + it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => { + const envelope = makeEnvelope() + mockRequestJson.mockResolvedValue({ data: envelope }) + + await useWorkflowRegistry.getState().loadWorkflowState('wf-1') + + const client = sharedQueryClient.current as QueryClient + const cached = client.getQueryData(workflowKeys.state('wf-1')) + expect(cached).toBeDefined() + expect((cached as { id: string }).id).toBe('wf-1') + + // Exactly one cache entry exists for this endpoint — the shared one. + const stateEntries = client + .getQueryCache() + .findAll({ queryKey: workflowKeys.states() }) + .filter((q) => q.queryKey[2] === 'wf-1') + expect(stateEntries).toHaveLength(1) + }) + + it('re-fetches on every call (staleTime: 0, never served stale)', async () => { + mockRequestJson.mockResolvedValue({ data: makeEnvelope() }) + + await useWorkflowRegistry.getState().loadWorkflowState('wf-1') + await useWorkflowRegistry.getState().loadWorkflowState('wf-1') + + expect(mockRequestJson).toHaveBeenCalledTimes(2) + }) + + it('discards a superseded response via the staleness guard', async () => { + // First load (wf-1) is in-flight; a second load (wf-2) supersedes the + // hydration workflowId, then wf-1 finally resolves. The guard compares the + // current hydration workflowId/requestId against the resolving request and + // must discard the now-stale wf-1 projection. + let resolveFirst: (value: unknown) => void = () => {} + const firstPending = new Promise((resolve) => { + resolveFirst = resolve + }) + + mockRequestJson + .mockImplementationOnce(() => firstPending) + .mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) })) + + const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1') + const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2') + await secondLoad + + expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2') + const projectionsAfterSecond = replaceWorkflowState.mock.calls.length + + resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) }) + await firstLoad + + // The stale wf-1 result must not project again — hydration is now wf-2. + expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond) + expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2') + }) +}) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index c692874997..97a0b55672 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -2,13 +2,13 @@ import { createLogger } from '@sim/logger' import { generateRandomHex } from '@sim/utils/random' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { requestJson } from '@/lib/api/client/request' -import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments' import { deploymentKeys } from '@/hooks/queries/deployments' +import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useOperationQueueStore } from '@/stores/operation-queue/store' import { useVariablesStore } from '@/stores/variables/store' import type { Variable } from '@/stores/variables/types' @@ -98,8 +98,10 @@ export const useWorkflowRegistry = create()( })) try { - const { data: workflowData } = await requestJson(getWorkflowStateContract, { - params: { id: workflowId }, + const workflowData = await getQueryClient().fetchQuery({ + queryKey: workflowKeys.state(workflowId), + queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal), + staleTime: 0, }) const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 28eb17aaeb..1f7e554c32 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1578,6 +1578,9 @@ async function executeToolRequest( } const headers = new Headers(requestParams.headers) + if (!headers.has('User-Agent')) { + headers.set('User-Agent', 'Sim') + } await addInternalAuthIfNeeded( headers, isInternalRoute, diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 3917f341d1..a490e75a41 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -56,7 +56,7 @@ export default defineConfig({ dirs: ['./background'], ...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}), build: { - external: ['isolated-vm'], + external: ['isolated-vm', '@earendil-works/pi-coding-agent'], extensions: [ additionalFiles({ files: [ @@ -67,7 +67,13 @@ export default defineConfig({ ], }), additionalPackages({ - packages: ['unpdf', 'isolated-vm', 'react-dom', '@react-email/render'], + packages: [ + 'unpdf', + 'isolated-vm', + 'react-dom', + '@react-email/render', + '@earendil-works/pi-coding-agent', + ], }), ], }, diff --git a/bun.lock b/bun.lock index ec76f4c62d..e9bb4f978b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -54,6 +53,10 @@ "typescript": "^5.8.2", }, }, + "apps/pii": { + "name": "@sim/pii", + "version": "0.0.0", + }, "apps/realtime": { "name": "@sim/realtime", "version": "0.1.0", @@ -118,6 +121,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", @@ -835,6 +839,14 @@ "@e2b/code-interpreter": ["@e2b/code-interpreter@2.6.0", "", { "dependencies": { "e2b": "^2.28.0" } }, "sha512-Xp3pajVf2LQ2rcXZynE/jYfZw4yyKTZM/LkVPB2vSqVft87GxqEUFDfWxssb811B4571uAMfJxKSHHIa8tMprA=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.4", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.4", "@earendil-works/pi-ai": "^0.79.4", "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="], + "@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1007,6 +1019,28 @@ "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -1017,6 +1051,8 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -1415,6 +1451,8 @@ "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], "@sim/auth": ["@sim/auth@workspace:packages/auth"], @@ -1423,6 +1461,8 @@ "@sim/logger": ["@sim/logger@workspace:packages/logger"], + "@sim/pii": ["@sim/pii@workspace:apps/pii"], + "@sim/platform-authz": ["@sim/platform-authz@workspace:packages/platform-authz"], "@sim/realtime": ["@sim/realtime@workspace:apps/realtime"], @@ -1797,6 +1837,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -2253,6 +2295,8 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -2573,8 +2617,12 @@ "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -2611,6 +2659,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], "imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="], @@ -3139,6 +3189,8 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -3161,6 +3213,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -3259,6 +3313,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], @@ -3417,6 +3473,8 @@ "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -3477,7 +3535,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -3509,7 +3567,7 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "sim": ["sim@workspace:apps/sim"], @@ -3715,6 +3773,8 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -3923,6 +3983,26 @@ "@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "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-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@earendil-works/pi-ai/@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@earendil-works/pi-ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@earendil-works/pi-ai/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@earendil-works/pi-ai/openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "@earendil-works/pi-coding-agent/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "@earendil-works/pi-coding-agent/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], + + "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], @@ -4311,6 +4391,8 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -4325,6 +4407,8 @@ "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fumadocs-core/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], @@ -4373,6 +4457,8 @@ "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "jsonwebtoken/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -4395,6 +4481,8 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "make-dir/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -4415,6 +4503,8 @@ "next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "node-abi/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], @@ -4435,6 +4525,8 @@ "ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -4475,14 +4567,14 @@ "restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "rimraf/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=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], @@ -4561,6 +4653,12 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg=="], + + "@earendil-works/pi-ai/@google/genai/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -4909,6 +5007,8 @@ "next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "next/sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -5021,6 +5121,8 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], @@ -5079,8 +5181,6 @@ "log-update/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "openai/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -5089,6 +5189,8 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], @@ -5109,6 +5211,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5151,6 +5255,8 @@ "lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "lint-staged/listr2/log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 67eb5f02c7..ff0ea1ccc2 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -114,16 +114,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-v # apps/sim/lib/execution/sandbox/bundles/build.ts to regenerate. COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/sandbox/bundles ./apps/sim/lib/execution/sandbox/bundles -# Guardrails setup with pip caching -COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt -COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/validate_pii.py ./apps/sim/lib/guardrails/validate_pii.py - -# Install Python dependencies with pip cache mount for faster rebuilds -RUN --mount=type=cache,target=/root/.cache/pip \ - python3 -m venv ./apps/sim/lib/guardrails/venv && \ - ./apps/sim/lib/guardrails/venv/bin/pip install --upgrade pip && \ - ./apps/sim/lib/guardrails/venv/bin/pip install -r ./apps/sim/lib/guardrails/requirements.txt && \ - chown -R nextjs:nodejs /app/apps/sim/lib/guardrails +# Guardrails PII runs in dedicated Presidio sidecar containers (analyzer + +# anonymizer), reached over localhost — no Python/Presidio in this image. # Create .next/cache directory with correct ownership RUN mkdir -p apps/sim/.next/cache && \ diff --git a/docker/pii.Dockerfile b/docker/pii.Dockerfile new file mode 100644 index 0000000000..96153208f5 --- /dev/null +++ b/docker/pii.Dockerfile @@ -0,0 +1,50 @@ +# ======================================== +# Combined Presidio service (analyzer + anonymizer) on a single port (3000) +# ======================================== +FROM python:3.12-slim-bookworm AS base + +WORKDIR /app + +# build-essential for any sdist that compiles native deps (e.g. blis/thinc). +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Pinned Python deps. Separate layer so source edits don't reinstall them. +COPY apps/pii/requirements.txt ./requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt + +# Pinned spaCy models (en + es/it/pl/fi, ~2.2GB total). Downloaded with +# retries/resume — the large wheels truncate on flaky networks if pip fetches +# the URLs directly. +ARG SPACY_MODELS="en_core_web_lg-3.8.0 es_core_news_lg-3.8.0 it_core_news_lg-3.8.0 pl_core_news_lg-3.8.0 fi_core_news_lg-3.8.0" +RUN --mount=type=cache,target=/root/.cache/pip \ + for model in ${SPACY_MODELS}; do \ + whl="${model}-py3-none-any.whl"; \ + curl -fL --retry 5 --retry-delay 5 --retry-all-errors -C - \ + -o "/tmp/${whl}" \ + "https://github.com/explosion/spacy-models/releases/download/${model}/${whl}" || exit 1; \ + done && \ + pip install /tmp/*.whl && \ + rm /tmp/*.whl + +COPY apps/pii/server.py ./server.py + +RUN groupadd -g 1001 pii && \ + useradd -u 1001 -g pii pii && \ + chown -R pii:pii /app +USER pii + +# Listen on 5001. In the ECS task all containers share one network namespace +# (awsvpc) and the app owns 3000, so this sidecar must not use 3000. +EXPOSE 5001 + +# start-period is generous: five large spaCy models load at import before +# /health responds. Tune against measured cold-start once built. +HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \ + CMD curl -fsS http://localhost:5001/health || exit 1 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5001"] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 8718883751..f066c19ad4 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1077,6 +1077,8 @@ export interface PiiRedactionRule { entityTypes: string[] /** `null` = all workspaces; otherwise the single targeted workspace. */ workspaceId: string | null + /** Language whose Presidio recognizers apply (e.g. 'en', 'es'); defaults to English. */ + language?: string } /** diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 09744c629b..17f0a25fa2 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 859, - zodRoutes: 859, + totalRoutes: 860, + zodRoutes: 860, nonZodRoutes: 0, } as const