An entry for the streamplace vod showcase
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: encrypted secrets, runner config, clack/hono/valibot refactor

- Add encrypted secrets system (X25519 + XSalsa20-Poly1305)
- CLI: runner init/info, secrets set/list/delete commands
- Runner: config file support, access control, resource limits
- Refactor CLI to use @clack/prompts
- Refactor runner to use Hono
- Add valibot for input validation
- Add resource limits (memory, timeout) to sandbox

+1676 -551
+9
apps/vod/src/index.ts
··· 347 347 } 348 348 }, 349 349 }) 350 + 351 + export const pollTranscoding = endpoint<{ uri: string }, { status: "pending" | "complete", playlistUri: string }>({ 352 + async handler({ uri }) { 353 + return { 354 + status: "pending", 355 + playlistUri: "" 356 + } 357 + } 358 + })
+31
bun.lock
··· 64 64 "@atproto/api": "^0.19.5", 65 65 "@atproto/identity": "^0.4.12", 66 66 "@atproto/syntax": "^0.5.2", 67 + "tweetnacl": "^1.0.3", 68 + "tweetnacl-util": "^0.15.1", 67 69 }, 68 70 "devDependencies": { 71 + "@clack/prompts": "^1.2.0", 69 72 "@types/node": "^25.5.0", 70 73 "bun-types": "^1.3.11", 71 74 }, ··· 81 84 "@atproto/api": "^0.19.5", 82 85 "@atproto/identity": "^0.4.12", 83 86 "@atproto/syntax": "^0.5.2", 87 + "hono": "^4.12.9", 88 + "tweetnacl": "^1.0.3", 89 + "tweetnacl-util": "^0.15.1", 84 90 }, 85 91 "devDependencies": { 86 92 "@types/node": "^25.5.0", ··· 90 96 "packages/at-run/runtime": { 91 97 "name": "@at-run/runtime", 92 98 "version": "0.1.0", 99 + "dependencies": { 100 + "tweetnacl": "^1.0.3", 101 + "tweetnacl-util": "^0.15.1", 102 + "valibot": "^1.3.1", 103 + }, 93 104 }, 94 105 }, 95 106 "packages": { ··· 150 161 "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], 151 162 152 163 "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], 164 + 165 + "@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="], 166 + 167 + "@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], 153 168 154 169 "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], 155 170 ··· 357 372 358 373 "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 359 374 375 + "fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], 376 + 377 + "fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="], 378 + 379 + "fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], 380 + 360 381 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 361 382 362 383 "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], ··· 384 405 "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], 385 406 386 407 "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], 408 + 409 + "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], 387 410 388 411 "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 389 412 ··· 503 526 504 527 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 505 528 529 + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], 530 + 506 531 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 507 532 508 533 "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], ··· 517 542 518 543 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 519 544 545 + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], 546 + 547 + "tweetnacl-util": ["tweetnacl-util@0.15.1", "", {}, "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw=="], 548 + 520 549 "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], 521 550 522 551 "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], ··· 532 561 "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], 533 562 534 563 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 564 + 565 + "valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="], 535 566 536 567 "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], 537 568
+4 -1
packages/at-run/cli/package.json
··· 13 13 "@at-run/runtime": "workspace:*", 14 14 "@atproto/api": "^0.19.5", 15 15 "@atproto/identity": "^0.4.12", 16 - "@atproto/syntax": "^0.5.2" 16 + "@atproto/syntax": "^0.5.2", 17 + "tweetnacl": "^1.0.3", 18 + "tweetnacl-util": "^0.15.1" 17 19 }, 18 20 "keywords": ["atproto", "serverless", "cli", "deploy"], 19 21 "license": "MIT", 20 22 "devDependencies": { 23 + "@clack/prompts": "^1.2.0", 21 24 "@types/node": "^25.5.0", 22 25 "bun-types": "^1.3.11" 23 26 }
+576 -212
packages/at-run/cli/src/index.ts
··· 1 1 #!/usr/bin/env bun 2 2 /** 3 3 * @at-run/cli - Deploy bundles to AT Protocol PDS 4 - * 5 - * Usage: 6 - * at-run deploy <bundle.js> [--patch|--minor|--major|--version=x.y.z] 7 - * at-run login 8 - * at-run whoami 9 - * at-run list 10 4 */ 11 5 6 + import * as p from "@clack/prompts" 12 7 import { AtpAgent } from "@atproto/api" 13 - import { isManifest, type Permissions } from "@at-run/runtime" 8 + import { IdResolver } from "@atproto/identity" 9 + import { 10 + isManifest, 11 + type Permissions, 12 + encryptSecretsEphemeral, 13 + isValidPublicKey, 14 + generateKeyPair, 15 + ALGORITHM, 16 + } from "@at-run/runtime" 14 17 import * as fs from "fs" 15 18 import * as path from "path" 16 19 import * as os from "os" 17 20 18 21 const CONFIG_DIR = path.join(os.homedir(), ".at-run") 19 22 const SESSION_FILE = path.join(CONFIG_DIR, "session.json") 20 - const COLLECTION = "dev.mainasara.at-run.beta.bundle" 23 + const RUNNER_KEY_FILE = path.join(CONFIG_DIR, "runner-key.json") 24 + const RUNNER_CONFIG_FILE = path.join(CONFIG_DIR, "runner.json") 25 + const BUNDLE_COLLECTION = "dev.mainasara.at-run.beta.bundle" 26 + const RUNNER_COLLECTION = "dev.mainasara.at-run.beta.runner" 27 + const SECRETS_COLLECTION = "dev.mainasara.at-run.beta.secrets" 28 + 29 + // For backwards compatibility 30 + const COLLECTION = BUNDLE_COLLECTION 31 + 32 + const idResolver = new IdResolver() 21 33 22 34 // ============================================================================= 23 35 // Types ··· 39 51 permissions: Permissions 40 52 } 41 53 42 - interface StoredSession { 54 + interface StoredCredentials { 43 55 service: string 44 - did: string 45 - handle: string 46 - accessJwt: string 47 - refreshJwt: string 56 + identifier: string 57 + password: string 58 + } 59 + 60 + interface RunnerRecord { 61 + endpoint: string 62 + publicKey: string 63 + keyAlgorithm?: string 64 + name?: string 65 + description?: string 66 + } 67 + 68 + interface SecretsRecord { 69 + $type: string 70 + bundle: string 71 + runner: string 72 + encrypted: string 73 + nonce: string 74 + ephemeralPublicKey: string 75 + algorithm: string 76 + createdAt: string 48 77 } 49 78 50 79 // ============================================================================= ··· 59 88 return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])] 60 89 } 61 90 62 - function bumpVersion( 63 - current: string, 64 - type: "patch" | "minor" | "major" 65 - ): string { 91 + function bumpVersion(current: string, type: "patch" | "minor" | "major"): string { 66 92 const [major, minor, patch] = parseVersion(current) 67 - 68 93 switch (type) { 69 94 case "major": 70 95 return `${major + 1}.0.0` ··· 75 100 } 76 101 } 77 102 103 + function compareVersions(a: string, b: string): number { 104 + const [aMajor, aMinor, aPatch] = parseVersion(a) 105 + const [bMajor, bMinor, bPatch] = parseVersion(b) 106 + if (aMajor !== bMajor) return aMajor - bMajor 107 + if (aMinor !== bMinor) return aMinor - bMinor 108 + return aPatch - bPatch 109 + } 110 + 78 111 // ============================================================================= 79 - // Session Management 112 + // Credentials 80 113 // ============================================================================= 81 114 82 115 function ensureConfigDir(): void { ··· 85 118 } 86 119 } 87 120 88 - function saveSession(session: StoredSession): void { 121 + function saveCredentials(credentials: StoredCredentials): void { 89 122 ensureConfigDir() 90 - fs.writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { mode: 0o600 }) 123 + fs.writeFileSync(SESSION_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }) 91 124 } 92 125 93 - function loadSession(): StoredSession | null { 94 - if (!fs.existsSync(SESSION_FILE)) { 95 - return null 96 - } 126 + function loadCredentials(): StoredCredentials | null { 127 + if (!fs.existsSync(SESSION_FILE)) return null 97 128 try { 98 129 return JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8")) 99 130 } catch { ··· 101 132 } 102 133 } 103 134 104 - function clearSession(): void { 135 + function clearCredentials(): void { 105 136 if (fs.existsSync(SESSION_FILE)) { 106 137 fs.unlinkSync(SESSION_FILE) 107 138 } 108 139 } 109 140 110 141 async function getAuthenticatedAgent(): Promise<AtpAgent> { 111 - const stored = loadSession() 112 - if (!stored) { 113 - console.error("Not logged in. Run: at-run login") 142 + const creds = loadCredentials() 143 + if (!creds) { 144 + p.log.error("Not logged in. Run: at-run login") 114 145 process.exit(1) 115 146 } 116 147 117 - const agent = new AtpAgent({ service: stored.service }) 118 - 148 + const agent = new AtpAgent({ service: creds.service }) 119 149 try { 120 - await agent.resumeSession({ 121 - did: stored.did, 122 - handle: stored.handle, 123 - accessJwt: stored.accessJwt, 124 - refreshJwt: stored.refreshJwt, 125 - active: true, 126 - }) 127 - } catch { 128 - console.error("Session expired. Run: at-run login") 129 - clearSession() 150 + await agent.login({ identifier: creds.identifier, password: creds.password }) 151 + } catch (err) { 152 + p.log.error(`Login failed: ${err instanceof Error ? err.message : err}`) 153 + p.log.info("Try: at-run login") 130 154 process.exit(1) 131 155 } 132 - 133 156 return agent 134 157 } 135 158 136 159 // ============================================================================= 160 + // Runner Utilities 161 + // ============================================================================= 162 + 163 + /** 164 + * Fetch a runner's public key from their PDS 165 + */ 166 + async function fetchRunnerPublicKey(runnerDid: string): Promise<{ publicKey: string; endpoint: string; name?: string }> { 167 + const didDoc = await idResolver.did.resolve(runnerDid) 168 + if (!didDoc) { 169 + throw new Error(`Failed to resolve DID: ${runnerDid}`) 170 + } 171 + 172 + const pdsUrl = didDoc.service?.find( 173 + (s) => s.id === "#atproto_pds" || s.id === `${runnerDid}#atproto_pds` 174 + )?.serviceEndpoint as string | undefined 175 + 176 + if (!pdsUrl) { 177 + throw new Error("No PDS service found for runner") 178 + } 179 + 180 + const agent = new AtpAgent({ service: pdsUrl }) 181 + 182 + const response = await agent.com.atproto.repo.listRecords({ 183 + repo: runnerDid, 184 + collection: RUNNER_COLLECTION, 185 + limit: 10, 186 + }) 187 + 188 + if (response.data.records.length === 0) { 189 + throw new Error(`No runner registration found for ${runnerDid}`) 190 + } 191 + 192 + // Use the most recent runner record 193 + const record = response.data.records[0].value as unknown as RunnerRecord 194 + 195 + if (!record.publicKey || !isValidPublicKey(record.publicKey)) { 196 + throw new Error("Runner has invalid or missing public key") 197 + } 198 + 199 + return { 200 + publicKey: record.publicKey, 201 + endpoint: record.endpoint, 202 + name: record.name, 203 + } 204 + } 205 + 206 + // ============================================================================= 137 207 // Bundle Utilities 138 208 // ============================================================================= 139 209 ··· 159 229 } 160 230 } 161 231 162 - async function getCurrentVersion( 163 - agent: AtpAgent, 164 - did: string, 165 - name: string 166 - ): Promise<string | null> { 232 + async function getCurrentVersion(agent: AtpAgent, did: string, name: string): Promise<string | null> { 167 233 try { 168 234 const response = await agent.com.atproto.repo.listRecords({ 169 235 repo: did, ··· 172 238 }) 173 239 174 240 let highestVersion: string | null = null 175 - 176 241 for (const record of response.data.records) { 177 242 const value = record.value as unknown as BundleRecord 178 243 if (value.name === name && value.version) { ··· 181 246 } 182 247 } 183 248 } 184 - 185 249 return highestVersion 186 250 } catch { 187 251 return null 188 252 } 189 253 } 190 254 191 - function compareVersions(a: string, b: string): number { 192 - const [aMajor, aMinor, aPatch] = parseVersion(a) 193 - const [bMajor, bMinor, bPatch] = parseVersion(b) 194 - 195 - if (aMajor !== bMajor) return aMajor - bMajor 196 - if (aMinor !== bMinor) return aMinor - bMinor 197 - return aPatch - bPatch 198 - } 199 - 200 255 // ============================================================================= 201 256 // Commands 202 257 // ============================================================================= 203 258 204 259 async function login(): Promise<void> { 205 - const service = await prompt("PDS URL (e.g., https://bsky.social): ") 206 - const identifier = await prompt("Handle or DID: ") 207 - const password = await prompt("App Password: ", true) 260 + p.intro("at-run login") 208 261 209 - console.log("\nLogging in...") 262 + const credentials = await p.group( 263 + { 264 + service: () => 265 + p.text({ 266 + message: "PDS URL", 267 + placeholder: "https://bsky.social", 268 + defaultValue: "https://bsky.social", 269 + }), 270 + identifier: () => 271 + p.text({ 272 + message: "Handle or DID", 273 + placeholder: "you.bsky.social", 274 + }), 275 + password: () => 276 + p.password({ 277 + message: "App Password", 278 + }), 279 + }, 280 + { 281 + onCancel: () => { 282 + p.cancel("Login cancelled") 283 + process.exit(0) 284 + }, 285 + } 286 + ) 287 + 288 + const s = p.spinner() 289 + s.start("Logging in...") 210 290 211 - const agent = new AtpAgent({ service }) 291 + const agent = new AtpAgent({ service: credentials.service }) 212 292 213 293 try { 214 - const response = await agent.login({ identifier, password }) 215 - saveSession({ 216 - service, 217 - did: response.data.did, 218 - handle: response.data.handle, 219 - accessJwt: response.data.accessJwt, 220 - refreshJwt: response.data.refreshJwt, 294 + const response = await agent.login({ 295 + identifier: credentials.identifier, 296 + password: credentials.password, 221 297 }) 222 - console.log(`\nLogged in as ${response.data.handle} (${response.data.did})`) 298 + saveCredentials(credentials) 299 + s.stop("Logged in") 300 + p.log.success(`${response.data.handle} (${response.data.did})`) 301 + p.outro("Credentials saved") 223 302 } catch (err) { 224 - console.error("Login failed:", err instanceof Error ? err.message : err) 303 + s.stop("Login failed") 304 + p.log.error(err instanceof Error ? err.message : String(err)) 225 305 process.exit(1) 226 306 } 227 307 } ··· 231 311 const session = agent.session 232 312 233 313 if (!session) { 234 - console.error("Not logged in") 314 + p.log.error("Not logged in") 235 315 process.exit(1) 236 316 } 237 317 238 - console.log(`Handle: ${session.handle}`) 239 - console.log(`DID: ${session.did}`) 240 - console.log(`PDS: ${agent.service}`) 318 + p.log.info(`Handle: ${session.handle}`) 319 + p.log.info(`DID: ${session.did}`) 320 + p.log.info(`PDS: ${agent.service}`) 241 321 } 242 322 243 323 async function logout(): Promise<void> { 244 - clearSession() 245 - console.log("Logged out") 324 + clearCredentials() 325 + p.log.success("Logged out (credentials cleared)") 246 326 } 247 327 248 328 async function deploy( 249 329 bundlePath: string, 250 - options: { 251 - patch?: boolean 252 - minor?: boolean 253 - major?: boolean 254 - version?: string 255 - } 330 + options: { patch?: boolean; minor?: boolean; major?: boolean; version?: string } 256 331 ): Promise<void> { 332 + p.intro(`at-run deploy`) 333 + 257 334 if (!fs.existsSync(bundlePath)) { 258 - console.error(`Bundle not found: ${bundlePath}`) 335 + p.log.error(`Bundle not found: ${bundlePath}`) 259 336 process.exit(1) 260 337 } 261 338 262 339 const bundleSource = fs.readFileSync(bundlePath) 263 - const bundleSize = bundleSource.length 340 + p.log.info(`Bundle: ${bundlePath} (${(bundleSource.length / 1024).toFixed(1)} KB)`) 264 341 265 - console.log(`Bundle: ${bundlePath} (${(bundleSize / 1024).toFixed(1)} KB)`) 342 + const s = p.spinner() 343 + s.start("Extracting manifest...") 266 344 267 345 const manifest = await extractManifest(bundlePath) 268 346 if (!manifest) { 269 - console.error("Error: Bundle must export a manifest with name and permissions") 270 - console.error("Example:") 271 - console.error(` export const bundle = manifest({`) 272 - console.error(` name: "my-api",`) 273 - console.error(` description: "My API bundle",`) 274 - console.error(` permissions: { net: ["api.example.com"] },`) 275 - console.error(` })`) 347 + s.stop("Failed") 348 + p.log.error("Bundle must export a manifest with name and permissions") 349 + p.log.info(`Example: 350 + export const bundle = manifest({ 351 + name: "my-api", 352 + description: "My API bundle", 353 + permissions: { net: ["api.example.com"] }, 354 + })`) 276 355 process.exit(1) 277 356 } 278 357 279 - console.log(`Name: ${manifest.name}`) 280 - if (manifest.description) { 281 - console.log(`Description: ${manifest.description}`) 282 - } 283 - console.log(`Permissions: ${JSON.stringify(manifest.permissions)}`) 358 + s.stop("Manifest extracted") 359 + p.log.info(`Name: ${manifest.name}`) 360 + if (manifest.description) p.log.info(`Description: ${manifest.description}`) 361 + p.log.info(`Permissions: ${JSON.stringify(manifest.permissions)}`) 284 362 285 363 const agent = await getAuthenticatedAgent() 286 364 const did = agent.session?.did 287 365 288 366 if (!did) { 289 - console.error("Not logged in") 367 + p.log.error("Not logged in") 290 368 process.exit(1) 291 369 } 292 370 293 371 let version: string 294 - 295 372 if (options.version) { 296 373 parseVersion(options.version) 297 374 version = options.version 298 375 } else { 299 376 const currentVersion = await getCurrentVersion(agent, did, manifest.name) 300 - 301 377 if (currentVersion) { 302 378 const bumpType = options.major ? "major" : options.minor ? "minor" : "patch" 303 379 version = bumpVersion(currentVersion, bumpType) 304 - console.log(`Current version: ${currentVersion}`) 305 - console.log(`Bump type: ${bumpType}`) 380 + p.log.info(`Current: ${currentVersion} -> ${version} (${bumpType})`) 306 381 } else { 307 382 version = "0.1.0" 308 - console.log(`First deployment, starting at version ${version}`) 383 + p.log.info(`First deployment: ${version}`) 309 384 } 310 385 } 311 386 312 - console.log(`Version: ${version}`) 313 - console.log(`\nDeploying to ${did}...`) 314 - 315 - console.log("Uploading bundle...") 387 + s.start("Uploading bundle...") 316 388 const blobResponse = await agent.uploadBlob(bundleSource, { 317 389 encoding: "application/javascript", 318 390 }) 319 391 320 - const blobRef = blobResponse.data.blob 392 + s.message("Creating record...") 321 393 const rkey = `${manifest.name}-${version.replace(/\./g, "-")}` 322 - 323 394 const record: BundleRecord = { 324 395 $type: COLLECTION, 325 396 name: manifest.name, 326 397 description: manifest.description, 327 398 version, 328 - blob: blobRef, 399 + blob: blobResponse.data.blob, 329 400 permissions: manifest.permissions, 330 401 createdAt: new Date().toISOString(), 331 402 } 332 403 333 - console.log("Creating record...") 334 - 335 404 const response = await agent.com.atproto.repo.putRecord({ 336 405 repo: did, 337 406 collection: COLLECTION, ··· 339 408 record: record as unknown as Record<string, unknown>, 340 409 }) 341 410 411 + s.stop("Deployed") 412 + 342 413 const atUri = `at://${did}/${COLLECTION}/${rkey}` 343 - 344 - console.log("\n✓ Deployed!") 345 - console.log(` Name: ${manifest.name}`) 346 - console.log(` Version: ${version}`) 347 - console.log(` AT URI: ${atUri}`) 348 - console.log(` CID: ${response.data.cid}`) 349 - console.log(`\nRun with:`) 350 - console.log(` POST https://your-runner/${atUri}/endpointName`) 414 + p.log.success(`${manifest.name}@${version}`) 415 + p.log.info(`AT URI: ${atUri}`) 416 + p.log.info(`CID: ${response.data.cid}`) 417 + p.outro(`Run: POST https://your-runner/bundle/${did}/${manifest.name}/latest/endpointName`) 351 418 } 352 419 353 420 async function list(): Promise<void> { ··· 355 422 const did = agent.session?.did 356 423 357 424 if (!did) { 358 - console.error("Not logged in") 425 + p.log.error("Not logged in") 359 426 process.exit(1) 360 427 } 361 428 429 + const s = p.spinner() 430 + s.start("Fetching bundles...") 431 + 362 432 const response = await agent.com.atproto.repo.listRecords({ 363 433 repo: did, 364 434 collection: COLLECTION, 365 435 limit: 100, 366 436 }) 437 + 438 + s.stop("Done") 367 439 368 440 if (response.data.records.length === 0) { 369 - console.log("No bundles found") 441 + p.log.info("No bundles found") 370 442 return 371 443 } 372 444 373 445 const bundles = new Map<string, BundleRecord[]>() 374 - 375 446 for (const record of response.data.records) { 376 447 const value = record.value as unknown as BundleRecord 377 448 const existing = bundles.get(value.name) || [] ··· 379 450 bundles.set(value.name, existing) 380 451 } 381 452 382 - console.log(`Bundles in ${COLLECTION}:\n`) 453 + p.log.info(`Collection: ${COLLECTION}\n`) 383 454 384 455 for (const [name, versions] of bundles) { 385 456 versions.sort((a, b) => compareVersions(b.version, a.version)) 386 - 387 457 const latest = versions[0] 458 + 388 459 console.log(` ${name}`) 389 - if (latest.description) { 390 - console.log(` ${latest.description}`) 391 - } 460 + if (latest.description) console.log(` ${latest.description}`) 392 461 console.log(` Latest: v${latest.version}`) 393 462 console.log(` Versions: ${versions.map((v) => v.version).join(", ")}`) 394 463 console.log(` Permissions: ${JSON.stringify(latest.permissions)}`) ··· 401 470 const did = agent.session?.did 402 471 403 472 if (!did) { 404 - console.error("Not logged in") 473 + p.log.error("Not logged in") 405 474 process.exit(1) 406 475 } 407 476 477 + // Direct rkey deletion 408 478 if (nameOrRkey.match(/-\d+-\d+-\d+$/)) { 479 + const s = p.spinner() 480 + s.start("Deleting...") 409 481 await agent.com.atproto.repo.deleteRecord({ 410 482 repo: did, 411 483 collection: COLLECTION, 412 484 rkey: nameOrRkey, 413 485 }) 414 - console.log(`Deleted: at://${did}/${COLLECTION}/${nameOrRkey}`) 486 + s.stop("Deleted") 487 + p.log.success(`at://${did}/${COLLECTION}/${nameOrRkey}`) 415 488 return 416 489 } 417 490 491 + // Find all versions by name 418 492 const response = await agent.com.atproto.repo.listRecords({ 419 493 repo: did, 420 494 collection: COLLECTION, ··· 422 496 }) 423 497 424 498 const toDelete: string[] = [] 425 - 426 499 for (const record of response.data.records) { 427 500 const value = record.value as unknown as BundleRecord 428 501 if (value.name === nameOrRkey) { 429 - const rkey = record.uri.split("/").pop()! 430 - toDelete.push(rkey) 502 + toDelete.push(record.uri.split("/").pop()!) 431 503 } 432 504 } 433 505 434 506 if (toDelete.length === 0) { 435 - console.error(`Bundle not found: ${nameOrRkey}`) 507 + p.log.error(`Bundle not found: ${nameOrRkey}`) 436 508 process.exit(1) 437 509 } 438 510 439 - console.log(`Deleting ${toDelete.length} version(s) of ${nameOrRkey}...`) 511 + const confirmed = await p.confirm({ 512 + message: `Delete ${toDelete.length} version(s) of ${nameOrRkey}?`, 513 + }) 514 + 515 + if (!confirmed || p.isCancel(confirmed)) { 516 + p.cancel("Cancelled") 517 + return 518 + } 519 + 520 + const s = p.spinner() 521 + s.start(`Deleting ${toDelete.length} version(s)...`) 440 522 441 523 for (const rkey of toDelete) { 442 524 await agent.com.atproto.repo.deleteRecord({ ··· 444 526 collection: COLLECTION, 445 527 rkey, 446 528 }) 447 - console.log(` Deleted: ${rkey}`) 448 529 } 449 530 450 - console.log("Done") 531 + s.stop("Deleted") 532 + p.log.success(`${toDelete.length} version(s) of ${nameOrRkey}`) 451 533 } 452 534 453 535 // ============================================================================= 454 - // Utilities 536 + // Secrets Commands 455 537 // ============================================================================= 456 538 457 - async function prompt(message: string, hidden = false): Promise<string> { 458 - process.stdout.write(message) 539 + async function secretsSet( 540 + bundleName: string, 541 + secretPairs: string[], 542 + runnerDid: string 543 + ): Promise<void> { 544 + p.intro("at-run secrets set") 459 545 460 - return new Promise((resolve) => { 461 - const stdin = process.stdin 462 - const wasRaw = stdin.isRaw 546 + // Parse KEY=value pairs 547 + const secrets: Record<string, string> = {} 548 + for (const pair of secretPairs) { 549 + const eqIndex = pair.indexOf("=") 550 + if (eqIndex <= 0) { 551 + p.log.error(`Invalid format: ${pair}. Expected KEY=value`) 552 + process.exit(1) 553 + } 554 + const key = pair.slice(0, eqIndex) 555 + const value = pair.slice(eqIndex + 1) 556 + secrets[key] = value 557 + } 463 558 464 - stdin.setRawMode?.(true) 465 - stdin.resume() 559 + if (Object.keys(secrets).length === 0) { 560 + p.log.error("No secrets provided") 561 + process.exit(1) 562 + } 466 563 467 - let input = "" 564 + p.log.info(`Bundle: ${bundleName}`) 565 + p.log.info(`Runner: ${runnerDid}`) 566 + p.log.info(`Secrets: ${Object.keys(secrets).join(", ")}`) 468 567 469 - const onData = (data: Buffer) => { 470 - const char = data.toString() 568 + const s = p.spinner() 569 + s.start("Fetching runner public key...") 570 + 571 + let runnerInfo: { publicKey: string; endpoint: string; name?: string } 572 + try { 573 + runnerInfo = await fetchRunnerPublicKey(runnerDid) 574 + } catch (err) { 575 + s.stop("Failed") 576 + p.log.error(err instanceof Error ? err.message : String(err)) 577 + process.exit(1) 578 + } 579 + 580 + s.message("Encrypting secrets...") 581 + 582 + const encrypted = encryptSecretsEphemeral(secrets, runnerInfo.publicKey) 583 + 584 + s.message("Storing encrypted secrets...") 585 + 586 + const agent = await getAuthenticatedAgent() 587 + const did = agent.session?.did 588 + 589 + if (!did) { 590 + s.stop("Failed") 591 + p.log.error("Not logged in") 592 + process.exit(1) 593 + } 594 + 595 + // Use bundle name + runner DID as rkey for easy lookup 596 + const rkey = `${bundleName}-${runnerDid.replace(/:/g, "-")}` 597 + 598 + const record: SecretsRecord = { 599 + $type: SECRETS_COLLECTION, 600 + bundle: bundleName, 601 + runner: runnerDid, 602 + encrypted: encrypted.encrypted, 603 + nonce: encrypted.nonce, 604 + ephemeralPublicKey: encrypted.ephemeralPublicKey, 605 + algorithm: ALGORITHM, 606 + createdAt: new Date().toISOString(), 607 + } 608 + 609 + await agent.com.atproto.repo.putRecord({ 610 + repo: did, 611 + collection: SECRETS_COLLECTION, 612 + rkey, 613 + record: record as unknown as Record<string, unknown>, 614 + }) 615 + 616 + s.stop("Stored") 617 + 618 + p.log.success(`Secrets encrypted for ${runnerInfo.name || runnerDid}`) 619 + p.log.info(`Runner endpoint: ${runnerInfo.endpoint}`) 620 + p.outro("Secrets will be decrypted by the runner at execution time") 621 + } 622 + 623 + async function secretsList(bundleName?: string): Promise<void> { 624 + const agent = await getAuthenticatedAgent() 625 + const did = agent.session?.did 626 + 627 + if (!did) { 628 + p.log.error("Not logged in") 629 + process.exit(1) 630 + } 631 + 632 + const s = p.spinner() 633 + s.start("Fetching secrets...") 634 + 635 + const response = await agent.com.atproto.repo.listRecords({ 636 + repo: did, 637 + collection: SECRETS_COLLECTION, 638 + limit: 100, 639 + }) 640 + 641 + s.stop("Done") 642 + 643 + const secrets = response.data.records 644 + .map((r) => r.value as unknown as SecretsRecord) 645 + .filter((s) => !bundleName || s.bundle === bundleName) 646 + 647 + if (secrets.length === 0) { 648 + p.log.info(bundleName ? `No secrets found for ${bundleName}` : "No secrets found") 649 + return 650 + } 651 + 652 + p.log.info(`Secrets (${secrets.length}):\n`) 653 + 654 + for (const secret of secrets) { 655 + console.log(` ${secret.bundle}`) 656 + console.log(` Runner: ${secret.runner}`) 657 + console.log(` Algorithm: ${secret.algorithm}`) 658 + console.log(` Created: ${secret.createdAt}`) 659 + console.log() 660 + } 661 + } 662 + 663 + async function secretsDelete(bundleName: string, runnerDid: string): Promise<void> { 664 + const agent = await getAuthenticatedAgent() 665 + const did = agent.session?.did 666 + 667 + if (!did) { 668 + p.log.error("Not logged in") 669 + process.exit(1) 670 + } 671 + 672 + const rkey = `${bundleName}-${runnerDid.replace(/:/g, "-")}` 673 + 674 + const confirmed = await p.confirm({ 675 + message: `Delete secrets for ${bundleName} on runner ${runnerDid}?`, 676 + }) 677 + 678 + if (!confirmed || p.isCancel(confirmed)) { 679 + p.cancel("Cancelled") 680 + return 681 + } 471 682 472 - if (char === "\n" || char === "\r") { 473 - stdin.removeListener("data", onData) 474 - stdin.setRawMode?.(wasRaw) 475 - stdin.pause() 476 - console.log() 477 - resolve(input) 478 - } else if (char === "\u0003") { 479 - process.exit(0) 480 - } else if (char === "\u007F") { 481 - if (input.length > 0) { 482 - input = input.slice(0, -1) 483 - if (!hidden) { 484 - process.stdout.write("\b \b") 485 - } 486 - } 487 - } else { 488 - input += char 489 - if (!hidden) { 490 - process.stdout.write(char) 491 - } 492 - } 683 + const s = p.spinner() 684 + s.start("Deleting...") 685 + 686 + try { 687 + await agent.com.atproto.repo.deleteRecord({ 688 + repo: did, 689 + collection: SECRETS_COLLECTION, 690 + rkey, 691 + }) 692 + s.stop("Deleted") 693 + p.log.success(`Secrets for ${bundleName} on ${runnerDid}`) 694 + } catch { 695 + s.stop("Failed") 696 + p.log.error("Secrets record not found") 697 + process.exit(1) 698 + } 699 + } 700 + 701 + // ============================================================================= 702 + // Runner Commands 703 + // ============================================================================= 704 + 705 + interface StoredKeyPair { 706 + publicKey: string 707 + secretKey: string 708 + createdAt: string 709 + } 710 + 711 + interface RunnerConfigFile { 712 + did: string 713 + endpoint?: string 714 + } 715 + 716 + function loadOrCreateKeyPair(): StoredKeyPair { 717 + if (fs.existsSync(RUNNER_KEY_FILE)) { 718 + try { 719 + return JSON.parse(fs.readFileSync(RUNNER_KEY_FILE, "utf-8")) 720 + } catch { 721 + // Regenerate if corrupted 493 722 } 723 + } 494 724 495 - stdin.on("data", onData) 725 + const keyPair = generateKeyPair() 726 + const stored: StoredKeyPair = { 727 + ...keyPair, 728 + createdAt: new Date().toISOString(), 729 + } 730 + 731 + fs.writeFileSync(RUNNER_KEY_FILE, JSON.stringify(stored, null, 2), { mode: 0o600 }) 732 + return stored 733 + } 734 + 735 + async function runnerInit(endpoint: string): Promise<void> { 736 + p.intro("at-run runner init") 737 + 738 + const agent = await getAuthenticatedAgent() 739 + const did = agent.session?.did 740 + 741 + if (!did) { 742 + p.log.error("Not logged in") 743 + process.exit(1) 744 + } 745 + 746 + p.log.info(`Operator DID: ${did}`) 747 + p.log.info(`Runner endpoint: ${endpoint}`) 748 + 749 + const s = p.spinner() 750 + s.start("Generating keypair...") 751 + 752 + // Get or create keypair 753 + const keyPair = loadOrCreateKeyPair() 754 + 755 + s.message("Publishing runner record...") 756 + 757 + // Create runner record 758 + const rkey = `runner-${Date.now()}` 759 + const record = { 760 + $type: RUNNER_COLLECTION, 761 + endpoint, 762 + publicKey: keyPair.publicKey, 763 + keyAlgorithm: ALGORITHM, 764 + createdAt: new Date().toISOString(), 765 + } 766 + 767 + await agent.com.atproto.repo.putRecord({ 768 + repo: did, 769 + collection: RUNNER_COLLECTION, 770 + rkey, 771 + record: record as unknown as Record<string, unknown>, 496 772 }) 773 + 774 + // Save runner config 775 + const runnerConfig: RunnerConfigFile = { did, endpoint } 776 + fs.writeFileSync(RUNNER_CONFIG_FILE, JSON.stringify(runnerConfig, null, 2)) 777 + 778 + s.stop("Published") 779 + 780 + p.log.success("Runner initialized!") 781 + p.log.info(`DID: ${did}`) 782 + p.log.info(`Public key: ${keyPair.publicKey}`) 783 + p.log.info(`Keypair saved to: ${RUNNER_KEY_FILE}`) 784 + p.log.info(`Config saved to: ${RUNNER_CONFIG_FILE}`) 785 + 786 + p.outro(`Bundle authors can now encrypt secrets for --runner ${did}`) 497 787 } 498 788 499 - function printHelp(): void { 500 - console.log(` 501 - @at-run/cli - Deploy bundles to AT Protocol PDS 789 + async function runnerInfo(): Promise<void> { 790 + if (!fs.existsSync(RUNNER_KEY_FILE)) { 791 + p.log.error("Runner not initialized. Run: at-run runner init <endpoint>") 792 + process.exit(1) 793 + } 794 + 795 + const keyPair: StoredKeyPair = JSON.parse(fs.readFileSync(RUNNER_KEY_FILE, "utf-8")) 796 + 797 + let config: RunnerConfigFile | null = null 798 + if (fs.existsSync(RUNNER_CONFIG_FILE)) { 799 + config = JSON.parse(fs.readFileSync(RUNNER_CONFIG_FILE, "utf-8")) 800 + } 801 + 802 + p.log.info(`Public key: ${keyPair.publicKey}`) 803 + p.log.info(`Created: ${keyPair.createdAt}`) 804 + if (config) { 805 + p.log.info(`DID: ${config.did}`) 806 + if (config.endpoint) p.log.info(`Endpoint: ${config.endpoint}`) 807 + } 808 + p.log.info(`Keypair file: ${RUNNER_KEY_FILE}`) 809 + } 810 + 811 + // ============================================================================= 812 + // Main 813 + // ============================================================================= 814 + 815 + async function main(): Promise<void> { 816 + const args = process.argv.slice(2) 817 + const command = args[0] 818 + 819 + if (!command || command === "help" || command === "--help") { 820 + console.log(` 821 + at-run - Deploy bundles to AT Protocol PDS 502 822 503 823 Usage: 504 824 at-run login Login to your PDS 505 - at-run logout Clear saved session 825 + at-run logout Clear saved credentials 506 826 at-run whoami Show current user 507 827 at-run deploy <bundle.js> Deploy a bundle 508 828 at-run list List deployed bundles 509 829 at-run delete <name|rkey> Delete bundle(s) 510 830 831 + Runner (for hosting a runner): 832 + at-run runner init <endpoint> Initialize runner with keypair 833 + at-run runner info Show runner public key and config 834 + 835 + Secrets (encrypted with runner's public key): 836 + at-run secrets set <bundle> KEY=val... --runner <did> 837 + at-run secrets list [bundle] 838 + at-run secrets delete <bundle> --runner <did> 839 + 511 840 Version Options (for deploy): 512 841 --patch Bump patch version (default) 513 842 --minor Bump minor version 514 843 --major Bump major version 515 844 --version=x.y.z Set explicit version 516 845 517 - Collection: ${COLLECTION} 518 - 519 - Examples: 520 - at-run login 521 - at-run deploy ./dist/bundle.js # Bumps patch: 0.1.0 -> 0.1.1 522 - at-run deploy ./dist/bundle.js --minor # Bumps minor: 0.1.1 -> 0.2.0 523 - at-run deploy ./dist/bundle.js --major # Bumps major: 0.2.0 -> 1.0.0 524 - at-run deploy ./dist/bundle.js --version=2.0.0 525 - at-run list 526 - at-run delete my-api # Delete all versions 527 - at-run delete my-api-0-1-0 # Delete specific version 846 + Collections: 847 + Bundles: ${BUNDLE_COLLECTION} 848 + Runners: ${RUNNER_COLLECTION} 849 + Secrets: ${SECRETS_COLLECTION} 528 850 `) 529 - } 530 - 531 - // ============================================================================= 532 - // Main 533 - // ============================================================================= 534 - 535 - async function main(): Promise<void> { 536 - const args = process.argv.slice(2) 537 - 538 - if (args.length === 0 || args[0] === "help" || args[0] === "--help") { 539 - printHelp() 540 851 return 541 852 } 542 853 543 - const command = args[0] 544 854 const positional: string[] = [] 545 855 const options: Record<string, string | boolean> = {} 546 856 ··· 549 859 if (arg.startsWith("--")) { 550 860 const eqIndex = arg.indexOf("=") 551 861 if (eqIndex > 0) { 552 - const key = arg.slice(2, eqIndex) 553 - const value = arg.slice(eqIndex + 1) 554 - options[key] = value 862 + options[arg.slice(2, eqIndex)] = arg.slice(eqIndex + 1) 555 863 } else { 556 864 options[arg.slice(2)] = true 557 865 } ··· 564 872 case "login": 565 873 await login() 566 874 break 567 - 568 875 case "logout": 569 876 await logout() 570 877 break 571 - 572 878 case "whoami": 573 879 await whoami() 574 880 break 575 - 576 881 case "deploy": 577 882 if (positional.length === 0) { 578 - console.error("Usage: at-run deploy <bundle.js>") 883 + p.log.error("Usage: at-run deploy <bundle.js>") 579 884 process.exit(1) 580 885 } 581 886 await deploy(positional[0], { ··· 585 890 version: typeof options.version === "string" ? options.version : undefined, 586 891 }) 587 892 break 588 - 589 893 case "list": 590 894 await list() 591 895 break 592 - 593 896 case "delete": 594 897 if (positional.length === 0) { 595 - console.error("Usage: at-run delete <name|rkey>") 898 + p.log.error("Usage: at-run delete <name|rkey>") 596 899 process.exit(1) 597 900 } 598 901 await deleteBundle(positional[0]) 599 902 break 903 + case "secrets": { 904 + const subcommand = positional[0] 905 + const subArgs = positional.slice(1) 600 906 907 + switch (subcommand) { 908 + case "set": { 909 + if (subArgs.length < 2) { 910 + p.log.error("Usage: at-run secrets set <bundle> KEY=val... --runner <did>") 911 + process.exit(1) 912 + } 913 + const runnerDid = typeof options.runner === "string" ? options.runner : undefined 914 + if (!runnerDid) { 915 + p.log.error("--runner <did> is required") 916 + process.exit(1) 917 + } 918 + await secretsSet(subArgs[0], subArgs.slice(1), runnerDid) 919 + break 920 + } 921 + case "list": 922 + await secretsList(subArgs[0]) 923 + break 924 + case "delete": { 925 + if (subArgs.length < 1) { 926 + p.log.error("Usage: at-run secrets delete <bundle> --runner <did>") 927 + process.exit(1) 928 + } 929 + const runnerDid = typeof options.runner === "string" ? options.runner : undefined 930 + if (!runnerDid) { 931 + p.log.error("--runner <did> is required") 932 + process.exit(1) 933 + } 934 + await secretsDelete(subArgs[0], runnerDid) 935 + break 936 + } 937 + default: 938 + p.log.error("Usage: at-run secrets <set|list|delete>") 939 + process.exit(1) 940 + } 941 + break 942 + } 943 + case "runner": { 944 + const subcommand = positional[0] 945 + const subArgs = positional.slice(1) 946 + 947 + switch (subcommand) { 948 + case "init": { 949 + if (subArgs.length < 1) { 950 + p.log.error("Usage: at-run runner init <endpoint>") 951 + p.log.info("Example: at-run runner init https://my-runner.example.com") 952 + process.exit(1) 953 + } 954 + await runnerInit(subArgs[0]) 955 + break 956 + } 957 + case "info": 958 + await runnerInfo() 959 + break 960 + default: 961 + p.log.error("Usage: at-run runner <init|info>") 962 + process.exit(1) 963 + } 964 + break 965 + } 601 966 default: 602 - console.error(`Unknown command: ${command}`) 603 - printHelp() 967 + p.log.error(`Unknown command: ${command}`) 604 968 process.exit(1) 605 969 } 606 970 } 607 971 608 972 main().catch((err) => { 609 - console.error("Error:", err instanceof Error ? err.message : err) 973 + p.log.error(err instanceof Error ? err.message : String(err)) 610 974 process.exit(1) 611 975 })
+36 -2
packages/at-run/lexicons/dev.mainasara.at-run.beta.runner.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "Self-registration record for at-run runners (enables discoverability)", 7 + "description": "Self-registration record for at-run runners (enables discoverability and encrypted secrets)", 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["endpoint", "createdAt"], 11 + "required": ["endpoint", "publicKey", "createdAt"], 12 12 "properties": { 13 13 "endpoint": { 14 14 "type": "string", ··· 26 26 "description": "Description of the runner and its capabilities", 27 27 "maxLength": 1024 28 28 }, 29 + "publicKey": { 30 + "type": "string", 31 + "description": "Base64-encoded X25519 public key for encrypting secrets", 32 + "maxLength": 64 33 + }, 34 + "keyAlgorithm": { 35 + "type": "string", 36 + "description": "Key algorithm (default: x25519-xsalsa20-poly1305)", 37 + "maxLength": 64, 38 + "default": "x25519-xsalsa20-poly1305" 39 + }, 40 + "maxLimits": { 41 + "type": "ref", 42 + "ref": "#resourceLimits", 43 + "description": "Maximum resource limits this runner will grant" 44 + }, 29 45 "maxPermissions": { 30 46 "type": "ref", 31 47 "ref": "dev.mainasara.at-run.beta.bundle#permissions", ··· 41 57 "format": "datetime", 42 58 "description": "Timestamp of registration" 43 59 } 60 + } 61 + } 62 + }, 63 + "resourceLimits": { 64 + "type": "object", 65 + "description": "Resource limits for sandboxed execution", 66 + "properties": { 67 + "maxMemoryMB": { 68 + "type": "integer", 69 + "description": "Maximum heap memory in MB", 70 + "minimum": 32, 71 + "maximum": 4096 72 + }, 73 + "timeoutMs": { 74 + "type": "integer", 75 + "description": "Maximum execution time in milliseconds", 76 + "minimum": 1000, 77 + "maximum": 600000 44 78 } 45 79 } 46 80 }
+53
packages/at-run/lexicons/dev.mainasara.at-run.beta.secrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "dev.mainasara.at-run.beta.secrets", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Encrypted secrets for a bundle to be decrypted by a specific runner", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["bundle", "runner", "encrypted", "nonce", "ephemeralPublicKey", "createdAt"], 12 + "properties": { 13 + "bundle": { 14 + "type": "string", 15 + "description": "Name of the bundle these secrets are for", 16 + "maxLength": 128 17 + }, 18 + "runner": { 19 + "type": "string", 20 + "format": "did", 21 + "description": "DID of the runner that can decrypt these secrets" 22 + }, 23 + "encrypted": { 24 + "type": "string", 25 + "description": "Base64-encoded encrypted secrets (JSON object encrypted with runner's public key)", 26 + "maxLength": 16384 27 + }, 28 + "nonce": { 29 + "type": "string", 30 + "description": "Base64-encoded nonce used for encryption", 31 + "maxLength": 48 32 + }, 33 + "ephemeralPublicKey": { 34 + "type": "string", 35 + "description": "Base64-encoded ephemeral public key used for encryption", 36 + "maxLength": 64 37 + }, 38 + "algorithm": { 39 + "type": "string", 40 + "description": "Encryption algorithm (default: x25519-xsalsa20-poly1305)", 41 + "maxLength": 64, 42 + "default": "x25519-xsalsa20-poly1305" 43 + }, 44 + "createdAt": { 45 + "type": "string", 46 + "format": "datetime", 47 + "description": "Timestamp of creation" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+4 -1
packages/at-run/runner/package.json
··· 16 16 "@at-run/runtime": "workspace:*", 17 17 "@atproto/api": "^0.19.5", 18 18 "@atproto/identity": "^0.4.12", 19 - "@atproto/syntax": "^0.5.2" 19 + "@atproto/syntax": "^0.5.2", 20 + "hono": "^4.12.9", 21 + "tweetnacl": "^1.0.3", 22 + "tweetnacl-util": "^0.15.1" 20 23 }, 21 24 "keywords": ["atproto", "serverless", "runner"], 22 25 "license": "MIT",
+251
packages/at-run/runner/src/config.ts
··· 1 + /** 2 + * Runner configuration and keypair management 3 + */ 4 + 5 + import * as fs from "fs" 6 + import * as path from "path" 7 + import * as os from "os" 8 + import { 9 + generateKeyPair, 10 + decryptSecrets, 11 + type KeyPair, 12 + type Permissions, 13 + type ResourceLimits, 14 + } from "@at-run/runtime" 15 + 16 + const CONFIG_DIR = path.join(os.homedir(), ".at-run") 17 + const RUNNER_CONFIG_FILE = path.join(CONFIG_DIR, "runner.json") 18 + const RUNNER_KEY_FILE = path.join(CONFIG_DIR, "runner-key.json") 19 + 20 + export interface RunnerConfig { 21 + // Network 22 + port?: number 23 + host?: string 24 + 25 + // Identity (for fetching secrets) 26 + did?: string 27 + service?: string 28 + identifier?: string 29 + password?: string 30 + 31 + // Resource caps 32 + maxLimits?: ResourceLimits 33 + 34 + // Permission caps 35 + maxPermissions?: { 36 + net?: string[] | boolean 37 + env?: string[] | boolean 38 + read?: string[] | boolean 39 + write?: string[] | boolean 40 + run?: boolean 41 + } 42 + 43 + // Access control 44 + access?: { 45 + allowedDids?: string[] 46 + blockedDids?: string[] 47 + } 48 + 49 + // Dev options 50 + devMode?: boolean 51 + sandboxLocal?: boolean 52 + } 53 + 54 + export interface StoredKeyPair { 55 + publicKey: string 56 + secretKey: string 57 + createdAt: string 58 + } 59 + 60 + function ensureConfigDir(): void { 61 + if (!fs.existsSync(CONFIG_DIR)) { 62 + fs.mkdirSync(CONFIG_DIR, { mode: 0o700 }) 63 + } 64 + } 65 + 66 + /** 67 + * Load runner config from file or environment 68 + */ 69 + export function loadConfig(): RunnerConfig { 70 + const config: RunnerConfig = {} 71 + 72 + // Try loading from file 73 + const configPath = process.env.AT_RUN_CONFIG || RUNNER_CONFIG_FILE 74 + if (fs.existsSync(configPath)) { 75 + try { 76 + const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) 77 + Object.assign(config, fileConfig) 78 + } catch (err) { 79 + console.error(`Failed to load config from ${configPath}:`, err) 80 + } 81 + } 82 + 83 + // Also check cwd for at-run-runner.json 84 + const cwdConfig = path.join(process.cwd(), "at-run-runner.json") 85 + if (fs.existsSync(cwdConfig)) { 86 + try { 87 + const fileConfig = JSON.parse(fs.readFileSync(cwdConfig, "utf-8")) 88 + Object.assign(config, fileConfig) 89 + } catch (err) { 90 + console.error(`Failed to load config from ${cwdConfig}:`, err) 91 + } 92 + } 93 + 94 + // Environment overrides 95 + if (process.env.PORT) config.port = Number(process.env.PORT) 96 + if (process.env.HOST) config.host = process.env.HOST 97 + if (process.env.DEV === "true" || process.env.DEV === "1") config.devMode = true 98 + if (process.env.SANDBOX_LOCAL === "true" || process.env.SANDBOX_LOCAL === "1") config.sandboxLocal = true 99 + 100 + return config 101 + } 102 + 103 + /** 104 + * Get or generate runner keypair 105 + */ 106 + export function getOrCreateKeyPair(): StoredKeyPair { 107 + ensureConfigDir() 108 + 109 + if (fs.existsSync(RUNNER_KEY_FILE)) { 110 + try { 111 + return JSON.parse(fs.readFileSync(RUNNER_KEY_FILE, "utf-8")) 112 + } catch { 113 + // Regenerate if corrupted 114 + } 115 + } 116 + 117 + const keyPair = generateKeyPair() 118 + const stored: StoredKeyPair = { 119 + ...keyPair, 120 + createdAt: new Date().toISOString(), 121 + } 122 + 123 + fs.writeFileSync(RUNNER_KEY_FILE, JSON.stringify(stored, null, 2), { mode: 0o600 }) 124 + console.log(`Generated new runner keypair`) 125 + console.log(`Public key: ${stored.publicKey}`) 126 + 127 + return stored 128 + } 129 + 130 + /** 131 + * Get existing keypair or null 132 + */ 133 + export function getKeyPair(): StoredKeyPair | null { 134 + if (!fs.existsSync(RUNNER_KEY_FILE)) return null 135 + 136 + try { 137 + return JSON.parse(fs.readFileSync(RUNNER_KEY_FILE, "utf-8")) 138 + } catch { 139 + return null 140 + } 141 + } 142 + 143 + /** 144 + * Decrypt secrets using runner's private key 145 + */ 146 + export function decryptBundleSecrets( 147 + payload: { encrypted: string; nonce: string; ephemeralPublicKey: string }, 148 + keyPair: StoredKeyPair 149 + ): Record<string, string> { 150 + return decryptSecrets(payload, keyPair.secretKey) 151 + } 152 + 153 + /** 154 + * Check if a DID is allowed based on access config 155 + */ 156 + export function isDIDAllowed(did: string, config: RunnerConfig): boolean { 157 + if (!config.access) return true 158 + 159 + if (config.access.blockedDids?.includes(did)) return false 160 + if (config.access.allowedDids && !config.access.allowedDids.includes(did)) return false 161 + 162 + return true 163 + } 164 + 165 + /** 166 + * Filter permissions based on runner's max permissions 167 + */ 168 + export function filterPermissions( 169 + requested: Permissions, 170 + maxPerms: RunnerConfig["maxPermissions"] 171 + ): Permissions { 172 + if (!maxPerms) return requested 173 + 174 + const filtered: Permissions = {} 175 + 176 + // Net 177 + if (maxPerms.net === false) { 178 + filtered.net = [] 179 + } else if (maxPerms.net === true) { 180 + filtered.net = requested.net 181 + } else if (Array.isArray(maxPerms.net) && requested.net) { 182 + const allowedNets = maxPerms.net as string[] 183 + filtered.net = requested.net.filter((h) => 184 + allowedNets.some((allowed) => { 185 + if (allowed.startsWith("*.")) { 186 + return h.endsWith(allowed.slice(1)) 187 + } 188 + return h === allowed 189 + }) 190 + ) 191 + } 192 + 193 + // Env 194 + if (maxPerms.env === false) { 195 + filtered.env = [] 196 + } else if (maxPerms.env === true) { 197 + filtered.env = requested.env 198 + } else if (Array.isArray(maxPerms.env) && requested.env) { 199 + filtered.env = requested.env.filter((e) => (maxPerms.env as string[]).includes(e)) 200 + } 201 + 202 + // Read 203 + if (maxPerms.read === false) { 204 + filtered.read = [] 205 + } else if (maxPerms.read === true) { 206 + filtered.read = requested.read 207 + } else if (Array.isArray(maxPerms.read) && requested.read) { 208 + filtered.read = requested.read.filter((p) => 209 + (maxPerms.read as string[]).some((allowed) => p.startsWith(allowed)) 210 + ) 211 + } 212 + 213 + // Write 214 + if (maxPerms.write === false) { 215 + filtered.write = [] 216 + } else if (maxPerms.write === true) { 217 + filtered.write = requested.write 218 + } else if (Array.isArray(maxPerms.write) && requested.write) { 219 + filtered.write = requested.write.filter((p) => 220 + (maxPerms.write as string[]).some((allowed) => p.startsWith(allowed)) 221 + ) 222 + } 223 + 224 + return filtered 225 + } 226 + 227 + /** 228 + * Cap resource limits based on runner's max limits 229 + */ 230 + export function capLimits( 231 + requested: ResourceLimits | undefined, 232 + maxLimits: ResourceLimits | undefined 233 + ): ResourceLimits { 234 + const result: ResourceLimits = { ...requested } 235 + 236 + if (maxLimits?.maxMemoryMB !== undefined) { 237 + result.maxMemoryMB = Math.min( 238 + result.maxMemoryMB ?? Infinity, 239 + maxLimits.maxMemoryMB 240 + ) 241 + } 242 + 243 + if (maxLimits?.timeoutMs !== undefined) { 244 + result.timeoutMs = Math.min( 245 + result.timeoutMs ?? Infinity, 246 + maxLimits.timeoutMs 247 + ) 248 + } 249 + 250 + return result 251 + }
+260 -284
packages/at-run/runner/src/index.ts
··· 1 + #!/usr/bin/env bun 1 2 /** 2 3 * at-run runner: HTTP server that fetches and executes bundles from AT Protocol PDS 3 4 * 4 - * Supports two modes: 5 - * - Remote: /at://did:plc:xxx/collection/rkey/endpoint (fetches from PDS, sandboxed) 6 - * - Local: /local/path/to/bundle.js/endpoint (dev only, optionally sandboxed) 7 - * 8 - * Security: Remote bundles are ALWAYS executed in a Deno sandbox with 9 - * permissions declared in the bundle's manifest. 5 + * Routes: 6 + * - /at://did/collection/rkey/endpoint - Execute by AT URI (sandboxed) 7 + * - /bundle/did/name/version/endpoint - Execute by name/version (sandboxed) 8 + * - /local/path/to/bundle.js/endpoint - Local execution (dev only) 10 9 */ 11 10 12 - import { isEndpoint, type Endpoint, type Permissions, isManifest } from "@at-run/runtime" 11 + import { Hono } from "hono" 12 + import { cors } from "hono/cors" 13 + import { isEndpoint, type Endpoint } from "@at-run/runtime" 13 14 import { AtpAgent } from "@atproto/api" 14 15 import { IdResolver } from "@atproto/identity" 15 16 import { AtUri } from "@atproto/syntax" ··· 19 20 checkDenoAvailable, 20 21 type BundleInfo, 21 22 } from "./sandbox" 23 + import { 24 + loadConfig, 25 + getOrCreateKeyPair, 26 + isDIDAllowed, 27 + filterPermissions, 28 + capLimits, 29 + type RunnerConfig, 30 + type StoredKeyPair, 31 + } from "./config" 32 + import { fetchSecrets } from "./secrets" 22 33 23 - const PORT = process.env.PORT || 3000 24 - const DEV_MODE = process.env.DEV === "true" || process.env.DEV === "1" 25 - const SANDBOX_LOCAL = process.env.SANDBOX_LOCAL === "true" || process.env.SANDBOX_LOCAL === "1" 34 + // Load config 35 + const config = loadConfig() 36 + const PORT = config.port || Number(process.env.PORT) || 3000 37 + const DEV_MODE = config.devMode ?? false 38 + const SANDBOX_LOCAL = config.sandboxLocal ?? false 26 39 const BUNDLE_COLLECTION = "dev.mainasara.at-run.beta.bundle" 27 40 28 41 // Identity resolver for DID -> PDS resolution 29 42 const idResolver = new IdResolver() 30 43 44 + // Runner keypair (for decrypting secrets) 45 + let runnerKeyPair: StoredKeyPair | null = null 46 + let runnerDid: string | null = config.did || null 47 + 31 48 // Caches 32 49 const bundleInfoCache = new Map<string, BundleInfo>() 33 50 const bundlePathCache = new Map<string, string>() 34 51 const localEndpointsCache = new Map<string, Record<string, Endpoint>>() 35 - const versionResolutionCache = new Map<string, string>() // "did/name/version" -> AT URI 52 + const versionResolutionCache = new Map<string, string>() 36 53 37 54 let denoAvailable: boolean | null = null 38 55 39 - /** 40 - * Check Deno availability (cached) 41 - */ 56 + // ============================================================================= 57 + // Utilities 58 + // ============================================================================= 59 + 42 60 async function ensureDenoAvailable(): Promise<void> { 43 61 if (denoAvailable === null) { 44 62 denoAvailable = await checkDenoAvailable() 45 63 } 46 64 if (!denoAvailable) { 47 - throw new Error( 48 - "Deno is required for sandboxed execution. Install from https://deno.land" 49 - ) 65 + throw new Error("Deno is required for sandboxed execution. Install from https://deno.land") 50 66 } 51 67 } 52 68 53 69 interface BundleRecord { 54 70 name: string 55 71 version: string 56 - blob: { ref: { $link: string } } 57 72 } 58 73 59 - /** 60 - * Resolve a bundle by name and version to its AT URI 61 - * Supports "latest" as version to get the highest semver 62 - */ 63 - async function resolveBundleUri( 64 - did: string, 65 - name: string, 66 - version: string 67 - ): Promise<string> { 74 + async function resolveBundleUri(did: string, name: string, version: string): Promise<string> { 68 75 const cacheKey = `${did}/${name}/${version}` 69 76 const cached = versionResolutionCache.get(cacheKey) 70 - if (cached && !DEV_MODE) { 71 - return cached 72 - } 77 + if (cached && !DEV_MODE) return cached 73 78 74 79 const didDoc = await idResolver.did.resolve(did) 75 - if (!didDoc) { 76 - throw new Error(`Failed to resolve DID: ${did}`) 77 - } 80 + if (!didDoc) throw new Error(`Failed to resolve DID: ${did}`) 78 81 79 82 const pdsUrl = didDoc.service?.find( 80 83 (s) => s.id === "#atproto_pds" || s.id === `${did}#atproto_pds` 81 84 )?.serviceEndpoint as string | undefined 82 85 83 - if (!pdsUrl) { 84 - throw new Error("No PDS service found in DID document") 85 - } 86 + if (!pdsUrl) throw new Error("No PDS service found in DID document") 86 87 87 88 const agent = new AtpAgent({ service: pdsUrl }) 88 - 89 89 const response = await agent.com.atproto.repo.listRecords({ 90 90 repo: did, 91 91 collection: BUNDLE_COLLECTION, ··· 93 93 }) 94 94 95 95 const matchingRecords: Array<{ uri: string; version: string }> = [] 96 - 97 96 for (const record of response.data.records) { 98 97 const value = record.value as unknown as BundleRecord 99 98 if (value.name === name) { ··· 101 100 } 102 101 } 103 102 104 - if (matchingRecords.length === 0) { 105 - throw new Error(`Bundle not found: ${name}`) 106 - } 103 + if (matchingRecords.length === 0) throw new Error(`Bundle not found: ${name}`) 107 104 108 105 let targetUri: string 109 - 110 106 if (version === "latest") { 111 - // Find highest semver 112 107 matchingRecords.sort((a, b) => { 113 108 const [aMajor, aMinor, aPatch] = a.version.split(".").map(Number) 114 109 const [bMajor, bMinor, bPatch] = b.version.split(".").map(Number) ··· 118 113 }) 119 114 targetUri = matchingRecords[0].uri 120 115 } else { 121 - // Find exact version 122 116 const match = matchingRecords.find((r) => r.version === version) 123 117 if (!match) { 124 - throw new Error( 125 - `Version ${version} not found for bundle ${name}. Available: ${matchingRecords.map((r) => r.version).join(", ")}` 126 - ) 118 + throw new Error(`Version ${version} not found. Available: ${matchingRecords.map((r) => r.version).join(", ")}`) 127 119 } 128 120 targetUri = match.uri 129 121 } ··· 132 124 return targetUri 133 125 } 134 126 135 - /** 136 - * Fetch bundle source from AT Protocol PDS 137 - */ 138 127 async function fetchBundleFromPds(atUriStr: string): Promise<string> { 139 128 const atUri = new AtUri(atUriStr) 140 129 141 130 const didDoc = await idResolver.did.resolve(atUri.host) 142 - if (!didDoc) { 143 - throw new Error(`Failed to resolve DID: ${atUri.host}`) 144 - } 131 + if (!didDoc) throw new Error(`Failed to resolve DID: ${atUri.host}`) 145 132 146 133 const pdsUrl = didDoc.service?.find( 147 134 (s) => s.id === "#atproto_pds" || s.id === `${atUri.host}#atproto_pds` 148 135 )?.serviceEndpoint as string | undefined 149 136 150 - if (!pdsUrl) { 151 - throw new Error("No PDS service found in DID document") 152 - } 137 + if (!pdsUrl) throw new Error("No PDS service found in DID document") 153 138 154 139 const agent = new AtpAgent({ service: pdsUrl }) 155 - 156 140 const record = await agent.com.atproto.repo.getRecord({ 157 141 repo: atUri.host, 158 142 collection: atUri.collection, ··· 160 144 }) 161 145 162 146 const value = record.data.value as { blob?: { ref: { toString(): string } } } 163 - 164 - if (!value?.blob?.ref) { 165 - throw new Error("Record does not contain a blob reference") 166 - } 147 + if (!value?.blob?.ref) throw new Error("Record does not contain a blob reference") 167 148 168 - // The SDK deserializes blob.ref as a CID object - use toString() to get the CID string 169 149 const cid = value.blob.ref.toString() 150 + if (DEV_MODE) console.log("[fetchBundle] Blob CID:", cid) 170 151 171 - if (DEV_MODE) { 172 - console.log("[fetchBundle] Blob CID:", cid) 173 - } 174 - 175 - const blobRes = await agent.com.atproto.sync.getBlob({ 176 - did: atUri.host, 177 - cid, 178 - }) 179 - 152 + const blobRes = await agent.com.atproto.sync.getBlob({ did: atUri.host, cid }) 180 153 return new TextDecoder().decode(blobRes.data) 181 154 } 182 155 ··· 185 158 | { type: "local"; path: string } 186 159 | { type: "named"; did: string; name: string; version: string } 187 160 188 - /** 189 - * Get bundle path on disk, fetching from PDS if needed 190 - */ 191 161 async function getBundlePath(ref: BundleRef): Promise<string> { 192 - if (ref.type === "local") { 193 - return ref.path 194 - } 162 + if (ref.type === "local") return ref.path 195 163 196 - // Resolve named bundles to AT URI first 197 - let atUri: string 198 - if (ref.type === "named") { 199 - atUri = await resolveBundleUri(ref.did, ref.name, ref.version) 200 - } else { 201 - atUri = ref.uri 202 - } 164 + const atUri = ref.type === "named" 165 + ? await resolveBundleUri(ref.did, ref.name, ref.version) 166 + : ref.uri 203 167 204 - const cacheKey = atUri 205 - const cached = bundlePathCache.get(cacheKey) 206 - if (cached && (await Bun.file(cached).exists())) { 207 - return cached 208 - } 168 + const cached = bundlePathCache.get(atUri) 169 + if (cached && (await Bun.file(cached).exists())) return cached 209 170 210 171 const source = await fetchBundleFromPds(atUri) 211 172 const bundlePath = `/tmp/atrun-${Bun.hash(atUri)}.js` 212 173 await Bun.write(bundlePath, source) 213 - bundlePathCache.set(cacheKey, bundlePath) 174 + bundlePathCache.set(atUri, bundlePath) 214 175 215 176 return bundlePath 216 177 } 217 178 218 - /** 219 - * Get bundle info (permissions + endpoint list) 220 - */ 221 179 async function getBundleInfo(bundlePath: string): Promise<BundleInfo> { 222 180 const cached = bundleInfoCache.get(bundlePath) 223 - if (cached && !DEV_MODE) { 224 - return cached 225 - } 181 + if (cached && !DEV_MODE) return cached 226 182 227 183 const info = await extractBundleInfo(bundlePath) 228 184 bundleInfoCache.set(bundlePath, info) 229 - 230 185 return info 231 186 } 232 187 233 - /** 234 - * Load bundle directly (for local dev without sandbox) 235 - */ 236 188 async function loadBundleUnsafe(path: string): Promise<Record<string, Endpoint>> { 237 189 const cached = localEndpointsCache.get(path) 238 - if (cached && !DEV_MODE) { 239 - return cached 240 - } 190 + if (cached && !DEV_MODE) return cached 241 191 242 192 const importPath = DEV_MODE ? `${path}?t=${Date.now()}` : path 243 193 const module = await import(importPath) 244 194 245 195 const endpoints: Record<string, Endpoint> = {} 246 196 for (const [name, value] of Object.entries(module)) { 247 - if (isEndpoint(value)) { 248 - endpoints[name] = value 249 - } 197 + if (isEndpoint(value)) endpoints[name] = value 250 198 } 251 199 252 200 localEndpointsCache.set(path, endpoints) 253 201 return endpoints 254 202 } 255 203 256 - interface ParsedRequest { 204 + // ============================================================================= 205 + // Request Handler 206 + // ============================================================================= 207 + 208 + interface ExecuteContext { 257 209 ref: BundleRef 258 - endpoint: string 210 + endpointName: string 211 + args: unknown 212 + useSandbox: boolean 213 + /** DID of the bundle author (for fetching secrets) */ 214 + authorDid?: string 215 + /** Bundle name (for fetching secrets) */ 216 + bundleName?: string 259 217 } 260 218 261 - function parseRequestPath(path: string): ParsedRequest | null { 262 - const trimmed = path.startsWith("/") ? path.slice(1) : path 219 + async function executeEndpoint(ctx: ExecuteContext): Promise<Response> { 220 + const { ref, endpointName, args, useSandbox, authorDid, bundleName } = ctx 221 + const bundlePath = await getBundlePath(ref) 222 + let result: unknown 263 223 264 - // AT URI format: at://did/collection/rkey/endpoint 265 - const atMatch = trimmed.match(/^(at:\/\/[^/]+\/[^/]+\/[^/]+)\/(.+)$/) 266 - if (atMatch) { 267 - return { 268 - ref: { type: "at", uri: atMatch[1] }, 269 - endpoint: atMatch[2], 224 + if (useSandbox) { 225 + await ensureDenoAvailable() 226 + const info = await getBundleInfo(bundlePath) 227 + 228 + // Apply runner's permission and limit caps 229 + const permissions = filterPermissions(info.permissions, config.maxPermissions) 230 + const limits = capLimits(info.limits, config.maxLimits) 231 + 232 + if (DEV_MODE) { 233 + console.log(`[sandbox] Bundle: ${bundlePath}`) 234 + console.log(`[sandbox] Permissions:`, permissions) 235 + console.log(`[sandbox] Limits:`, limits) 236 + console.log(`[sandbox] Endpoints:`, Object.keys(info.endpoints)) 270 237 } 271 - } 272 238 273 - // Named bundle format: bundle/did/name/version/endpoint 274 - // e.g., bundle/did:plc:xxx/my-api/0.1.0/getVideos 275 - // e.g., bundle/did:plc:xxx/my-api/latest/getVideos 276 - const namedMatch = trimmed.match(/^bundle\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/) 277 - if (namedMatch) { 278 - return { 279 - ref: { 280 - type: "named", 281 - did: namedMatch[1], 282 - name: namedMatch[2], 283 - version: namedMatch[3], 284 - }, 285 - endpoint: namedMatch[4], 239 + if (!info.endpoints[endpointName]) { 240 + return Response.json( 241 + { error: `Endpoint not found: ${endpointName}`, available: Object.keys(info.endpoints) }, 242 + { status: 404 } 243 + ) 286 244 } 287 - } 288 245 289 - // Local format: local/path/to/bundle.js/endpoint 290 - const localMatch = trimmed.match(/^local\/(.+\.js)\/(.+)$/) 291 - if (localMatch) { 292 - const localPath = localMatch[1].startsWith("/") 293 - ? localMatch[1] 294 - : `/${localMatch[1]}` 295 - return { 296 - ref: { type: "local", path: localPath }, 297 - endpoint: localMatch[2], 246 + // Fetch secrets if runner has a keypair and we know the author 247 + let secrets: Record<string, string> = {} 248 + if (runnerKeyPair && runnerDid && authorDid && bundleName) { 249 + try { 250 + secrets = await fetchSecrets(authorDid, bundleName, runnerDid, runnerKeyPair) 251 + if (DEV_MODE && Object.keys(secrets).length > 0) { 252 + console.log(`[sandbox] Secrets loaded: ${Object.keys(secrets).join(", ")}`) 253 + } 254 + } catch (err) { 255 + if (DEV_MODE) { 256 + console.log(`[sandbox] No secrets found or error: ${err instanceof Error ? err.message : err}`) 257 + } 258 + } 298 259 } 299 - } 300 260 301 - return null 302 - } 261 + const endpointInfo = info.endpoints[endpointName] 262 + result = await executeInSandbox({ 263 + bundlePath, 264 + endpointName, 265 + args, 266 + globalPermissions: permissions, 267 + endpointPermissions: endpointInfo.permissions, 268 + globalLimits: limits, 269 + endpointLimits: endpointInfo.limits, 270 + secrets, 271 + }) 272 + } else { 273 + if (DEV_MODE) console.log(`[unsafe] Loading bundle from ${bundlePath}`) 303 274 304 - function corsHeaders(): HeadersInit { 305 - return { "Access-Control-Allow-Origin": "*" } 306 - } 275 + const endpoints = await loadBundleUnsafe(bundlePath) 276 + if (DEV_MODE) console.log(`[unsafe] Endpoints:`, Object.keys(endpoints)) 307 277 308 - const server = Bun.serve({ 309 - port: PORT, 278 + const endpoint = endpoints[endpointName] 279 + if (!endpoint) { 280 + return Response.json( 281 + { error: `Endpoint not found: ${endpointName}`, available: Object.keys(endpoints) }, 282 + { status: 404 } 283 + ) 284 + } 310 285 311 - async fetch(req) { 312 - const url = new URL(req.url) 286 + result = await endpoint.handler(args) 287 + } 313 288 314 - // Health check 315 - if (url.pathname === "/health") { 316 - return new Response("ok") 317 - } 289 + if (result instanceof Response) return result 290 + return Response.json(result) 291 + } 318 292 319 - // CORS preflight 320 - if (req.method === "OPTIONS") { 321 - return new Response(null, { 322 - headers: { 323 - "Access-Control-Allow-Origin": "*", 324 - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 325 - "Access-Control-Allow-Headers": "Content-Type", 326 - }, 327 - }) 293 + async function parseArgs(c: { req: { method: string; json: () => Promise<unknown>; query: (k: string) => string | undefined; queries: () => Record<string, string[]> } }): Promise<unknown> { 294 + if (c.req.method === "POST") { 295 + try { 296 + return await c.req.json() 297 + } catch { 298 + return {} 328 299 } 300 + } 329 301 330 - // Parse path 331 - const parsed = parseRequestPath(url.pathname) 332 - if (!parsed) { 333 - const expected = [ 334 - "/at://did:plc:xxx/collection/rkey/endpointName", 335 - "/bundle/did:plc:xxx/bundle-name/version/endpointName", 336 - "/bundle/did:plc:xxx/bundle-name/latest/endpointName", 337 - ] 338 - if (DEV_MODE) { 339 - expected.push("/local/path/to/bundle.js/endpointName") 340 - } 341 - return Response.json( 342 - { error: "Invalid path format", expected }, 343 - { status: 400, headers: corsHeaders() } 344 - ) 302 + const argsParam = c.req.query("args") 303 + if (argsParam) { 304 + try { 305 + return JSON.parse(argsParam) 306 + } catch { 307 + return {} 345 308 } 309 + } 346 310 347 - // Block local bundles in production 348 - if (parsed.ref.type === "local" && !DEV_MODE) { 349 - return Response.json( 350 - { error: "Local bundle execution is disabled. Set DEV=true to enable." }, 351 - { status: 403, headers: corsHeaders() } 352 - ) 311 + const queries = c.req.queries() 312 + const args: Record<string, string> = {} 313 + for (const [key, values] of Object.entries(queries)) { 314 + if (key !== "args" && values.length > 0) { 315 + args[key] = values[0] 353 316 } 317 + } 318 + return Object.keys(args).length > 0 ? args : {} 319 + } 354 320 355 - // Determine if we need sandboxing (remote bundles always sandboxed) 356 - const useSandbox = parsed.ref.type === "at" || parsed.ref.type === "named" || SANDBOX_LOCAL 321 + // ============================================================================= 322 + // App 323 + // ============================================================================= 357 324 358 - try { 359 - // Parse request args 360 - let args: unknown = {} 361 - if (req.method === "POST") { 362 - const contentType = req.headers.get("content-type") 363 - if (contentType?.includes("application/json")) { 364 - args = await req.json() 365 - } 366 - } else if (req.method === "GET") { 367 - const argsParam = url.searchParams.get("args") 368 - if (argsParam) { 369 - args = JSON.parse(argsParam) 370 - } else { 371 - const queryArgs: Record<string, string> = {} 372 - for (const [key, value] of url.searchParams) { 373 - queryArgs[key] = value 374 - } 375 - if (Object.keys(queryArgs).length > 0) { 376 - args = queryArgs 377 - } 378 - } 379 - } 325 + const app = new Hono() 380 326 381 - // Get bundle path 382 - const bundlePath = await getBundlePath(parsed.ref) 327 + app.use("*", cors()) 383 328 384 - let result: unknown 329 + app.get("/health", (c) => c.text("ok")) 385 330 386 - if (useSandbox) { 387 - // SANDBOXED EXECUTION 388 - await ensureDenoAvailable() 331 + // AT URI format: /at://did/collection/rkey/endpoint 332 + app.all("/at\\://:did/:collection/:rkey/:endpoint{.+}", async (c) => { 333 + const { did, collection, rkey, endpoint } = c.req.param() 334 + const atUri = `at://${did}/${collection}/${rkey}` 335 + const args = await parseArgs(c) 389 336 390 - // Extract bundle info (permissions, endpoints) 391 - const info = await getBundleInfo(bundlePath) 337 + // Access control 338 + if (!isDIDAllowed(did, config)) { 339 + return c.json({ error: "Access denied for this DID" }, 403) 340 + } 392 341 393 - if (DEV_MODE) { 394 - console.log(`[sandbox] Bundle: ${bundlePath}`) 395 - console.log(`[sandbox] Permissions:`, info.permissions) 396 - console.log(`[sandbox] Endpoints:`, Object.keys(info.endpoints)) 397 - } 342 + try { 343 + // Extract bundle name from rkey (format: name-x-y-z) 344 + const bundleName = rkey.replace(/-\d+-\d+-\d+$/, "") 345 + return await executeEndpoint({ 346 + ref: { type: "at", uri: atUri }, 347 + endpointName: endpoint, 348 + args, 349 + useSandbox: true, 350 + authorDid: did, 351 + bundleName, 352 + }) 353 + } catch (error) { 354 + console.error("Error:", error) 355 + return c.json({ error: error instanceof Error ? error.message : "Unknown error" }, 500) 356 + } 357 + }) 398 358 399 - // Check endpoint exists 400 - if (!info.endpoints[parsed.endpoint]) { 401 - return Response.json( 402 - { 403 - error: `Endpoint not found: ${parsed.endpoint}`, 404 - available: Object.keys(info.endpoints), 405 - }, 406 - { status: 404, headers: corsHeaders() } 407 - ) 408 - } 359 + // Named bundle: /bundle/did/name/version/endpoint 360 + app.all("/bundle/:did/:name/:version/:endpoint{.+}", async (c) => { 361 + const { did, name, version, endpoint } = c.req.param() 362 + const args = await parseArgs(c) 409 363 410 - // Execute in sandbox 411 - const endpointPerms = info.endpoints[parsed.endpoint].permissions 412 - result = await executeInSandbox( 413 - bundlePath, 414 - parsed.endpoint, 415 - args, 416 - info.permissions, 417 - endpointPerms 418 - ) 419 - } else { 420 - // UNSANDBOXED EXECUTION (local dev only) 421 - if (DEV_MODE) { 422 - console.log(`[unsafe] Loading bundle from ${bundlePath}`) 423 - } 364 + // Access control 365 + if (!isDIDAllowed(did, config)) { 366 + return c.json({ error: "Access denied for this DID" }, 403) 367 + } 424 368 425 - const endpoints = await loadBundleUnsafe(bundlePath) 369 + try { 370 + return await executeEndpoint({ 371 + ref: { type: "named", did, name, version }, 372 + endpointName: endpoint, 373 + args, 374 + useSandbox: true, 375 + authorDid: did, 376 + bundleName: name, 377 + }) 378 + } catch (error) { 379 + console.error("Error:", error) 380 + return c.json({ error: error instanceof Error ? error.message : "Unknown error" }, 500) 381 + } 382 + }) 426 383 427 - if (DEV_MODE) { 428 - console.log(`[unsafe] Endpoints:`, Object.keys(endpoints)) 429 - } 384 + // Local bundle (dev only): /local/path/to/bundle.js/endpoint 385 + app.all("/local/*", async (c) => { 386 + if (!DEV_MODE) { 387 + return c.json({ error: "Local bundle execution is disabled. Set DEV=true to enable." }, 403) 388 + } 430 389 431 - const endpoint = endpoints[parsed.endpoint] 432 - if (!endpoint) { 433 - return Response.json( 434 - { 435 - error: `Endpoint not found: ${parsed.endpoint}`, 436 - available: Object.keys(endpoints), 437 - }, 438 - { status: 404, headers: corsHeaders() } 439 - ) 440 - } 390 + const path = c.req.path.slice(7) // Remove "/local/" 391 + const match = path.match(/^(.+\.js)\/(.+)$/) 392 + if (!match) { 393 + return c.json({ error: "Invalid local path format. Expected: /local/path/to/bundle.js/endpoint" }, 400) 394 + } 441 395 442 - result = await endpoint.handler(args) 443 - } 396 + const [, bundlePath, endpoint] = match 397 + const fullPath = bundlePath.startsWith("/") ? bundlePath : `/${bundlePath}` 398 + const args = await parseArgs(c) 444 399 445 - // Return response with CORS headers 446 - if (result instanceof Response) { 447 - const headers = new Headers(result.headers) 448 - headers.set("Access-Control-Allow-Origin", "*") 449 - return new Response(result.body, { 450 - status: result.status, 451 - statusText: result.statusText, 452 - headers, 453 - }) 454 - } 400 + try { 401 + return await executeEndpoint({ 402 + ref: { type: "local", path: fullPath }, 403 + endpointName: endpoint, 404 + args, 405 + useSandbox: SANDBOX_LOCAL, 406 + }) 407 + } catch (error) { 408 + console.error("Error:", error) 409 + return c.json({ error: error instanceof Error ? error.message : "Unknown error" }, 500) 410 + } 411 + }) 455 412 456 - return Response.json(result, { headers: corsHeaders() }) 457 - } catch (error) { 458 - console.error("Error executing endpoint:", error) 459 - return Response.json( 460 - { error: error instanceof Error ? error.message : "Unknown error" }, 461 - { status: 500, headers: corsHeaders() } 462 - ) 463 - } 464 - }, 413 + // Root - show available routes 414 + app.all("*", (c) => { 415 + const routes = [ 416 + "/at://did:plc:xxx/collection/rkey/endpoint", 417 + "/bundle/did:plc:xxx/bundle-name/version/endpoint", 418 + "/bundle/did:plc:xxx/bundle-name/latest/endpoint", 419 + ] 420 + if (DEV_MODE) routes.push("/local/path/to/bundle.js/endpoint") 421 + 422 + return c.json({ error: "Invalid path", routes }, 400) 465 423 }) 466 424 467 - console.log(`at-run runner listening on http://localhost:${server.port}`) 425 + // ============================================================================= 426 + // Start 427 + // ============================================================================= 428 + 429 + // Initialize runner keypair if DID is configured (for secrets decryption) 430 + if (runnerDid) { 431 + runnerKeyPair = getOrCreateKeyPair() 432 + console.log(`Runner DID: ${runnerDid}`) 433 + console.log(`Public key: ${runnerKeyPair.publicKey}`) 434 + } 435 + 436 + console.log(`at-run runner listening on http://localhost:${PORT}`) 468 437 469 438 if (DEV_MODE) { 470 - console.warn( 471 - "\x1b[33m⚠️ WARNING: DEV mode enabled - local file execution allowed\x1b[0m" 472 - ) 439 + console.warn("\x1b[33m WARNING: DEV mode enabled - local file execution allowed\x1b[0m") 473 440 if (SANDBOX_LOCAL) { 474 441 console.log(" Local bundles will be sandboxed (SANDBOX_LOCAL=true)") 475 442 } else { 476 443 console.log(" Local bundles run UNSANDBOXED (set SANDBOX_LOCAL=true to sandbox)") 477 444 } 478 445 } 446 + 447 + if (config.access?.allowedDids) { 448 + console.log(`Access restricted to: ${config.access.allowedDids.join(", ")}`) 449 + } 450 + 451 + export default { 452 + port: PORT, 453 + fetch: app.fetch, 454 + }
+110 -45
packages/at-run/runner/src/sandbox.ts
··· 7 7 8 8 import { 9 9 type Permissions, 10 + type ResourceLimits, 10 11 intersectPermissions, 12 + intersectLimits, 11 13 permissionsToDenoCLI, 14 + DEFAULT_LIMITS, 12 15 } from "@at-run/runtime" 13 16 import * as crypto from "crypto" 14 17 import * as fs from "fs" 15 18 16 19 export interface BundleInfo { 17 20 permissions: Permissions 18 - endpoints: Record<string, { permissions?: Permissions }> 21 + limits?: ResourceLimits 22 + endpoints: Record<string, { permissions?: Permissions; limits?: ResourceLimits }> 19 23 } 20 24 21 25 interface SandboxResult { ··· 27 31 body: string 28 32 } 29 33 error?: string 34 + validationError?: string 30 35 } 31 36 32 37 function generateResultPath(): string { ··· 46 51 47 52 const info = { 48 53 permissions: {}, 54 + limits: undefined, 49 55 endpoints: {} 50 56 }; 51 57 ··· 53 59 if (value && typeof value === "object" && "__atrun" in value) { 54 60 if (value.__atrun === "manifest") { 55 61 info.permissions = value.permissions || {}; 62 + info.limits = value.limits; 56 63 } else if (value.__atrun === "endpoint") { 57 - info.endpoints[name] = { permissions: value.permissions }; 64 + info.endpoints[name] = { 65 + permissions: value.permissions, 66 + limits: value.limits, 67 + }; 58 68 } 59 69 } 60 70 } ··· 83 93 const stderr = await new Response(proc.stderr).text() 84 94 const exitCode = await proc.exited 85 95 86 - // Log any stdout/stderr for debugging (won't affect result) 87 - if (stdout.trim()) { 88 - console.log("[sandbox:extract:stdout]", stdout.trim()) 89 - } 90 - if (stderr.trim()) { 91 - console.error("[sandbox:extract:stderr]", stderr.trim()) 92 - } 96 + if (stdout.trim()) console.log("[sandbox:extract:stdout]", stdout.trim()) 97 + if (stderr.trim()) console.error("[sandbox:extract:stderr]", stderr.trim()) 93 98 94 99 if (exitCode !== 0) { 95 - // Clean up result file if it exists 96 100 try { fs.unlinkSync(resultPath) } catch {} 97 101 throw new Error(`Failed to extract bundle info: ${stderr || "Unknown error"}`) 98 102 } 99 103 100 - // Read result from temp file 101 104 let result: SandboxResult 102 105 try { 103 106 const resultJson = fs.readFileSync(resultPath, "utf-8") ··· 105 108 } catch (err) { 106 109 throw new Error(`Failed to read sandbox result: ${err instanceof Error ? err.message : err}`) 107 110 } finally { 108 - // Clean up 109 111 try { fs.unlinkSync(resultPath) } catch {} 110 112 } 111 113 ··· 116 118 return result.data as BundleInfo 117 119 } 118 120 121 + export interface ExecutionOptions { 122 + bundlePath: string 123 + endpointName: string 124 + args: unknown 125 + globalPermissions: Permissions 126 + endpointPermissions?: Permissions 127 + globalLimits?: ResourceLimits 128 + endpointLimits?: ResourceLimits 129 + /** Secrets to inject as environment variables */ 130 + secrets?: Record<string, string> 131 + } 132 + 119 133 /** 120 134 * Execute an endpoint in a sandboxed Deno process 121 135 */ 122 - export async function executeInSandbox( 123 - bundlePath: string, 124 - endpointName: string, 125 - args: unknown, 126 - globalPermissions: Permissions, 127 - endpointPermissions?: Permissions 128 - ): Promise<unknown> { 136 + export async function executeInSandbox(options: ExecutionOptions): Promise<unknown> { 137 + const { 138 + bundlePath, 139 + endpointName, 140 + args, 141 + globalPermissions, 142 + endpointPermissions, 143 + globalLimits, 144 + endpointLimits, 145 + secrets = {}, 146 + } = options 147 + 129 148 const resultPath = generateResultPath() 130 149 131 - // Compute effective permissions 150 + // Compute effective permissions and limits 132 151 const effectivePerms = intersectPermissions(globalPermissions, endpointPermissions) 152 + const effectiveLimits = intersectLimits(globalLimits, endpointLimits) 133 153 134 154 // Build Deno permission flags 135 155 const permFlags = permissionsToDenoCLI(effectivePerms) ··· 141 161 } 142 162 permFlags.push(`--allow-write=${resultPath}`) 143 163 164 + // The execute script with input validation support 144 165 const executeScript = ` 145 166 let result; 146 167 try { ··· 151 172 throw new Error("Endpoint not found: ${endpointName}"); 152 173 } 153 174 154 - const args = ${JSON.stringify(args)}; 175 + let args = ${JSON.stringify(args)}; 176 + 177 + // Validate input if schema is defined 178 + if (endpoint.input) { 179 + // Import valibot dynamically (it's bundled with the endpoint) 180 + const { parse } = await import("npm:valibot@1"); 181 + try { 182 + args = parse(endpoint.input, args); 183 + } catch (validationErr) { 184 + result = { 185 + success: false, 186 + validationError: validationErr.message || "Input validation failed" 187 + }; 188 + await Deno.writeTextFile("${resultPath}", JSON.stringify(result)); 189 + Deno.exit(0); 190 + } 191 + } 192 + 155 193 const handlerResult = await endpoint.handler(args); 156 194 157 195 // Handle Response objects ··· 179 217 await Deno.writeTextFile("${resultPath}", JSON.stringify(result)); 180 218 ` 181 219 182 - const proc = Bun.spawn( 183 - [ 184 - "deno", 185 - "run", 186 - "--quiet", 187 - "--no-prompt", 188 - ...permFlags, 189 - "-", 190 - ], 191 - { 192 - stdin: new TextEncoder().encode(executeScript), 193 - stdout: "pipe", 194 - stderr: "pipe", 220 + // Build Deno command with resource limits 221 + const denoArgs = [ 222 + "deno", 223 + "run", 224 + "--quiet", 225 + "--no-prompt", 226 + `--v8-flags=--max-old-space-size=${effectiveLimits.maxMemoryMB}`, 227 + ...permFlags, 228 + "-", 229 + ] 230 + 231 + // Merge secrets into environment (only allowed env vars) 232 + const allowedEnv = effectivePerms.env || [] 233 + const env: Record<string, string> = {} 234 + for (const [key, value] of Object.entries(secrets)) { 235 + if (allowedEnv.includes(key)) { 236 + env[key] = value 195 237 } 196 - ) 238 + } 239 + 240 + const proc = Bun.spawn(denoArgs, { 241 + stdin: new TextEncoder().encode(executeScript), 242 + stdout: "pipe", 243 + stderr: "pipe", 244 + env: { ...process.env, ...env }, 245 + }) 246 + 247 + // Set up timeout 248 + const timeoutPromise = new Promise<"timeout">((resolve) => { 249 + setTimeout(() => resolve("timeout"), effectiveLimits.timeoutMs) 250 + }) 251 + 252 + const exitPromise = proc.exited 253 + 254 + const raceResult = await Promise.race([exitPromise, timeoutPromise]) 255 + 256 + if (raceResult === "timeout") { 257 + proc.kill() 258 + try { fs.unlinkSync(resultPath) } catch {} 259 + throw new Error(`Execution timed out after ${effectiveLimits.timeoutMs}ms`) 260 + } 197 261 198 262 const stdout = await new Response(proc.stdout).text() 199 263 const stderr = await new Response(proc.stderr).text() 200 - const exitCode = await proc.exited 264 + const exitCode = raceResult 201 265 202 - // Log any stdout/stderr for debugging (won't affect result) 203 - if (stdout.trim()) { 204 - console.log("[sandbox:stdout]", stdout.trim()) 205 - } 206 - if (stderr.trim()) { 207 - console.error("[sandbox:stderr]", stderr.trim()) 208 - } 266 + if (stdout.trim()) console.log("[sandbox:stdout]", stdout.trim()) 267 + if (stderr.trim()) console.error("[sandbox:stderr]", stderr.trim()) 209 268 210 269 // Read result from temp file 211 270 let result: SandboxResult ··· 213 272 const resultJson = fs.readFileSync(resultPath, "utf-8") 214 273 result = JSON.parse(resultJson) 215 274 } catch (err) { 216 - // If result file doesn't exist, check for Deno-level failure 217 275 if (exitCode !== 0) { 276 + // Check for OOM 277 + if (stderr.includes("heap out of memory") || stderr.includes("allocation failed")) { 278 + throw new Error(`Memory limit exceeded (${effectiveLimits.maxMemoryMB}MB)`) 279 + } 218 280 throw new Error(`Sandbox execution failed: ${stderr || "Unknown error"}`) 219 281 } 220 282 throw new Error(`Failed to read sandbox result: ${err instanceof Error ? err.message : err}`) 221 283 } finally { 222 - // Clean up 223 284 try { fs.unlinkSync(resultPath) } catch {} 285 + } 286 + 287 + if (result.validationError) { 288 + throw new Error(`Input validation failed: ${result.validationError}`) 224 289 } 225 290 226 291 if (!result.success) {
+116
packages/at-run/runner/src/secrets.ts
··· 1 + /** 2 + * Secrets fetching and decryption for runner 3 + */ 4 + 5 + import { AtpAgent } from "@atproto/api" 6 + import { IdResolver } from "@atproto/identity" 7 + import { decryptBundleSecrets, type StoredKeyPair } from "./config" 8 + 9 + const SECRETS_COLLECTION = "dev.mainasara.at-run.beta.secrets" 10 + 11 + const idResolver = new IdResolver() 12 + 13 + // Cache resolved secrets to avoid repeated PDS calls 14 + const secretsCache = new Map<string, Record<string, string>>() 15 + 16 + interface SecretsRecord { 17 + bundle: string 18 + runner: string 19 + encrypted: string 20 + nonce: string 21 + ephemeralPublicKey: string 22 + algorithm: string 23 + } 24 + 25 + /** 26 + * Fetch and decrypt secrets for a bundle from the author's PDS 27 + * 28 + * @param authorDid - DID of the bundle author (who created the secrets) 29 + * @param bundleName - Name of the bundle 30 + * @param runnerDid - This runner's DID 31 + * @param keyPair - This runner's keypair for decryption 32 + */ 33 + export async function fetchSecrets( 34 + authorDid: string, 35 + bundleName: string, 36 + runnerDid: string, 37 + keyPair: StoredKeyPair 38 + ): Promise<Record<string, string>> { 39 + const cacheKey = `${authorDid}/${bundleName}/${runnerDid}` 40 + 41 + // Check cache first 42 + const cached = secretsCache.get(cacheKey) 43 + if (cached) return cached 44 + 45 + // Resolve author's PDS 46 + const didDoc = await idResolver.did.resolve(authorDid) 47 + if (!didDoc) { 48 + throw new Error(`Failed to resolve bundle author DID: ${authorDid}`) 49 + } 50 + 51 + const pdsUrl = didDoc.service?.find( 52 + (s) => s.id === "#atproto_pds" || s.id === `${authorDid}#atproto_pds` 53 + )?.serviceEndpoint as string | undefined 54 + 55 + if (!pdsUrl) { 56 + throw new Error("No PDS service found for bundle author") 57 + } 58 + 59 + const agent = new AtpAgent({ service: pdsUrl }) 60 + 61 + // Try to fetch the secrets record 62 + // rkey format: {bundleName}-{runnerDid with : replaced by -} 63 + const rkey = `${bundleName}-${runnerDid.replace(/:/g, "-")}` 64 + 65 + try { 66 + const response = await agent.com.atproto.repo.getRecord({ 67 + repo: authorDid, 68 + collection: SECRETS_COLLECTION, 69 + rkey, 70 + }) 71 + 72 + const record = response.data.value as unknown as SecretsRecord 73 + 74 + // Verify it's for this runner 75 + if (record.runner !== runnerDid) { 76 + throw new Error("Secrets record is not for this runner") 77 + } 78 + 79 + // Decrypt 80 + const secrets = decryptBundleSecrets( 81 + { 82 + encrypted: record.encrypted, 83 + nonce: record.nonce, 84 + ephemeralPublicKey: record.ephemeralPublicKey, 85 + }, 86 + keyPair 87 + ) 88 + 89 + // Cache the result 90 + secretsCache.set(cacheKey, secrets) 91 + 92 + return secrets 93 + } catch (err) { 94 + // No secrets found is not an error - bundle may not need secrets 95 + if (err instanceof Error && err.message.includes("RecordNotFound")) { 96 + return {} 97 + } 98 + // Rethrow other errors 99 + throw err 100 + } 101 + } 102 + 103 + /** 104 + * Clear secrets cache (useful for testing or when secrets are updated) 105 + */ 106 + export function clearSecretsCache(): void { 107 + secretsCache.clear() 108 + } 109 + 110 + /** 111 + * Clear specific entry from secrets cache 112 + */ 113 + export function invalidateSecrets(authorDid: string, bundleName: string, runnerDid: string): void { 114 + const cacheKey = `${authorDid}/${bundleName}/${runnerDid}` 115 + secretsCache.delete(cacheKey) 116 + }
+6 -1
packages/at-run/runtime/package.json
··· 9 9 ".": "./src/index.ts" 10 10 }, 11 11 "keywords": ["atproto", "serverless", "functions"], 12 - "license": "MIT" 12 + "license": "MIT", 13 + "dependencies": { 14 + "tweetnacl": "^1.0.3", 15 + "tweetnacl-util": "^0.15.1", 16 + "valibot": "^1.3.1" 17 + } 13 18 }
+135
packages/at-run/runtime/src/crypto.ts
··· 1 + /** 2 + * Cryptographic utilities for at-run secrets 3 + * 4 + * Uses X25519 key exchange with XSalsa20-Poly1305 authenticated encryption 5 + * (the same primitives used by Signal, libsodium, etc.) 6 + */ 7 + 8 + import nacl from "tweetnacl" 9 + import { encodeBase64, decodeBase64 } from "tweetnacl-util" 10 + 11 + export const ALGORITHM = "x25519-xsalsa20-poly1305" 12 + 13 + export interface KeyPair { 14 + publicKey: string // Base64 15 + secretKey: string // Base64 16 + } 17 + 18 + export interface EncryptedPayload { 19 + encrypted: string // Base64 20 + nonce: string // Base64 21 + algorithm: string 22 + } 23 + 24 + /** 25 + * Generate a new X25519 keypair for a runner 26 + */ 27 + export function generateKeyPair(): KeyPair { 28 + const keyPair = nacl.box.keyPair() 29 + return { 30 + publicKey: encodeBase64(keyPair.publicKey), 31 + secretKey: encodeBase64(keyPair.secretKey), 32 + } 33 + } 34 + 35 + /** 36 + * Encrypt secrets for a specific runner using their public key 37 + * 38 + * @param secrets - Object containing secret key-value pairs 39 + * @param runnerPublicKey - Base64-encoded runner public key 40 + * @param authorSecretKey - Base64-encoded author's secret key (ephemeral is fine) 41 + */ 42 + export function encryptSecrets( 43 + secrets: Record<string, string>, 44 + runnerPublicKey: string, 45 + authorSecretKey: string 46 + ): EncryptedPayload { 47 + const message = new TextEncoder().encode(JSON.stringify(secrets)) 48 + const nonce = nacl.randomBytes(nacl.box.nonceLength) 49 + 50 + const encrypted = nacl.box( 51 + message, 52 + nonce, 53 + decodeBase64(runnerPublicKey), 54 + decodeBase64(authorSecretKey) 55 + ) 56 + 57 + if (!encrypted) { 58 + throw new Error("Encryption failed") 59 + } 60 + 61 + return { 62 + encrypted: encodeBase64(encrypted), 63 + nonce: encodeBase64(nonce), 64 + algorithm: ALGORITHM, 65 + } 66 + } 67 + 68 + /** 69 + * Encrypt secrets using an ephemeral keypair (simpler API) 70 + * Returns the encrypted payload plus the ephemeral public key needed for decryption 71 + */ 72 + export function encryptSecretsEphemeral( 73 + secrets: Record<string, string>, 74 + runnerPublicKey: string 75 + ): EncryptedPayload & { ephemeralPublicKey: string } { 76 + const ephemeral = nacl.box.keyPair() 77 + 78 + const message = new TextEncoder().encode(JSON.stringify(secrets)) 79 + const nonce = nacl.randomBytes(nacl.box.nonceLength) 80 + 81 + const encrypted = nacl.box( 82 + message, 83 + nonce, 84 + decodeBase64(runnerPublicKey), 85 + ephemeral.secretKey 86 + ) 87 + 88 + if (!encrypted) { 89 + throw new Error("Encryption failed") 90 + } 91 + 92 + return { 93 + encrypted: encodeBase64(encrypted), 94 + nonce: encodeBase64(nonce), 95 + algorithm: ALGORITHM, 96 + ephemeralPublicKey: encodeBase64(ephemeral.publicKey), 97 + } 98 + } 99 + 100 + /** 101 + * Decrypt secrets using the runner's secret key 102 + * 103 + * @param payload - Encrypted payload from encryptSecretsEphemeral 104 + * @param runnerSecretKey - Base64-encoded runner's secret key 105 + */ 106 + export function decryptSecrets( 107 + payload: { encrypted: string; nonce: string; ephemeralPublicKey: string }, 108 + runnerSecretKey: string 109 + ): Record<string, string> { 110 + const decrypted = nacl.box.open( 111 + decodeBase64(payload.encrypted), 112 + decodeBase64(payload.nonce), 113 + decodeBase64(payload.ephemeralPublicKey), 114 + decodeBase64(runnerSecretKey) 115 + ) 116 + 117 + if (!decrypted) { 118 + throw new Error("Decryption failed - invalid key or corrupted data") 119 + } 120 + 121 + const json = new TextDecoder().decode(decrypted) 122 + return JSON.parse(json) 123 + } 124 + 125 + /** 126 + * Validate that a string is a valid base64-encoded public key 127 + */ 128 + export function isValidPublicKey(key: string): boolean { 129 + try { 130 + const decoded = decodeBase64(key) 131 + return decoded.length === nacl.box.publicKeyLength 132 + } catch { 133 + return false 134 + } 135 + }
+85 -5
packages/at-run/runtime/src/index.ts
··· 4 4 * Bundles import this to define endpoints that the runner can discover and execute. 5 5 */ 6 6 7 + import * as v from "valibot" 8 + 9 + // Re-export valibot for convenience 10 + export { v } 11 + 12 + // Re-export crypto utilities 13 + export * from "./crypto" 14 + 7 15 // ============================================================================= 8 16 // Permissions 9 17 // ============================================================================= ··· 20 28 } 21 29 22 30 // ============================================================================= 31 + // Resource Limits 32 + // ============================================================================= 33 + 34 + export interface ResourceLimits { 35 + /** Max heap memory in MB (default: 128) */ 36 + maxMemoryMB?: number 37 + /** Execution timeout in ms (default: 30000) */ 38 + timeoutMs?: number 39 + } 40 + 41 + export const DEFAULT_LIMITS: Required<ResourceLimits> = { 42 + maxMemoryMB: 128, 43 + timeoutMs: 30000, 44 + } 45 + 46 + // ============================================================================= 23 47 // Manifest 24 48 // ============================================================================= 25 49 ··· 30 54 description?: string 31 55 /** Required permissions */ 32 56 permissions: Permissions 57 + /** Resource limits for all endpoints */ 58 + limits?: ResourceLimits 33 59 } 34 60 35 61 export interface Manifest extends ManifestConfig { ··· 49 75 * permissions: { 50 76 * net: ["vod-beta.stream.place", "plc.directory"], 51 77 * }, 78 + * limits: { 79 + * maxMemoryMB: 256, 80 + * timeoutMs: 60000, 81 + * }, 52 82 * }) 53 83 * ``` 54 84 */ ··· 75 105 // Endpoints 76 106 // ============================================================================= 77 107 108 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 109 + type AnySchema = v.BaseSchema<unknown, unknown, any> 110 + 78 111 export interface EndpointConfig<TArgs = unknown, TResponse = unknown> { 112 + /** Valibot schema for input validation (optional) */ 113 + input?: AnySchema 79 114 /** Optional permissions override (must be subset of global) */ 80 115 permissions?: Permissions 116 + /** Optional resource limits override */ 117 + limits?: ResourceLimits 81 118 /** The handler function */ 82 119 handler: (args: TArgs) => Promise<TResponse> | TResponse 83 120 } 84 121 85 122 export interface Endpoint<TArgs = unknown, TResponse = unknown> { 86 123 __atrun: "endpoint" 124 + input?: AnySchema 87 125 permissions?: Permissions 126 + limits?: ResourceLimits 88 127 handler: (args: TArgs) => Promise<TResponse> | TResponse 89 128 } 90 129 ··· 93 132 * 94 133 * @example 95 134 * ```ts 96 - * import { endpoint } from "@at-run/runtime" 135 + * import { endpoint, v } from "@at-run/runtime" 97 136 * 98 - * export const listVideos = endpoint({ 137 + * // Simple endpoint with typed args 138 + * export const listVideos = endpoint<{ limit?: number }>({ 99 139 * handler: async (args) => { 100 140 * return { videos: [...] } 101 141 * } 102 142 * }) 103 143 * 104 - * // With endpoint-specific permissions (subset of global) 105 - * export const getPlaylist = endpoint({ 106 - * permissions: { net: ["vod-beta.stream.place"] }, 144 + * // With input validation 145 + * export const getVideo = endpoint<{ uri: string }>({ 146 + * input: v.object({ 147 + * uri: v.pipe(v.string(), v.startsWith("at://")), 148 + * }), 149 + * handler: async ({ uri }) => { 150 + * return { uri, title: "..." } 151 + * } 152 + * }) 153 + * 154 + * // With permissions and limits 155 + * export const heavyTask = endpoint({ 156 + * permissions: { net: ["api.example.com"] }, 157 + * limits: { maxMemoryMB: 512, timeoutMs: 120000 }, 107 158 * handler: async (args) => { ... } 108 159 * }) 109 160 * ``` ··· 113 164 ): Endpoint<TArgs, TResponse> { 114 165 return { 115 166 __atrun: "endpoint", 167 + input: config.input, 116 168 permissions: config.permissions, 169 + limits: config.limits, 117 170 handler: config.handler, 118 171 } 119 172 } ··· 130 183 ) 131 184 } 132 185 186 + /** 187 + * Validate input against endpoint schema 188 + */ 189 + export function validateInput<TSchema extends AnySchema>( 190 + schema: TSchema, 191 + input: unknown 192 + ): v.InferOutput<TSchema> { 193 + return v.parse(schema, input) 194 + } 195 + 133 196 // ============================================================================= 134 197 // Utilities for runner 135 198 // ============================================================================= ··· 156 219 write: endpoint.write 157 220 ? endpoint.write.filter((p) => global.write?.some((gp) => p.startsWith(gp))) 158 221 : global.write, 222 + } 223 + } 224 + 225 + /** 226 + * Merge resource limits, taking the minimum (most restrictive) 227 + */ 228 + export function intersectLimits( 229 + global: ResourceLimits | undefined, 230 + endpoint: ResourceLimits | undefined 231 + ): Required<ResourceLimits> { 232 + const base = { ...DEFAULT_LIMITS, ...global } 233 + 234 + if (!endpoint) return base 235 + 236 + return { 237 + maxMemoryMB: Math.min(base.maxMemoryMB, endpoint.maxMemoryMB ?? Infinity), 238 + timeoutMs: Math.min(base.timeoutMs, endpoint.timeoutMs ?? Infinity), 159 239 } 160 240 } 161 241