@useatlas/create 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -18
- package/index.ts +191 -31
- package/package.json +1 -1
- package/templates/docker/.env.example +3 -3
- package/templates/docker/Dockerfile.sidecar +28 -0
- package/templates/docker/bin/__tests__/plugin-cli.test.ts +9 -9
- package/templates/docker/bin/atlas.ts +108 -44
- package/templates/docker/data/demo-semantic/catalog.yml +51 -27
- package/templates/docker/data/demo-semantic/entities/accounts.yml +95 -103
- package/templates/docker/data/demo-semantic/entities/companies.yml +88 -152
- package/templates/docker/data/demo-semantic/entities/people.yml +82 -95
- package/templates/docker/data/demo-semantic/glossary.yml +104 -8
- package/templates/docker/data/demo-semantic/metrics/accounts.yml +62 -23
- package/templates/docker/data/demo-semantic/metrics/companies.yml +52 -78
- package/templates/docker/docker-compose.yml +1 -1
- package/templates/docker/docs/deploy.md +2 -39
- package/templates/docker/package.json +17 -1
- package/templates/docker/semantic/catalog.yml +62 -3
- package/templates/docker/semantic/entities/accounts.yml +162 -0
- package/templates/docker/semantic/entities/companies.yml +143 -0
- package/templates/docker/semantic/entities/people.yml +132 -0
- package/templates/docker/semantic/glossary.yml +116 -4
- package/templates/docker/semantic/metrics/accounts.yml +77 -0
- package/templates/docker/semantic/metrics/companies.yml +63 -0
- package/templates/docker/sidecar/Dockerfile +5 -6
- package/templates/docker/sidecar/railway.json +1 -2
- package/templates/docker/src/api/__tests__/admin.test.ts +7 -7
- package/templates/docker/src/api/__tests__/health-plugin.test.ts +7 -0
- package/templates/docker/src/api/__tests__/health.test.ts +30 -8
- package/templates/docker/src/api/routes/admin.ts +549 -8
- package/templates/docker/src/api/routes/chat.ts +5 -20
- package/templates/docker/src/api/routes/health.ts +39 -27
- package/templates/docker/src/api/routes/openapi.ts +1329 -74
- package/templates/docker/src/api/routes/query.ts +2 -1
- package/templates/docker/src/api/server.ts +27 -0
- package/templates/docker/src/app/api/[...route]/route.ts +2 -2
- package/templates/docker/src/app/globals.css +13 -12
- package/templates/docker/src/app/layout.tsx +9 -2
- package/templates/docker/src/components/ui/alert-dialog.tsx +196 -0
- package/templates/docker/src/components/ui/badge.tsx +48 -0
- package/templates/docker/src/components/ui/button.tsx +64 -0
- package/templates/docker/src/components/ui/card.tsx +92 -0
- package/templates/docker/src/components/ui/collapsible.tsx +33 -0
- package/templates/docker/src/components/ui/command.tsx +184 -0
- package/templates/docker/src/components/ui/dialog.tsx +158 -0
- package/templates/docker/src/components/ui/dropdown-menu.tsx +257 -0
- package/templates/docker/src/components/ui/input.tsx +21 -0
- package/templates/docker/src/components/ui/scroll-area.tsx +58 -0
- package/templates/docker/src/components/ui/select.tsx +190 -0
- package/templates/docker/src/components/ui/separator.tsx +28 -0
- package/templates/docker/src/components/ui/sheet.tsx +143 -0
- package/templates/docker/src/components/ui/sidebar.tsx +726 -0
- package/templates/docker/src/components/ui/skeleton.tsx +13 -0
- package/templates/docker/src/components/ui/table.tsx +116 -0
- package/templates/docker/src/components/ui/tabs.tsx +91 -0
- package/templates/docker/src/components/ui/toggle-group.tsx +83 -0
- package/templates/docker/src/components/ui/toggle.tsx +47 -0
- package/templates/docker/src/components/ui/tooltip.tsx +57 -0
- package/templates/docker/src/hooks/use-mobile.ts +19 -0
- package/templates/docker/src/lib/__tests__/agent-cache.test.ts +2 -0
- package/templates/docker/src/lib/__tests__/agent-dialect.test.ts +17 -0
- package/templates/docker/src/lib/__tests__/agent-health-annotations.test.ts +2 -0
- package/templates/docker/src/lib/__tests__/agent-integration.test.ts +2 -0
- package/templates/docker/src/lib/__tests__/config.test.ts +69 -19
- package/templates/docker/src/lib/__tests__/plugin-aware-validation.test.ts +321 -0
- package/templates/docker/src/lib/__tests__/providers.test.ts +32 -1
- package/templates/docker/src/lib/__tests__/startup-actions.test.ts +9 -0
- package/templates/docker/src/lib/__tests__/startup-first-run.test.ts +429 -0
- package/templates/docker/src/lib/__tests__/startup.test.ts +5 -0
- package/templates/docker/src/lib/agent-query.ts +5 -23
- package/templates/docker/src/lib/agent.ts +32 -112
- package/templates/docker/src/lib/auth/__tests__/migrate.test.ts +5 -3
- package/templates/docker/src/lib/auth/middleware.ts +30 -4
- package/templates/docker/src/lib/auth/migrate.ts +97 -0
- package/templates/docker/src/lib/auth/server.ts +12 -1
- package/templates/docker/src/lib/config.ts +37 -39
- package/templates/docker/src/lib/db/__tests__/connection.test.ts +89 -14
- package/templates/docker/src/lib/db/__tests__/registry-health.test.ts +1 -18
- package/templates/docker/src/lib/db/__tests__/registry-pool-limits.test.ts +0 -19
- package/templates/docker/src/lib/db/__tests__/registry.test.ts +11 -208
- package/templates/docker/src/lib/db/connection.ts +87 -265
- package/templates/docker/src/lib/db/internal.ts +6 -1
- package/templates/docker/src/lib/plugins/__tests__/hooks-integration.test.ts +3 -1
- package/templates/docker/src/lib/plugins/__tests__/hooks.test.ts +2 -2
- package/templates/docker/src/lib/plugins/__tests__/migrate.test.ts +355 -1
- package/templates/docker/src/lib/plugins/__tests__/registry.test.ts +32 -5
- package/templates/docker/src/lib/plugins/__tests__/wiring.test.ts +228 -14
- package/templates/docker/src/lib/plugins/index.ts +4 -1
- package/templates/docker/src/lib/plugins/migrate.ts +103 -0
- package/templates/docker/src/lib/plugins/registry.ts +12 -6
- package/templates/docker/src/lib/plugins/wiring.ts +113 -4
- package/templates/docker/src/lib/providers.ts +6 -1
- package/templates/docker/src/lib/security.ts +24 -0
- package/templates/docker/src/lib/semantic.ts +2 -0
- package/templates/docker/src/lib/sidecar-types.ts +12 -1
- package/templates/docker/src/lib/startup.ts +71 -101
- package/templates/docker/src/lib/tools/__tests__/custom-validation.test.ts +2 -0
- package/templates/docker/src/lib/tools/__tests__/explore-nsjail.test.ts +32 -18
- package/templates/docker/src/lib/tools/__tests__/explore-plugin.test.ts +14 -14
- package/templates/docker/src/lib/tools/__tests__/explore-sidecar.test.ts +5 -3
- package/templates/docker/src/lib/tools/__tests__/python-nsjail.test.ts +515 -0
- package/templates/docker/src/lib/tools/__tests__/python-sandbox.test.ts +397 -0
- package/templates/docker/src/lib/tools/__tests__/python-sidecar.test.ts +365 -0
- package/templates/docker/src/lib/tools/__tests__/python.test.ts +331 -0
- package/templates/docker/src/lib/tools/__tests__/registry-actions.test.ts +1 -13
- package/templates/docker/src/lib/tools/__tests__/registry.test.ts +38 -31
- package/templates/docker/src/lib/tools/__tests__/sql-audit.test.ts +2 -0
- package/templates/docker/src/lib/tools/__tests__/sql-connection-whitelist.test.ts +2 -0
- package/templates/docker/src/lib/tools/__tests__/sql-ratelimit.test.ts +2 -0
- package/templates/docker/src/lib/tools/__tests__/sql.test.ts +5 -308
- package/templates/docker/src/lib/tools/explore-nsjail.ts +17 -12
- package/templates/docker/src/lib/tools/explore-sidecar.ts +25 -0
- package/templates/docker/src/lib/tools/explore.ts +28 -32
- package/templates/docker/src/lib/tools/python-nsjail.ts +396 -0
- package/templates/docker/src/lib/tools/python-sandbox.ts +476 -0
- package/templates/docker/src/lib/tools/python-sidecar.ts +150 -0
- package/templates/docker/src/lib/tools/python.ts +367 -0
- package/templates/docker/src/lib/tools/registry.ts +49 -22
- package/templates/docker/src/lib/tools/sql.ts +88 -88
- package/templates/docker/src/types/vercel-sandbox.d.ts +7 -0
- package/templates/docker/src/ui/components/admin/admin-layout.tsx +77 -8
- package/templates/docker/src/ui/components/admin/admin-sidebar.tsx +25 -17
- package/templates/docker/src/ui/components/admin/change-password-dialog.tsx +128 -0
- package/templates/docker/src/ui/components/admin/entity-detail.tsx +3 -3
- package/templates/docker/src/ui/components/admin/semantic-file-tree.tsx +159 -0
- package/templates/docker/src/ui/components/atlas-chat.tsx +64 -12
- package/templates/docker/src/ui/components/chart/result-chart.tsx +25 -15
- package/templates/docker/src/ui/components/chat/markdown.tsx +88 -42
- package/templates/docker/src/ui/components/chat/python-result-card.tsx +244 -0
- package/templates/docker/src/ui/components/chat/sql-block.tsx +39 -15
- package/templates/docker/src/ui/components/chat/sql-result-card.tsx +6 -1
- package/templates/docker/src/ui/components/chat/tool-part.tsx +12 -3
- package/templates/docker/src/ui/components/chat/typing-indicator.tsx +5 -2
- package/templates/docker/src/ui/components/conversations/conversation-item.tsx +25 -20
- package/templates/docker/src/ui/context.tsx +1 -1
- package/templates/docker/src/ui/hooks/use-conversations.ts +3 -3
- package/templates/docker/src/ui/hooks/use-dark-mode.ts +17 -10
- package/templates/docker/tsconfig.json +2 -2
- package/templates/nextjs-standalone/.env.example +1 -1
- package/templates/nextjs-standalone/bin/__tests__/plugin-cli.test.ts +9 -9
- package/templates/nextjs-standalone/bin/atlas.ts +108 -44
- package/templates/nextjs-standalone/data/demo-semantic/catalog.yml +51 -27
- package/templates/nextjs-standalone/data/demo-semantic/entities/accounts.yml +95 -103
- package/templates/nextjs-standalone/data/demo-semantic/entities/companies.yml +88 -152
- package/templates/nextjs-standalone/data/demo-semantic/entities/people.yml +82 -95
- package/templates/nextjs-standalone/data/demo-semantic/glossary.yml +104 -8
- package/templates/nextjs-standalone/data/demo-semantic/metrics/accounts.yml +62 -23
- package/templates/nextjs-standalone/data/demo-semantic/metrics/companies.yml +52 -78
- package/templates/nextjs-standalone/docs/deploy.md +2 -39
- package/templates/nextjs-standalone/package.json +11 -2
- package/templates/nextjs-standalone/scripts/migrate-auth.ts +25 -0
- package/templates/nextjs-standalone/scripts/seed-demo.ts +94 -0
- package/templates/nextjs-standalone/semantic/catalog.yml +62 -3
- package/templates/nextjs-standalone/semantic/entities/accounts.yml +162 -0
- package/templates/nextjs-standalone/semantic/entities/companies.yml +143 -0
- package/templates/nextjs-standalone/semantic/entities/people.yml +132 -0
- package/templates/nextjs-standalone/semantic/glossary.yml +116 -4
- package/templates/nextjs-standalone/semantic/metrics/accounts.yml +77 -0
- package/templates/nextjs-standalone/semantic/metrics/companies.yml +63 -0
- package/templates/nextjs-standalone/src/api/__tests__/admin.test.ts +7 -7
- package/templates/nextjs-standalone/src/api/__tests__/health-plugin.test.ts +7 -0
- package/templates/nextjs-standalone/src/api/__tests__/health.test.ts +30 -8
- package/templates/nextjs-standalone/src/api/routes/admin.ts +549 -8
- package/templates/nextjs-standalone/src/api/routes/chat.ts +5 -20
- package/templates/nextjs-standalone/src/api/routes/health.ts +39 -27
- package/templates/nextjs-standalone/src/api/routes/openapi.ts +1329 -74
- package/templates/nextjs-standalone/src/api/routes/query.ts +2 -1
- package/templates/nextjs-standalone/src/api/server.ts +27 -0
- package/templates/nextjs-standalone/src/app/api/[...route]/route.ts +2 -2
- package/templates/nextjs-standalone/src/app/globals.css +13 -12
- package/templates/nextjs-standalone/src/app/layout.tsx +9 -2
- package/templates/nextjs-standalone/src/components/ui/alert-dialog.tsx +196 -0
- package/templates/nextjs-standalone/src/components/ui/badge.tsx +48 -0
- package/templates/nextjs-standalone/src/components/ui/button.tsx +64 -0
- package/templates/nextjs-standalone/src/components/ui/card.tsx +92 -0
- package/templates/nextjs-standalone/src/components/ui/collapsible.tsx +33 -0
- package/templates/nextjs-standalone/src/components/ui/command.tsx +184 -0
- package/templates/nextjs-standalone/src/components/ui/dialog.tsx +158 -0
- package/templates/nextjs-standalone/src/components/ui/dropdown-menu.tsx +257 -0
- package/templates/nextjs-standalone/src/components/ui/input.tsx +21 -0
- package/templates/nextjs-standalone/src/components/ui/scroll-area.tsx +58 -0
- package/templates/nextjs-standalone/src/components/ui/select.tsx +190 -0
- package/templates/nextjs-standalone/src/components/ui/separator.tsx +28 -0
- package/templates/nextjs-standalone/src/components/ui/sheet.tsx +143 -0
- package/templates/nextjs-standalone/src/components/ui/sidebar.tsx +726 -0
- package/templates/nextjs-standalone/src/components/ui/skeleton.tsx +13 -0
- package/templates/nextjs-standalone/src/components/ui/table.tsx +116 -0
- package/templates/nextjs-standalone/src/components/ui/tabs.tsx +91 -0
- package/templates/nextjs-standalone/src/components/ui/toggle-group.tsx +83 -0
- package/templates/nextjs-standalone/src/components/ui/toggle.tsx +47 -0
- package/templates/nextjs-standalone/src/components/ui/tooltip.tsx +57 -0
- package/templates/nextjs-standalone/src/hooks/use-mobile.ts +19 -0
- package/templates/nextjs-standalone/src/lib/__tests__/agent-cache.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/__tests__/agent-dialect.test.ts +17 -0
- package/templates/nextjs-standalone/src/lib/__tests__/agent-health-annotations.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/__tests__/agent-integration.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/__tests__/config.test.ts +69 -19
- package/templates/nextjs-standalone/src/lib/__tests__/plugin-aware-validation.test.ts +321 -0
- package/templates/nextjs-standalone/src/lib/__tests__/providers.test.ts +32 -1
- package/templates/nextjs-standalone/src/lib/__tests__/startup-actions.test.ts +9 -0
- package/templates/nextjs-standalone/src/lib/__tests__/startup-first-run.test.ts +429 -0
- package/templates/nextjs-standalone/src/lib/__tests__/startup.test.ts +5 -0
- package/templates/nextjs-standalone/src/lib/agent-query.ts +5 -23
- package/templates/nextjs-standalone/src/lib/agent.ts +32 -112
- package/templates/nextjs-standalone/src/lib/auth/__tests__/migrate.test.ts +5 -3
- package/templates/nextjs-standalone/src/lib/auth/middleware.ts +30 -4
- package/templates/nextjs-standalone/src/lib/auth/migrate.ts +97 -0
- package/templates/nextjs-standalone/src/lib/auth/server.ts +12 -1
- package/templates/nextjs-standalone/src/lib/config.ts +37 -39
- package/templates/nextjs-standalone/src/lib/db/__tests__/connection.test.ts +89 -14
- package/templates/nextjs-standalone/src/lib/db/__tests__/registry-health.test.ts +1 -18
- package/templates/nextjs-standalone/src/lib/db/__tests__/registry-pool-limits.test.ts +0 -19
- package/templates/nextjs-standalone/src/lib/db/__tests__/registry.test.ts +11 -208
- package/templates/nextjs-standalone/src/lib/db/connection.ts +87 -265
- package/templates/nextjs-standalone/src/lib/db/internal.ts +6 -1
- package/templates/nextjs-standalone/src/lib/plugins/__tests__/hooks-integration.test.ts +3 -1
- package/templates/nextjs-standalone/src/lib/plugins/__tests__/hooks.test.ts +2 -2
- package/templates/nextjs-standalone/src/lib/plugins/__tests__/migrate.test.ts +355 -1
- package/templates/nextjs-standalone/src/lib/plugins/__tests__/registry.test.ts +32 -5
- package/templates/nextjs-standalone/src/lib/plugins/__tests__/wiring.test.ts +228 -14
- package/templates/nextjs-standalone/src/lib/plugins/index.ts +4 -1
- package/templates/nextjs-standalone/src/lib/plugins/migrate.ts +103 -0
- package/templates/nextjs-standalone/src/lib/plugins/registry.ts +12 -6
- package/templates/nextjs-standalone/src/lib/plugins/wiring.ts +113 -4
- package/templates/nextjs-standalone/src/lib/providers.ts +6 -1
- package/templates/nextjs-standalone/src/lib/security.ts +24 -0
- package/templates/nextjs-standalone/src/lib/semantic.ts +2 -0
- package/templates/nextjs-standalone/src/lib/sidecar-types.ts +12 -1
- package/templates/nextjs-standalone/src/lib/startup.ts +71 -101
- package/templates/nextjs-standalone/src/lib/tools/__tests__/custom-validation.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/explore-nsjail.test.ts +32 -18
- package/templates/nextjs-standalone/src/lib/tools/__tests__/explore-plugin.test.ts +14 -14
- package/templates/nextjs-standalone/src/lib/tools/__tests__/explore-sidecar.test.ts +5 -3
- package/templates/nextjs-standalone/src/lib/tools/__tests__/python-nsjail.test.ts +515 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/python-sandbox.test.ts +397 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/python-sidecar.test.ts +365 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/python.test.ts +331 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/registry-actions.test.ts +1 -13
- package/templates/nextjs-standalone/src/lib/tools/__tests__/registry.test.ts +38 -31
- package/templates/nextjs-standalone/src/lib/tools/__tests__/sql-audit.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/sql-connection-whitelist.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/sql-ratelimit.test.ts +2 -0
- package/templates/nextjs-standalone/src/lib/tools/__tests__/sql.test.ts +5 -308
- package/templates/nextjs-standalone/src/lib/tools/explore-nsjail.ts +17 -12
- package/templates/nextjs-standalone/src/lib/tools/explore-sidecar.ts +25 -0
- package/templates/nextjs-standalone/src/lib/tools/explore.ts +28 -32
- package/templates/nextjs-standalone/src/lib/tools/python-nsjail.ts +396 -0
- package/templates/nextjs-standalone/src/lib/tools/python-sandbox.ts +476 -0
- package/templates/nextjs-standalone/src/lib/tools/python-sidecar.ts +150 -0
- package/templates/nextjs-standalone/src/lib/tools/python.ts +367 -0
- package/templates/nextjs-standalone/src/lib/tools/registry.ts +49 -22
- package/templates/nextjs-standalone/src/lib/tools/sql.ts +88 -88
- package/templates/nextjs-standalone/src/ui/components/admin/admin-layout.tsx +77 -8
- package/templates/nextjs-standalone/src/ui/components/admin/admin-sidebar.tsx +25 -17
- package/templates/nextjs-standalone/src/ui/components/admin/change-password-dialog.tsx +128 -0
- package/templates/nextjs-standalone/src/ui/components/admin/entity-detail.tsx +3 -3
- package/templates/nextjs-standalone/src/ui/components/admin/semantic-file-tree.tsx +159 -0
- package/templates/nextjs-standalone/src/ui/components/atlas-chat.tsx +64 -12
- package/templates/nextjs-standalone/src/ui/components/chart/result-chart.tsx +25 -15
- package/templates/nextjs-standalone/src/ui/components/chat/markdown.tsx +88 -42
- package/templates/nextjs-standalone/src/ui/components/chat/python-result-card.tsx +244 -0
- package/templates/nextjs-standalone/src/ui/components/chat/sql-block.tsx +39 -15
- package/templates/nextjs-standalone/src/ui/components/chat/sql-result-card.tsx +6 -1
- package/templates/nextjs-standalone/src/ui/components/chat/tool-part.tsx +12 -3
- package/templates/nextjs-standalone/src/ui/components/chat/typing-indicator.tsx +5 -2
- package/templates/nextjs-standalone/src/ui/components/conversations/conversation-item.tsx +25 -20
- package/templates/nextjs-standalone/src/ui/context.tsx +1 -1
- package/templates/nextjs-standalone/src/ui/hooks/use-conversations.ts +3 -3
- package/templates/nextjs-standalone/src/ui/hooks/use-dark-mode.ts +17 -10
- package/templates/nextjs-standalone/tsconfig.json +0 -1
- package/templates/nextjs-standalone/vercel.json +4 -1
- package/templates/docker/render.yaml +0 -34
- package/templates/docker/semantic/entities/.gitkeep +0 -0
- package/templates/docker/semantic/metrics/.gitkeep +0 -0
- package/templates/docker/src/lib/db/__tests__/duckdb.test.ts +0 -141
- package/templates/docker/src/lib/db/__tests__/salesforce.test.ts +0 -339
- package/templates/docker/src/lib/db/__tests__/snowflake.test.ts +0 -217
- package/templates/docker/src/lib/db/duckdb.ts +0 -122
- package/templates/docker/src/lib/db/salesforce.ts +0 -342
- package/templates/docker/src/lib/tools/__tests__/salesforce-tool.test.ts +0 -154
- package/templates/docker/src/lib/tools/__tests__/soql-validation.test.ts +0 -303
- package/templates/docker/src/lib/tools/__tests__/sql-duckdb.test.ts +0 -233
- package/templates/docker/src/lib/tools/salesforce.ts +0 -138
- package/templates/docker/src/lib/tools/soql-validation.ts +0 -172
- package/templates/nextjs-standalone/semantic/entities/.gitkeep +0 -0
- package/templates/nextjs-standalone/semantic/metrics/.gitkeep +0 -0
- package/templates/nextjs-standalone/src/lib/db/__tests__/duckdb.test.ts +0 -141
- package/templates/nextjs-standalone/src/lib/db/__tests__/salesforce.test.ts +0 -339
- package/templates/nextjs-standalone/src/lib/db/__tests__/snowflake.test.ts +0 -217
- package/templates/nextjs-standalone/src/lib/db/duckdb.ts +0 -122
- package/templates/nextjs-standalone/src/lib/db/salesforce.ts +0 -342
- package/templates/nextjs-standalone/src/lib/tools/__tests__/salesforce-tool.test.ts +0 -154
- package/templates/nextjs-standalone/src/lib/tools/__tests__/soql-validation.test.ts +0 -303
- package/templates/nextjs-standalone/src/lib/tools/__tests__/sql-duckdb.test.ts +0 -233
- package/templates/nextjs-standalone/src/lib/tools/salesforce.ts +0 -138
- package/templates/nextjs-standalone/src/lib/tools/soql-validation.ts +0 -172
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Sandbox backend for the Python execution tool.
|
|
3
|
+
*
|
|
4
|
+
* Uses @vercel/sandbox with runtime: "python3.13" to run Python code
|
|
5
|
+
* in an ephemeral Firecracker microVM. Adapted from the explore-sandbox.ts
|
|
6
|
+
* pattern but with a different lifecycle (lazy creation, package installation):
|
|
7
|
+
* - Creates a Python 3.13 sandbox (initially allow-all for pip install)
|
|
8
|
+
* - Installs data science packages, then locks down to deny-all
|
|
9
|
+
* - Writes wrapper + user code to the sandbox filesystem
|
|
10
|
+
* - Injects data via a JSON file (runCommand does not support stdin piping)
|
|
11
|
+
* - Collects charts and structured output via result marker
|
|
12
|
+
* - Unlike explore-sandbox.ts, the sandbox is created lazily and reused
|
|
13
|
+
* across calls (no explicit close/stop lifecycle — invalidation stops
|
|
14
|
+
* the old sandbox and creates a fresh one on next call)
|
|
15
|
+
*
|
|
16
|
+
* Only loaded when ATLAS_RUNTIME=vercel or running on the Vercel platform.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PythonBackend, PythonResult } from "./python";
|
|
20
|
+
import { randomUUID } from "crypto";
|
|
21
|
+
import { createLogger } from "@atlas/api/lib/logger";
|
|
22
|
+
import { SENSITIVE_PATTERNS } from "@atlas/api/lib/security";
|
|
23
|
+
|
|
24
|
+
const log = createLogger("python-sandbox");
|
|
25
|
+
|
|
26
|
+
/** Default Python execution timeout in ms. */
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/** Maximum bytes to read from stdout/stderr (1 MB). */
|
|
30
|
+
const MAX_OUTPUT = 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/** Packages to install in the sandbox. */
|
|
33
|
+
const DATA_SCIENCE_PACKAGES = [
|
|
34
|
+
"pandas",
|
|
35
|
+
"numpy",
|
|
36
|
+
"matplotlib",
|
|
37
|
+
"scipy",
|
|
38
|
+
"scikit-learn",
|
|
39
|
+
"statsmodels",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Python wrapper script — adapted from the nsjail PYTHON_WRAPPER.
|
|
44
|
+
*
|
|
45
|
+
* Key difference: data is injected via a JSON file (argv[2]) instead of
|
|
46
|
+
* stdin, since Vercel Sandbox's runCommand does not support stdin piping.
|
|
47
|
+
*
|
|
48
|
+
* Reads user code from argv[1], executes in a restricted Python namespace,
|
|
49
|
+
* collects charts + structured output via result marker.
|
|
50
|
+
*/
|
|
51
|
+
const PYTHON_WRAPPER = `
|
|
52
|
+
import sys, json, io, base64, glob, os, ast
|
|
53
|
+
|
|
54
|
+
_marker = os.environ["ATLAS_RESULT_MARKER"]
|
|
55
|
+
_chart_dir = os.environ.get("ATLAS_CHART_DIR", "/tmp/charts")
|
|
56
|
+
|
|
57
|
+
# --- Import guard ---
|
|
58
|
+
_BLOCKED_MODULES = {
|
|
59
|
+
"subprocess", "os", "socket", "shutil", "sys", "ctypes", "importlib",
|
|
60
|
+
"code", "signal", "multiprocessing", "threading", "pty", "fcntl",
|
|
61
|
+
"termios", "resource", "posixpath",
|
|
62
|
+
"http", "urllib", "requests", "httpx", "aiohttp", "webbrowser",
|
|
63
|
+
"pickle", "tempfile", "pathlib",
|
|
64
|
+
}
|
|
65
|
+
_BLOCKED_BUILTINS = {
|
|
66
|
+
"compile", "exec", "eval", "__import__", "open", "breakpoint",
|
|
67
|
+
"getattr", "globals", "locals", "vars", "dir", "delattr", "setattr",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_user_code = open(sys.argv[1]).read()
|
|
71
|
+
try:
|
|
72
|
+
_tree = ast.parse(_user_code)
|
|
73
|
+
except SyntaxError as e:
|
|
74
|
+
print(_marker + json.dumps({"success": False, "error": f"SyntaxError: {e.msg} (line {e.lineno})"}))
|
|
75
|
+
sys.exit(0)
|
|
76
|
+
|
|
77
|
+
_blocked = None
|
|
78
|
+
for _node in ast.walk(_tree):
|
|
79
|
+
if _blocked:
|
|
80
|
+
break
|
|
81
|
+
if isinstance(_node, ast.Import):
|
|
82
|
+
for _alias in _node.names:
|
|
83
|
+
_mod = _alias.name.split('.')[0]
|
|
84
|
+
if _mod in _BLOCKED_MODULES:
|
|
85
|
+
_blocked = f'Blocked import: "{_mod}" is not allowed'
|
|
86
|
+
break
|
|
87
|
+
elif isinstance(_node, ast.ImportFrom):
|
|
88
|
+
if _node.module:
|
|
89
|
+
_mod = _node.module.split('.')[0]
|
|
90
|
+
if _mod in _BLOCKED_MODULES:
|
|
91
|
+
_blocked = f'Blocked import: "{_mod}" is not allowed'
|
|
92
|
+
elif isinstance(_node, ast.Call):
|
|
93
|
+
_name = None
|
|
94
|
+
if isinstance(_node.func, ast.Name):
|
|
95
|
+
_name = _node.func.id
|
|
96
|
+
elif isinstance(_node.func, ast.Attribute):
|
|
97
|
+
_name = _node.func.attr
|
|
98
|
+
if _name and _name in _BLOCKED_BUILTINS:
|
|
99
|
+
_blocked = f'Blocked builtin: "{_name}()" is not allowed'
|
|
100
|
+
|
|
101
|
+
if _blocked:
|
|
102
|
+
print(_marker + json.dumps({"success": False, "error": _blocked}))
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
|
|
105
|
+
# --- Data injection (from file, not stdin) ---
|
|
106
|
+
_atlas_data = None
|
|
107
|
+
if len(sys.argv) > 2:
|
|
108
|
+
_data_file = sys.argv[2]
|
|
109
|
+
if os.path.exists(_data_file):
|
|
110
|
+
with open(_data_file) as f:
|
|
111
|
+
_raw = f.read().strip()
|
|
112
|
+
if _raw:
|
|
113
|
+
_atlas_data = json.loads(_raw)
|
|
114
|
+
|
|
115
|
+
data = None
|
|
116
|
+
df = None
|
|
117
|
+
if _atlas_data:
|
|
118
|
+
try:
|
|
119
|
+
import pandas as pd
|
|
120
|
+
df = pd.DataFrame(_atlas_data["rows"], columns=_atlas_data["columns"])
|
|
121
|
+
data = df
|
|
122
|
+
except ImportError:
|
|
123
|
+
data = _atlas_data
|
|
124
|
+
|
|
125
|
+
# Configure matplotlib for headless rendering
|
|
126
|
+
try:
|
|
127
|
+
import matplotlib
|
|
128
|
+
matplotlib.use('Agg')
|
|
129
|
+
except ImportError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
os.makedirs(_chart_dir, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
def chart_path(n=0):
|
|
135
|
+
return os.path.join(_chart_dir, f"chart_{n}.png")
|
|
136
|
+
|
|
137
|
+
# --- Execute user code ---
|
|
138
|
+
_old_stdout = sys.stdout
|
|
139
|
+
sys.stdout = _captured = io.StringIO()
|
|
140
|
+
|
|
141
|
+
_user_ns = {"chart_path": chart_path, "data": data, "df": df}
|
|
142
|
+
_atlas_error = None
|
|
143
|
+
try:
|
|
144
|
+
exec(_user_code, _user_ns)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
_atlas_error = f"{type(e).__name__}: {e}"
|
|
147
|
+
|
|
148
|
+
_output = _captured.getvalue()
|
|
149
|
+
sys.stdout = _old_stdout
|
|
150
|
+
|
|
151
|
+
# --- Collect results ---
|
|
152
|
+
_charts = []
|
|
153
|
+
for f in sorted(glob.glob(os.path.join(_chart_dir, "chart_*.png"))):
|
|
154
|
+
with open(f, "rb") as fh:
|
|
155
|
+
_charts.append({"base64": base64.b64encode(fh.read()).decode(), "mimeType": "image/png"})
|
|
156
|
+
|
|
157
|
+
_result = {"success": _atlas_error is None}
|
|
158
|
+
if _output.strip():
|
|
159
|
+
_result["output"] = _output.strip()
|
|
160
|
+
if _atlas_error:
|
|
161
|
+
_result["error"] = _atlas_error
|
|
162
|
+
|
|
163
|
+
if "_atlas_table" in _user_ns:
|
|
164
|
+
_result["table"] = _user_ns["_atlas_table"]
|
|
165
|
+
|
|
166
|
+
if "_atlas_chart" in _user_ns:
|
|
167
|
+
_ac = _user_ns["_atlas_chart"]
|
|
168
|
+
if isinstance(_ac, dict):
|
|
169
|
+
_result["rechartsCharts"] = [_ac]
|
|
170
|
+
elif isinstance(_ac, list):
|
|
171
|
+
_result["rechartsCharts"] = _ac
|
|
172
|
+
|
|
173
|
+
if _charts:
|
|
174
|
+
_result["charts"] = _charts
|
|
175
|
+
|
|
176
|
+
print(_marker + json.dumps(_result), file=_old_stdout)
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
/** Format an error for logging, with extra detail from @vercel/sandbox APIError. */
|
|
180
|
+
function sandboxErrorDetail(err: unknown): string {
|
|
181
|
+
if (!(err instanceof Error)) return String(err);
|
|
182
|
+
const detail = err.message;
|
|
183
|
+
const json = (err as unknown as Record<string, unknown>).json;
|
|
184
|
+
const text = (err as unknown as Record<string, unknown>).text;
|
|
185
|
+
if (json) {
|
|
186
|
+
try {
|
|
187
|
+
return `${detail} — response: ${JSON.stringify(json)}`;
|
|
188
|
+
} catch {
|
|
189
|
+
return `${detail} — response: [unserializable object]`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof text === "string" && text) return `${detail} — body: ${text.slice(0, 500)}`;
|
|
193
|
+
return detail;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Scrub sensitive data from error messages before exposing. */
|
|
197
|
+
function safeError(detail: string): string {
|
|
198
|
+
return SENSITIVE_PATTERNS.test(detail)
|
|
199
|
+
? "sandbox API error (details in server logs)"
|
|
200
|
+
: detail;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Sandbox base dir for relative paths
|
|
204
|
+
const SANDBOX_BASE = "/vercel/sandbox";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create a Python sandbox backend using @vercel/sandbox.
|
|
208
|
+
*
|
|
209
|
+
* The sandbox is created lazily on first exec() call and reused for
|
|
210
|
+
* subsequent calls. If the sandbox errors, the cached promise is discarded
|
|
211
|
+
* (and the old sandbox stopped) so a fresh one is created on the next call.
|
|
212
|
+
*/
|
|
213
|
+
export function createPythonSandboxBackend(): PythonBackend {
|
|
214
|
+
let sandboxPromise: Promise<SandboxInstance> | null = null;
|
|
215
|
+
|
|
216
|
+
interface SandboxInstance {
|
|
217
|
+
sandbox: InstanceType<(typeof import("@vercel/sandbox"))["Sandbox"]>;
|
|
218
|
+
packagesInstalled: boolean;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getSandbox(): Promise<SandboxInstance> {
|
|
222
|
+
let Sandbox: (typeof import("@vercel/sandbox"))["Sandbox"];
|
|
223
|
+
try {
|
|
224
|
+
({ Sandbox } = await import("@vercel/sandbox"));
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
227
|
+
log.error({ err: detail }, "Failed to import @vercel/sandbox");
|
|
228
|
+
throw new Error(
|
|
229
|
+
"Vercel Sandbox runtime selected but @vercel/sandbox is not installed.",
|
|
230
|
+
{ cause: err },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let sandbox: InstanceType<typeof Sandbox>;
|
|
235
|
+
try {
|
|
236
|
+
// Start with allow-all so pip can reach pypi.org during setup
|
|
237
|
+
sandbox = await Sandbox.create({
|
|
238
|
+
runtime: "python3.13",
|
|
239
|
+
networkPolicy: "allow-all",
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
const detail = sandboxErrorDetail(err);
|
|
243
|
+
log.error({ err: detail }, "Python Sandbox.create() failed");
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Failed to create Python Vercel Sandbox: ${safeError(detail)}.`,
|
|
246
|
+
{ cause: err },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Install data science packages (requires network access)
|
|
251
|
+
let packagesInstalled = false;
|
|
252
|
+
try {
|
|
253
|
+
const result = await sandbox.runCommand({
|
|
254
|
+
cmd: "pip",
|
|
255
|
+
args: ["install", "--quiet", ...DATA_SCIENCE_PACKAGES],
|
|
256
|
+
sudo: true,
|
|
257
|
+
});
|
|
258
|
+
if (result.exitCode === 0) {
|
|
259
|
+
packagesInstalled = true;
|
|
260
|
+
log.info("Python data science packages installed in sandbox");
|
|
261
|
+
} else {
|
|
262
|
+
const stderr = await result.stderr();
|
|
263
|
+
log.warn(
|
|
264
|
+
{ exitCode: result.exitCode, stderr: stderr.slice(0, 500) },
|
|
265
|
+
"pip install returned non-zero — some packages may be unavailable",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const detail = sandboxErrorDetail(err);
|
|
270
|
+
log.warn({ err: detail }, "pip install failed — continuing without data science packages");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Lock down network before running any user code
|
|
274
|
+
try {
|
|
275
|
+
await sandbox.updateNetworkPolicy("deny-all");
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const detail = sandboxErrorDetail(err);
|
|
278
|
+
log.error({ err: detail }, "Failed to set deny-all network policy");
|
|
279
|
+
try { await sandbox.stop(); } catch (stopErr) { log.warn({ err: stopErr instanceof Error ? stopErr.message : String(stopErr) }, "Failed to stop sandbox after network policy error"); }
|
|
280
|
+
throw new Error(
|
|
281
|
+
`Failed to lock down sandbox network: ${safeError(detail)}.`,
|
|
282
|
+
{ cause: err },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { sandbox, packagesInstalled };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function invalidate() {
|
|
290
|
+
const old = sandboxPromise;
|
|
291
|
+
sandboxPromise = null;
|
|
292
|
+
if (old) {
|
|
293
|
+
old.then(instance => instance.sandbox.stop()).catch((err) => {
|
|
294
|
+
log.warn({ err: err instanceof Error ? err.message : String(err) }, "Failed to stop old Python sandbox during cleanup");
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
exec: async (code, data): Promise<PythonResult> => {
|
|
301
|
+
if (!sandboxPromise) {
|
|
302
|
+
sandboxPromise = getSandbox();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let instance: SandboxInstance;
|
|
306
|
+
try {
|
|
307
|
+
instance = await sandboxPromise;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
invalidate();
|
|
310
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
311
|
+
return { success: false, error: detail };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { sandbox } = instance;
|
|
315
|
+
const execId = randomUUID();
|
|
316
|
+
const resultMarker = `__ATLAS_RESULT_${execId}__`;
|
|
317
|
+
const execDir = `exec-${execId}`;
|
|
318
|
+
const chartDir = `${execDir}/charts`;
|
|
319
|
+
const wrapperPath = `${execDir}/wrapper.py`;
|
|
320
|
+
const codePath = `${execDir}/user_code.py`;
|
|
321
|
+
const dataPath = `${execDir}/data.json`;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
// Create directories
|
|
325
|
+
try {
|
|
326
|
+
await sandbox.mkDir(execDir);
|
|
327
|
+
await sandbox.mkDir(chartDir);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const detail = sandboxErrorDetail(err);
|
|
330
|
+
log.error({ err: detail, execId }, "Failed to create exec dirs in sandbox");
|
|
331
|
+
invalidate();
|
|
332
|
+
return { success: false, error: `Sandbox infrastructure error: ${safeError(detail)}` };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Write files
|
|
336
|
+
const files: { path: string; content: Buffer }[] = [
|
|
337
|
+
{ path: wrapperPath, content: Buffer.from(PYTHON_WRAPPER) },
|
|
338
|
+
{ path: codePath, content: Buffer.from(code) },
|
|
339
|
+
];
|
|
340
|
+
if (data) {
|
|
341
|
+
files.push({ path: dataPath, content: Buffer.from(JSON.stringify(data)) });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await sandbox.writeFiles(files);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
const detail = sandboxErrorDetail(err);
|
|
348
|
+
log.error({ err: detail, execId }, "Failed to write Python files to sandbox");
|
|
349
|
+
invalidate();
|
|
350
|
+
return { success: false, error: `Sandbox infrastructure error: ${safeError(detail)}` };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Build command args
|
|
354
|
+
const pythonArgs = [
|
|
355
|
+
`${SANDBOX_BASE}/${wrapperPath}`,
|
|
356
|
+
`${SANDBOX_BASE}/${codePath}`,
|
|
357
|
+
];
|
|
358
|
+
if (data) {
|
|
359
|
+
pythonArgs.push(`${SANDBOX_BASE}/${dataPath}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Execute with timeout enforcement
|
|
363
|
+
const timeout = parseInt(
|
|
364
|
+
process.env.ATLAS_PYTHON_TIMEOUT ?? String(DEFAULT_TIMEOUT_MS),
|
|
365
|
+
10,
|
|
366
|
+
) || DEFAULT_TIMEOUT_MS;
|
|
367
|
+
|
|
368
|
+
let result;
|
|
369
|
+
try {
|
|
370
|
+
const commandPromise = sandbox.runCommand({
|
|
371
|
+
cmd: "python3",
|
|
372
|
+
args: pythonArgs,
|
|
373
|
+
cwd: `${SANDBOX_BASE}/${execDir}`,
|
|
374
|
+
env: {
|
|
375
|
+
ATLAS_RESULT_MARKER: resultMarker,
|
|
376
|
+
ATLAS_CHART_DIR: `${SANDBOX_BASE}/${chartDir}`,
|
|
377
|
+
MPLBACKEND: "Agg",
|
|
378
|
+
HOME: "/tmp",
|
|
379
|
+
LANG: "C.UTF-8",
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
383
|
+
setTimeout(() => reject(new Error(`Python execution timed out after ${timeout}ms`)), timeout),
|
|
384
|
+
);
|
|
385
|
+
result = await Promise.race([commandPromise, timeoutPromise]);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
388
|
+
if (detail.includes("timed out")) {
|
|
389
|
+
log.warn({ execId, timeout }, "Python sandbox execution timed out");
|
|
390
|
+
return { success: false, error: detail };
|
|
391
|
+
}
|
|
392
|
+
const fullDetail = sandboxErrorDetail(err);
|
|
393
|
+
log.error({ err: fullDetail, execId }, "Sandbox runCommand failed for Python");
|
|
394
|
+
invalidate();
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: `Sandbox infrastructure error: ${safeError(fullDetail)}. Will retry with a fresh sandbox.`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let stdout: string;
|
|
402
|
+
let stderr: string;
|
|
403
|
+
try {
|
|
404
|
+
[stdout, stderr] = await Promise.all([
|
|
405
|
+
result.stdout(),
|
|
406
|
+
result.stderr(),
|
|
407
|
+
]);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
const detail = sandboxErrorDetail(err);
|
|
410
|
+
log.error({ err: detail, execId }, "Failed to read stdout/stderr from sandbox");
|
|
411
|
+
invalidate();
|
|
412
|
+
return { success: false, error: `Failed to read execution output: ${safeError(detail)}` };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Output size guard (matches nsjail's 1 MB limit)
|
|
416
|
+
if (stdout.length > MAX_OUTPUT) {
|
|
417
|
+
return {
|
|
418
|
+
success: false,
|
|
419
|
+
error: "Python output exceeded 1 MB limit — reduce print() output or use _atlas_table for large results.",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
log.debug(
|
|
424
|
+
{ execId, exitCode: result.exitCode, stdoutLen: stdout.length },
|
|
425
|
+
"python sandbox execution finished",
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Extract structured result from the last marker line
|
|
429
|
+
const lines = stdout.split("\n");
|
|
430
|
+
const resultLine = lines.findLast((l) => l.startsWith(resultMarker));
|
|
431
|
+
|
|
432
|
+
if (resultLine) {
|
|
433
|
+
try {
|
|
434
|
+
return JSON.parse(resultLine.slice(resultMarker.length)) as PythonResult;
|
|
435
|
+
} catch (parseErr) {
|
|
436
|
+
log.warn(
|
|
437
|
+
{ execId, resultLine: resultLine.slice(0, 500), parseError: String(parseErr) },
|
|
438
|
+
"failed to parse Python result JSON",
|
|
439
|
+
);
|
|
440
|
+
const userOutput = stdout.split(resultMarker)[0].trim();
|
|
441
|
+
return {
|
|
442
|
+
success: false,
|
|
443
|
+
error: `Python produced unparseable output.${userOutput ? ` Output: ${userOutput.slice(0, 500)}` : ""} stderr: ${stderr.trim().slice(0, 500)}`,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// No structured result — process errored before the wrapper could emit one
|
|
449
|
+
if (result.exitCode > 128) {
|
|
450
|
+
const signal = result.exitCode - 128;
|
|
451
|
+
const signalNames: Record<number, string> = {
|
|
452
|
+
6: "SIGABRT", 9: "SIGKILL", 11: "SIGSEGV", 15: "SIGTERM",
|
|
453
|
+
};
|
|
454
|
+
const name = signalNames[signal] ?? `signal ${signal}`;
|
|
455
|
+
if (signal === 9) {
|
|
456
|
+
return { success: false, error: "Python execution killed (likely exceeded time or memory limit)" };
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
error: `Python process terminated by ${name}${stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : ""}`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
error: stderr.trim() || `Python execution failed (exit code ${result.exitCode})`,
|
|
467
|
+
};
|
|
468
|
+
} catch (err) {
|
|
469
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
470
|
+
log.error({ err: detail, execId }, "Unexpected error in Python sandbox execution");
|
|
471
|
+
invalidate();
|
|
472
|
+
return { success: false, error: detail };
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar backend for the Python execution tool.
|
|
3
|
+
*
|
|
4
|
+
* Calls the sandbox sidecar's POST /exec-python endpoint to run Python code
|
|
5
|
+
* in an isolated container with no secrets and no host access. The sidecar
|
|
6
|
+
* handles data injection, chart collection, and structured output.
|
|
7
|
+
*
|
|
8
|
+
* Configured via ATLAS_SANDBOX_URL (same as the explore sidecar).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SidecarPythonRequest } from "@atlas/api/lib/sidecar-types";
|
|
12
|
+
import type { PythonResult } from "./python";
|
|
13
|
+
import { createLogger } from "@atlas/api/lib/logger";
|
|
14
|
+
|
|
15
|
+
const log = createLogger("python-sidecar");
|
|
16
|
+
|
|
17
|
+
/** Default timeout for Python execution (ms). */
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/** HTTP-level timeout — longer than the execution timeout to allow for overhead. */
|
|
21
|
+
const HTTP_OVERHEAD_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
/** Shorthand for building error results. */
|
|
24
|
+
function pythonError(error: string): PythonResult & { success: false } {
|
|
25
|
+
return { success: false, error };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function executePythonViaSidecar(
|
|
29
|
+
sidecarUrl: string,
|
|
30
|
+
code: string,
|
|
31
|
+
data?: { columns: string[]; rows: unknown[][] },
|
|
32
|
+
): Promise<PythonResult> {
|
|
33
|
+
const authToken = process.env.SIDECAR_AUTH_TOKEN;
|
|
34
|
+
|
|
35
|
+
let baseUrl: URL;
|
|
36
|
+
try {
|
|
37
|
+
baseUrl = new URL(sidecarUrl);
|
|
38
|
+
} catch {
|
|
39
|
+
return pythonError(
|
|
40
|
+
`Invalid ATLAS_SANDBOX_URL: "${sidecarUrl}". Expected a valid URL (e.g. http://sandbox-sidecar:8080).`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const execUrl = new URL("/exec-python", baseUrl).toString();
|
|
45
|
+
const rawTimeout = parseInt(process.env.ATLAS_PYTHON_TIMEOUT ?? String(DEFAULT_TIMEOUT_MS), 10);
|
|
46
|
+
if (Number.isNaN(rawTimeout)) {
|
|
47
|
+
log.warn({ value: process.env.ATLAS_PYTHON_TIMEOUT }, "Invalid ATLAS_PYTHON_TIMEOUT, using default");
|
|
48
|
+
}
|
|
49
|
+
const timeout = Number.isNaN(rawTimeout) ? DEFAULT_TIMEOUT_MS : rawTimeout;
|
|
50
|
+
|
|
51
|
+
const requestBody: SidecarPythonRequest = { code, timeout };
|
|
52
|
+
if (data) {
|
|
53
|
+
requestBody.data = data;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let response: Response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(execUrl, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(requestBody),
|
|
65
|
+
signal: AbortSignal.timeout(timeout + HTTP_OVERHEAD_MS),
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
detail.includes("ECONNREFUSED") ||
|
|
72
|
+
detail.includes("fetch failed") ||
|
|
73
|
+
detail.includes("Failed to connect")
|
|
74
|
+
) {
|
|
75
|
+
log.error({ err: detail, url: execUrl }, "Sidecar connection failed for Python execution");
|
|
76
|
+
return pythonError(
|
|
77
|
+
`Python sidecar unreachable at ${baseUrl.origin}: ${detail}. Check that the sandbox-sidecar service is running.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (detail.includes("TimeoutError") || detail.includes("timed out") || detail.includes("aborted")) {
|
|
82
|
+
log.warn({ timeout }, "Python sidecar request timed out");
|
|
83
|
+
return pythonError(`Python execution timed out after ${timeout}ms`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log.error({ err: detail }, "Python sidecar request failed");
|
|
87
|
+
return pythonError(`Sidecar request failed: ${detail}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
let errorBody: string;
|
|
92
|
+
try {
|
|
93
|
+
errorBody = await response.text();
|
|
94
|
+
} catch {
|
|
95
|
+
errorBody = `HTTP ${response.status}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log.error(
|
|
99
|
+
{
|
|
100
|
+
status: response.status,
|
|
101
|
+
contentLength: response.headers.get("content-length"),
|
|
102
|
+
body: errorBody.slice(0, 500),
|
|
103
|
+
},
|
|
104
|
+
"Python sidecar returned HTTP error",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Try to parse as structured error (500 with PythonResult shape)
|
|
108
|
+
if (response.status === 500) {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(errorBody) as PythonResult;
|
|
111
|
+
if (typeof parsed.success === "boolean") {
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Not structured — fall through
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return pythonError(
|
|
120
|
+
`Python sidecar error (HTTP ${response.status}): ${errorBody.slice(0, 500)}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = await response.json();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
129
|
+
log.error(
|
|
130
|
+
{
|
|
131
|
+
err: detail,
|
|
132
|
+
status: response.status,
|
|
133
|
+
contentLength: response.headers.get("content-length"),
|
|
134
|
+
},
|
|
135
|
+
"Failed to parse Python sidecar response",
|
|
136
|
+
);
|
|
137
|
+
return pythonError(`Failed to parse sidecar response: ${detail}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result = parsed as PythonResult;
|
|
141
|
+
if (typeof result.success !== "boolean") {
|
|
142
|
+
log.error(
|
|
143
|
+
{ body: JSON.stringify(parsed).slice(0, 500) },
|
|
144
|
+
"Python sidecar returned unexpected response shape",
|
|
145
|
+
);
|
|
146
|
+
return pythonError("Sidecar returned an unexpected response format.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
}
|