From be7c52506300478d953ab8422c772de9ed500db4 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 11 Apr 2026 23:08:05 -0400 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 4.6 --- README.md | 54 +++ package-lock.json | 834 +++++++++++++++++++++++++++++++++++++ package.json | 34 ++ src/index.ts | 58 +++ src/server/execute.ts | 451 ++++++++++++++++++++ src/server/index.ts | 19 + src/server/job-manifest.ts | 381 +++++++++++++++++ src/server/k8s-client.ts | 172 ++++++++ src/server/parse.ts | 99 +++++ src/server/session.ts | 61 +++ src/server/test.ts | 241 +++++++++++ src/ui-parser.ts | 13 + tsconfig.json | 19 + 13 files changed, 2436 insertions(+) create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/server/execute.ts create mode 100644 src/server/index.ts create mode 100644 src/server/job-manifest.ts create mode 100644 src/server/k8s-client.ts create mode 100644 src/server/parse.ts create mode 100644 src/server/session.ts create mode 100644 src/server/test.ts create mode 100644 src/ui-parser.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..609f496 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# paperclip-adapter-opencode-k8s + +Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs instead of inside the main Paperclip process. + +## Features + +- Spawns agent runs as K8s Jobs with full pod isolation +- Inherits container image, secrets, DNS, and PVC from the Paperclip Deployment automatically +- Real-time log streaming from Job pods back to the Paperclip UI +- Session resume via shared RWX PVC +- Per-agent concurrency guard +- Configurable resources, namespace, kubeconfig +- Runtime config injection for permission bypass + +## Installation + +### Via Paperclip Adapter Manager + +```bash +curl -X POST http://localhost:3100/api/adapters \ + -H "Content-Type: application/json" \ + -d '{"packageName": "paperclip-adapter-opencode-k8s"}' +``` + +### Local Development + +```bash +curl -X POST http://localhost:3100/api/adapters \ + -H "Content-Type: application/json" \ + -d '{"localPath": "/path/to/paperclip-opencode-k8s"}' +``` + +## Configuration + +See the agent configuration documentation for all available fields: + +- `model` (required) — OpenCode model in provider/model format +- `variant` — reasoning profile variant +- `namespace` — K8s namespace for Jobs +- `image` — Override container image +- `kubeconfig` — Path to kubeconfig file +- `resources` — CPU/memory requests and limits +- `timeoutSec` — Run timeout (0 = no timeout) +- `retainJobs` — Keep completed Jobs for debugging + +## Requirements + +- Kubernetes cluster with RBAC permissions to create Jobs, list Pods, and read Pod logs +- Shared RWX PVC mounted at `/paperclip` for session resume and workspace access +- `@paperclipai/adapter-utils` >= 0.3.0 + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7f8e3c6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,834 @@ +{ + "name": "paperclip-adapter-opencode-k8s", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paperclip-adapter-opencode-k8s", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@kubernetes/client-node": "^1.0.0" + }, + "devDependencies": { + "@paperclipai/adapter-utils": "^0.3.0", + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "@paperclipai/adapter-utils": ">=0.3.0" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "node-fetch": "^2.7.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks-proxy-agent": "^8.0.4", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "ws": "^8.18.2" + } + }, + "node_modules/@paperclipai/adapter-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-0.3.1.tgz", + "integrity": "sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==", + "dev": true + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1636b28 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "paperclip-adapter-opencode-k8s", + "version": "0.1.0", + "description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs", + "license": "MIT", + "type": "module", + "paperclip": { + "adapterUiParser": "1.0.0" + }, + "exports": { + ".": "./dist/index.js", + "./server": "./dist/server/index.js", + "./ui-parser": "./dist/ui-parser.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@kubernetes/client-node": "^1.0.0" + }, + "peerDependencies": { + "@paperclipai/adapter-utils": ">=0.3.0" + }, + "devDependencies": { + "@paperclipai/adapter-utils": "^0.3.0", + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..487cf08 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,58 @@ +export const type = "opencode_k8s"; +export const label = "OpenCode (Kubernetes)"; + +export const models = [ + { id: "openai/gpt-5.2-codex", label: "openai/gpt-5.2-codex" }, + { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, + { id: "openai/gpt-5.2", label: "openai/gpt-5.2" }, + { id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" }, + { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, +]; + +export const agentConfigurationDoc = `# opencode_k8s agent configuration + +Adapter: opencode_k8s + +Runs OpenCode inside an isolated Kubernetes Job pod instead of the main +Paperclip process. The Job inherits the container image, imagePullSecrets, +DNS config, and PVC from the running Paperclip Deployment automatically. + +Core fields: +- model (string, required): OpenCode model id in provider/model format (e.g. anthropic/claude-sonnet-4-6) +- variant (string, optional): provider-specific reasoning/profile variant passed as --variant +- dangerouslySkipPermissions (boolean, optional): inject runtime config with permission.external_directory=allow; defaults to true +- promptTemplate (string, optional): run prompt template +- extraArgs (string[], optional): additional CLI args appended to the opencode command +- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment + +Kubernetes fields: +- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace +- image (string, optional): override container image; defaults to the running Deployment image +- imagePullPolicy (string, optional): image pull policy; default "IfNotPresent" +- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth +- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } } +- nodeSelector (object, optional): node selector for Job pods +- tolerations (array, optional): tolerations for Job pods +- labels (object, optional): extra labels added to Job metadata +- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300 +- retainJobs (boolean, optional): skip cleanup on completion for debugging + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout +- graceSec (number, optional): additional grace before adapter gives up after Job deadline + +Inherited from Deployment (no config needed): +- ANTHROPIC_API_KEY, OPENAI_API_KEY, and other provider keys +- CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_BEARER_TOKEN_BEDROCK +- PAPERCLIP_API_URL +- Container image, imagePullSecrets, DNS config, PVC mount, security context + +Notes: +- Session resume works via the shared /paperclip PVC (HOME=/paperclip) +- Skills are bundled in the container image +- Prompts are delivered via a busybox init container writing to an emptyDir volume +- Runtime config (permission.external_directory=allow) is written inside the Job container +- OPENCODE_DISABLE_PROJECT_CONFIG=true is always set to prevent config file pollution +`; + +export { createServerAdapter } from "./server/index.js"; diff --git a/src/server/execute.ts b/src/server/execute.ts new file mode 100644 index 0000000..7569c9e --- /dev/null +++ b/src/server/execute.ts @@ -0,0 +1,451 @@ +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function inferOpenAiCompatibleBiller(env: Record, _fallback: string | null): string | null { + if (env.OPENROUTER_API_KEY) return "openrouter"; + if (env.OPENAI_BASE_URL?.includes("openrouter")) return "openrouter"; + if (env.OPENAI_API_KEY) return "openai"; + return null; +} +import { + parseOpenCodeJsonl, + isOpenCodeUnknownSessionError, +} from "./parse.js"; +import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js"; +import { buildJobManifest } from "./job-manifest.js"; +import type * as k8s from "@kubernetes/client-node"; +import { Writable } from "node:stream"; + +const POLL_INTERVAL_MS = 2000; + +function parseModelProvider(model: string | null): string | null { + if (!model) return null; + const trimmed = model.trim(); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; +} + +async function waitForPod( + namespace: string, + jobName: string, + timeoutMs: number, + onLog: AdapterExecutionContext["onLog"], + kubeconfigPath?: string, +): Promise { + const coreApi = getCoreApi(kubeconfigPath); + const deadline = Date.now() + timeoutMs; + const labelSelector = `job-name=${jobName}`; + + await onLog("stdout", `[paperclip] Waiting for pod to be scheduled (job: ${jobName})...\n`); + + let lastStatus = ""; + while (Date.now() < deadline) { + const podList = await coreApi.listNamespacedPod({ + namespace, + labelSelector, + }); + const pod = podList.items[0]; + + if (!pod) { + if (lastStatus !== "no-pod") { + await onLog("stdout", `[paperclip] Waiting for Job controller to create pod...\n`); + lastStatus = "no-pod"; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + continue; + } + + const podName = pod.metadata?.name ?? "unknown"; + const phase = pod.status?.phase ?? "Unknown"; + const initStatuses = pod.status?.initContainerStatuses ?? []; + const containerStatuses = pod.status?.containerStatuses ?? []; + + const statusKey = `${phase}:${initStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.terminated?.reason ?? "ok").join(",")}:${containerStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.running ? "running" : "waiting").join(",")}`; + if (statusKey !== lastStatus) { + const details: string[] = [`phase=${phase}`]; + for (const init of initStatuses) { + if (init.state?.waiting) details.push(`init/${init.name}: waiting (${init.state.waiting.reason ?? "unknown"})`); + else if (init.state?.running) details.push(`init/${init.name}: running`); + else if (init.state?.terminated) details.push(`init/${init.name}: done (exit ${init.state.terminated.exitCode})`); + } + for (const cs of containerStatuses) { + if (cs.state?.waiting) details.push(`${cs.name}: waiting (${cs.state.waiting.reason ?? "unknown"})`); + else if (cs.state?.running) details.push(`${cs.name}: running`); + } + await onLog("stdout", `[paperclip] Pod ${podName}: ${details.join(", ")}\n`); + lastStatus = statusKey; + } + + if (phase === "Running" || phase === "Succeeded" || phase === "Failed") { + return podName; + } + + const allInitsDone = initStatuses.length > 0 && initStatuses.every( + (s) => s.state?.terminated?.exitCode === 0, + ); + const mainRunning = containerStatuses.some((s) => s.state?.running); + if (allInitsDone && mainRunning) { + return podName; + } + + for (const init of initStatuses) { + const terminated = init.state?.terminated; + if (terminated && (terminated.exitCode ?? 0) !== 0) { + throw new Error(`Init container "${init.name}" failed with exit code ${terminated.exitCode}: ${terminated.reason ?? terminated.message ?? "unknown"}`); + } + const waiting = init.state?.waiting; + if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") { + throw new Error(`Init container "${init.name}" image pull failed: ${waiting.message ?? waiting.reason}`); + } + if (waiting?.reason === "CrashLoopBackOff") { + throw new Error(`Init container "${init.name}" crash loop: ${waiting.message ?? waiting.reason}`); + } + } + + const conditions = pod.status?.conditions ?? []; + const unschedulable = conditions.find( + (c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable", + ); + if (unschedulable) { + throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`); + } + + for (const cs of containerStatuses) { + const waiting = cs.state?.waiting; + if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") { + throw new Error(`Image pull failed for "${cs.name}": ${waiting.message ?? waiting.reason}`); + } + if (waiting?.reason === "CrashLoopBackOff") { + throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`); + } + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + throw new Error(`Timed out waiting for pod to be scheduled (${Math.round(timeoutMs / 1000)}s)`); +} + +async function streamPodLogs( + namespace: string, + podName: string, + onLog: AdapterExecutionContext["onLog"], + kubeconfigPath?: string, +): Promise { + const logApi = getLogApi(kubeconfigPath); + const chunks: string[] = []; + + const writable = new Writable({ + write(chunk: Buffer, _encoding, callback) { + const text = chunk.toString("utf-8"); + chunks.push(text); + void onLog("stdout", text).then(() => callback(), callback); + }, + }); + + try { + await logApi.log(namespace, podName, "opencode", writable, { + follow: true, + pretty: false, + }); + } catch { + // follow may fail if the container already exited + } + + return chunks.join(""); +} + +async function readPodLogs( + namespace: string, + podName: string, + kubeconfigPath?: string, +): Promise { + const coreApi = getCoreApi(kubeconfigPath); + try { + const log = await coreApi.readNamespacedPodLog({ + name: podName, + namespace, + container: "opencode", + }); + return typeof log === "string" ? log : ""; + } catch { + return ""; + } +} + +async function waitForJobCompletion( + namespace: string, + jobName: string, + timeoutMs: number, + kubeconfigPath?: string, +): Promise<{ succeeded: boolean; timedOut: boolean }> { + const batchApi = getBatchApi(kubeconfigPath); + const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0; + + while (deadline === 0 || Date.now() < deadline) { + const job = await batchApi.readNamespacedJob({ name: jobName, namespace }); + const conditions = job.status?.conditions ?? []; + + const complete = conditions.find((c) => c.type === "Complete" && c.status === "True"); + if (complete) return { succeeded: true, timedOut: false }; + + const failed = conditions.find((c) => c.type === "Failed" && c.status === "True"); + if (failed) { + const isDeadlineExceeded = failed.reason === "DeadlineExceeded"; + return { succeeded: false, timedOut: isDeadlineExceeded }; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return { succeeded: false, timedOut: true }; +} + +async function getPodExitCode(namespace: string, jobName: string, kubeconfigPath?: string): Promise { + const coreApi = getCoreApi(kubeconfigPath); + const podList = await coreApi.listNamespacedPod({ + namespace, + labelSelector: `job-name=${jobName}`, + }); + const pod = podList.items[0]; + if (!pod) return null; + + const containerStatus = pod.status?.containerStatuses?.find((s) => s.name === "opencode"); + return containerStatus?.state?.terminated?.exitCode ?? null; +} + +async function cleanupJob( + namespace: string, + jobName: string, + onLog: AdapterExecutionContext["onLog"], + kubeconfigPath?: string, +): Promise { + try { + const batchApi = getBatchApi(kubeconfigPath); + await batchApi.deleteNamespacedJob({ + name: jobName, + namespace, + body: { propagationPolicy: "Background" }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await onLog("stderr", `[paperclip] Warning: failed to cleanup job ${jobName}: ${msg}\n`); + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, runtime, config: rawConfig, onLog, onMeta } = ctx; + const config = parseObject(rawConfig); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 60); + const retainJobs = asBoolean(config.retainJobs, false); + const kubeconfigPath = asString(config.kubeconfig, "") || undefined; + const model = asString(config.model, "").trim(); + + // Guard: single concurrency per agent (shared PVC/session) + const agentId = ctx.agent.id; + const selfPod = await getSelfPodInfo(kubeconfigPath); + const guardNamespace = asString(config.namespace, "") || selfPod.namespace; + try { + const batchApi = getBatchApi(kubeconfigPath); + const existing = await batchApi.listNamespacedJob({ + namespace: guardNamespace, + labelSelector: `paperclip.io/agent-id=${agentId},paperclip.io/adapter-type=opencode_k8s`, + }); + const running = existing.items.filter( + (j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"), + ); + if (running.length > 0) { + const names = running.map((j) => j.metadata?.name).join(", "); + await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this agent: ${names}\n`); + return { + exitCode: null, + signal: null, + timedOut: false, + errorMessage: `Concurrent run blocked: Job ${names} is still running for this agent`, + errorCode: "k8s_concurrent_run_blocked", + }; + } + } catch { + // If we can't check, proceed — heartbeat service enforces concurrency too + } + + const { job, jobName, namespace, prompt, opencodeArgs, promptMetrics } = buildJobManifest({ + ctx, + selfPod, + }); + + if (onMeta) { + await onMeta({ + adapterType: "opencode_k8s", + command: `kubectl job/${jobName}`, + cwd: namespace, + commandArgs: opencodeArgs, + commandNotes: [ + `Image: ${job.spec?.template.spec?.containers[0]?.image ?? "unknown"}`, + `Namespace: ${namespace}`, + `Timeout: ${timeoutSec}s`, + ], + prompt, + ...(promptMetrics ? { promptMetrics } : {}), + context: ctx.context, + } as Parameters[0]); + } + + const batchApi = getBatchApi(kubeconfigPath); + try { + await batchApi.createNamespacedJob({ namespace, body: job }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await onLog("stderr", `[paperclip] Failed to create K8s Job: ${msg}\n`); + return { + exitCode: null, + signal: null, + timedOut: false, + errorMessage: `Failed to create Kubernetes Job: ${msg}`, + errorCode: "k8s_job_create_failed", + }; + } + + await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`); + + let stdout = ""; + let exitCode: number | null = null; + let jobTimedOut = false; + + try { + const scheduleTimeoutMs = 120_000; + let podName: string; + try { + podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath); + await onLog("stdout", `[paperclip] Pod running: ${podName}\n`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await onLog("stderr", `[paperclip] Pod scheduling failed: ${msg}\n`); + return { + exitCode: null, + signal: null, + timedOut: false, + errorMessage: `Pod scheduling failed: ${msg}`, + errorCode: "k8s_pod_schedule_failed", + }; + } + + const completionTimeoutMs = timeoutSec > 0 ? (timeoutSec + graceSec) * 1000 : 0; + + const [logResult, completionResult] = await Promise.allSettled([ + streamPodLogs(namespace, podName, onLog, kubeconfigPath), + waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath), + ]); + + if (logResult.status === "fulfilled") { + stdout = logResult.value; + } + + if (!stdout.trim()) { + await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`); + stdout = await readPodLogs(namespace, podName, kubeconfigPath); + if (stdout.trim()) { + await onLog("stdout", stdout); + } + } + + if (completionResult.status === "fulfilled") { + jobTimedOut = completionResult.value.timedOut; + } else { + jobTimedOut = true; + } + + exitCode = await getPodExitCode(namespace, jobName, kubeconfigPath); + } finally { + if (!retainJobs) { + await cleanupJob(namespace, jobName, onLog, kubeconfigPath); + } else { + await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`); + } + } + + if (jobTimedOut) { + return { + exitCode, + signal: null, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: "timeout", + }; + } + + // Parse OpenCode JSONL output + const parsed = parseOpenCodeJsonl(stdout); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const fallbackSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const workspaceContext = parseObject(ctx.context.paperclipWorkspace); + const workspaceId = asString(workspaceContext.workspaceId, "") || null; + const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null; + const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null; + const cwd = asString(workspaceContext.cwd, ""); + + const resolvedSessionId = parsed.sessionId ?? (fallbackSessionId || null); + const resolvedSessionParams = resolvedSessionId + ? { + sessionId: resolvedSessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record + : null; + + const provider = parseModelProvider(model); + // Build a minimal env record for biller inference + const billerEnv: Record = {}; + for (const key of ["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENROUTER_API_KEY"]) { + const val = process.env[key]; + if (val) billerEnv[key] = val; + } + const biller = inferOpenAiCompatibleBiller(billerEnv, null) ?? provider ?? "unknown"; + + const parsedError = typeof parsed.errorMessage === "string" ? parsed.errorMessage.trim() : ""; + const rawExitCode = exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const failed = (synthesizedExitCode ?? 0) !== 0; + + // If the session was stale, clear it so the next heartbeat starts fresh + if (failed && isOpenCodeUnknownSessionError(stdout, parsedError)) { + await onLog("stdout", `[paperclip] OpenCode session is unavailable; clearing for next run.\n`); + return { + exitCode: synthesizedExitCode, + signal: null, + timedOut: false, + errorMessage: parsedError || "Session unavailable", + errorCode: "session_unavailable", + clearSession: true, + resultJson: { stdout }, + }; + } + const firstStderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? ""; + const fallbackErrorMessage = parsedError || firstStderrLine || `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + + return { + exitCode: synthesizedExitCode, + signal: null, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: parsed.usage.inputTokens, + outputTokens: parsed.usage.outputTokens, + cachedInputTokens: parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider, + model: model || null, + billingType: "unknown", + costUsd: parsed.costUsd, + resultJson: { stdout }, + summary: parsed.summary, + clearSession: false, + } as AdapterExecutionResult; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..d14ede6 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,19 @@ +import type { ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { type, models, agentConfigurationDoc } from "../index.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; +import { sessionCodec } from "./session.js"; + +export function createServerAdapter(): ServerAdapterModule { + return { + type, + execute, + testEnvironment, + sessionCodec, + models, + supportsLocalAgentJwt: true, + agentConfigurationDoc, + }; +} + +export { execute, testEnvironment, sessionCodec }; diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts new file mode 100644 index 0000000..b52bb72 --- /dev/null +++ b/src/server/job-manifest.ts @@ -0,0 +1,381 @@ +import type * as k8s from "@kubernetes/client-node"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { + asString, + asNumber, + asBoolean, + asStringArray, + parseObject, + buildPaperclipEnv, + renderTemplate, +} from "@paperclipai/adapter-utils/server-utils"; + +function joinPromptSections(sections: string[], separator = "\n\n"): string { + return sections.filter((s) => s.trim().length > 0).join(separator); +} + +function stringifyPaperclipWakePayload(wake: unknown): string | null { + if (!wake || typeof wake !== "object") return null; + try { + const json = JSON.stringify(wake); + return json === "{}" ? null : json; + } catch { + return null; + } +} + +function renderPaperclipWakePrompt(wake: unknown, _opts?: { resumedSession?: boolean }): string { + if (!wake || typeof wake !== "object") return ""; + const w = wake as Record; + const reason = typeof w.reason === "string" ? w.reason.trim() : ""; + const comments = Array.isArray(w.comments) ? w.comments : []; + if (!reason && comments.length === 0) return ""; + const parts: string[] = []; + if (reason) parts.push(`Wake reason: ${reason}`); + for (const c of comments) { + if (typeof c === "object" && c !== null) { + const comment = c as Record; + const body = typeof comment.body === "string" ? comment.body.trim() : ""; + if (body) parts.push(`Comment: ${body}`); + } + } + return parts.join("\n\n"); +} +import type { SelfPodInfo } from "./k8s-client.js"; + +export interface JobBuildInput { + ctx: AdapterExecutionContext; + selfPod: SelfPodInfo; +} + +export interface JobBuildResult { + job: k8s.V1Job; + jobName: string; + namespace: string; + prompt: string; + opencodeArgs: string[]; + promptMetrics: Record; +} + +function sanitizeForK8sName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8); +} + +function buildEnvVars( + ctx: AdapterExecutionContext, + selfPod: SelfPodInfo, + config: Record, +): k8s.V1EnvVar[] { + const { runId, agent, context } = ctx; + const envConfig = parseObject(config.env); + + // Layer 1: PAPERCLIP_* base vars + const paperclipEnv = buildPaperclipEnv(agent); + paperclipEnv.PAPERCLIP_RUN_ID = runId; + + const setIfPresent = (envKey: string, value: unknown) => { + if (typeof value === "string" && value.trim().length > 0) { + paperclipEnv[envKey] = value.trim(); + } + }; + + setIfPresent("PAPERCLIP_TASK_ID", context.taskId ?? context.issueId); + setIfPresent("PAPERCLIP_WAKE_REASON", context.wakeReason); + setIfPresent("PAPERCLIP_WAKE_COMMENT_ID", context.wakeCommentId ?? context.commentId); + setIfPresent("PAPERCLIP_APPROVAL_ID", context.approvalId); + setIfPresent("PAPERCLIP_APPROVAL_STATUS", context.approvalStatus); + + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + if (wakePayloadJson) { + paperclipEnv.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } + + const workspaceContext = parseObject(context.paperclipWorkspace); + setIfPresent("PAPERCLIP_WORKSPACE_CWD", workspaceContext.cwd); + setIfPresent("PAPERCLIP_WORKSPACE_SOURCE", workspaceContext.source); + setIfPresent("PAPERCLIP_WORKSPACE_STRATEGY", workspaceContext.strategy); + setIfPresent("PAPERCLIP_WORKSPACE_ID", workspaceContext.workspaceId); + setIfPresent("PAPERCLIP_WORKSPACE_REPO_URL", workspaceContext.repoUrl); + setIfPresent("PAPERCLIP_WORKSPACE_REPO_REF", workspaceContext.repoRef); + setIfPresent("PAPERCLIP_WORKSPACE_BRANCH", workspaceContext.branchName); + setIfPresent("PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspaceContext.worktreePath); + setIfPresent("AGENT_HOME", workspaceContext.agentHome); + + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((v): v is string => typeof v === "string" && v.trim().length > 0) + : []; + if (linkedIssueIds.length > 0) { + paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + } + if (Array.isArray(context.paperclipWorkspaces) && context.paperclipWorkspaces.length > 0) { + paperclipEnv.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(context.paperclipWorkspaces); + } + if (Array.isArray(context.paperclipRuntimeServiceIntents) && context.paperclipRuntimeServiceIntents.length > 0) { + paperclipEnv.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(context.paperclipRuntimeServiceIntents); + } + if (Array.isArray(context.paperclipRuntimeServices) && context.paperclipRuntimeServices.length > 0) { + paperclipEnv.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(context.paperclipRuntimeServices); + } + setIfPresent("PAPERCLIP_RUNTIME_PRIMARY_URL", context.paperclipRuntimePrimaryUrl); + + if (ctx.authToken) { + paperclipEnv.PAPERCLIP_API_KEY = ctx.authToken; + } + + // Inherit PAPERCLIP_API_URL from Deployment env (in-cluster service URL) + if (selfPod.inheritedEnv.PAPERCLIP_API_URL) { + paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL; + } + + // Layer 3: Inherited from Deployment (Bedrock, API keys, etc.) + const merged: Record = { + ...selfPod.inheritedEnv, + ...paperclipEnv, + }; + + // Layer 4: User-defined overrides from adapterConfig.env + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") merged[key] = value; + } + + // OpenCode-specific: prevent project config pollution, always set after user overrides + merged.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; + merged.HOME = "/paperclip"; + + // Convert to V1EnvVar array + const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({ + name, + value, + })); + + return envVars; +} + +/** + * Build the OpenCode runtime config JSON for permission.external_directory=allow. + * Returned as a string to be written inside the Job container. + */ +function buildRuntimeConfigJson(config: Record): string | null { + const skipPermissions = asBoolean(config.dangerouslySkipPermissions, true); + if (!skipPermissions) return null; + return JSON.stringify({ permission: { external_directory: "allow" } }, null, 2); +} + +export function buildJobManifest(input: JobBuildInput): JobBuildResult { + const { ctx, selfPod } = input; + const { runId, agent, runtime, config: rawConfig, context } = ctx; + const config = parseObject(rawConfig); + + const namespace = asString(config.namespace, "") || selfPod.namespace; + const image = asString(config.image, "") || selfPod.image; + const model = asString(config.model, "").trim(); + const variant = asString(config.variant, "").trim(); + const extraArgs = asStringArray(config.extraArgs); + const timeoutSec = asNumber(config.timeoutSec, 0); + const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300); + const resources = parseObject(config.resources); + const nodeSelector = parseObject(config.nodeSelector); + const tolerations = Array.isArray(config.tolerations) ? config.tolerations : []; + const extraLabels = parseObject(config.labels); + + // Resolve working directory + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const configuredCwd = asString(config.cwd, ""); + const workingDir = workspaceCwd || configuredCwd || "/paperclip"; + + // Job naming + const agentSlug = sanitizeForK8sName(agent.id); + const runSlug = sanitizeForK8sName(runId); + const jobName = `agent-${agentSlug}-${runSlug}`; + + // Build prompt + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedBootstrapPrompt = + !runtimeSessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(runtimeSessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + renderedBootstrapPrompt, + wakePrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + // Build opencode CLI args + const opencodeArgs = ["run", "--format", "json"]; + if (runtimeSessionId) opencodeArgs.push("--session", runtimeSessionId); + if (model) opencodeArgs.push("--model", model); + if (variant) opencodeArgs.push("--variant", variant); + if (extraArgs.length > 0) opencodeArgs.push(...extraArgs); + + // Build env vars + const envVars = buildEnvVars(ctx, selfPod, config); + + // Runtime config for permissions + const runtimeConfigJson = buildRuntimeConfigJson(config); + + // Resource defaults + const resourceRequests = parseObject(resources.requests); + const resourceLimits = parseObject(resources.limits); + const containerResources: k8s.V1ResourceRequirements = { + requests: { + cpu: asString(resourceRequests.cpu, "1000m"), + memory: asString(resourceRequests.memory, "2Gi"), + }, + limits: { + cpu: asString(resourceLimits.cpu, "4000m"), + memory: asString(resourceLimits.memory, "8Gi"), + }, + }; + + // Labels + const labels: Record = { + "app.kubernetes.io/managed-by": "paperclip", + "app.kubernetes.io/component": "agent-job", + "paperclip.io/agent-id": agent.id, + "paperclip.io/run-id": runId, + "paperclip.io/company-id": agent.companyId, + "paperclip.io/adapter-type": "opencode_k8s", + }; + for (const [key, value] of Object.entries(extraLabels)) { + if (typeof value === "string") labels[key] = value; + } + + // Volumes + const volumes: k8s.V1Volume[] = [{ name: "prompt", emptyDir: {} }]; + const volumeMounts: k8s.V1VolumeMount[] = [{ name: "prompt", mountPath: "/tmp/prompt" }]; + + if (selfPod.pvcClaimName) { + volumes.push({ + name: "data", + persistentVolumeClaim: { claimName: selfPod.pvcClaimName }, + }); + volumeMounts.push({ name: "data", mountPath: "/paperclip" }); + } + + // Mount secret volumes inherited from the Deployment pod + for (const sv of selfPod.secretVolumes) { + volumes.push({ + name: sv.volumeName, + secret: { secretName: sv.secretName, defaultMode: sv.defaultMode, optional: true }, + }); + volumeMounts.push({ + name: sv.volumeName, + mountPath: sv.mountPath, + readOnly: true, + }); + } + + const securityContext: k8s.V1SecurityContext = { + capabilities: { drop: ["ALL"] }, + readOnlyRootFilesystem: false, + runAsNonRoot: true, + runAsUser: 1000, + allowPrivilegeEscalation: false, + }; + + const podSecurityContext: k8s.V1PodSecurityContext = { + runAsNonRoot: true, + runAsUser: 1000, + runAsGroup: 1000, + fsGroup: 1000, + }; + + // Build the main container command + // 1. Optionally write opencode runtime config for permission bypass + // 2. Pipe prompt into opencode + const opencodeArgsEscaped = opencodeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); + const configSetup = runtimeConfigJson + ? `mkdir -p ~/.config/opencode && echo '${runtimeConfigJson.replace(/'/g, "'\\''")}' > ~/.config/opencode/opencode.json && ` + : ""; + const mainCommand = `${configSetup}cat /tmp/prompt/prompt.txt | opencode ${opencodeArgsEscaped}`; + + const job: k8s.V1Job = { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: jobName, + namespace, + labels, + annotations: { + "paperclip.io/adapter-type": "opencode_k8s", + "paperclip.io/agent-name": agent.name, + }, + }, + spec: { + backoffLimit: 0, + ...(timeoutSec > 0 ? { activeDeadlineSeconds: timeoutSec } : {}), + ttlSecondsAfterFinished: ttlSeconds, + template: { + metadata: { labels }, + spec: { + restartPolicy: "Never", + serviceAccountName: asString(config.serviceAccountName, "") || undefined, + securityContext: podSecurityContext, + ...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}), + ...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}), + ...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector as Record } : {}), + ...(tolerations.length > 0 ? { tolerations: tolerations as k8s.V1Toleration[] } : {}), + initContainers: [ + { + name: "write-prompt", + image: "busybox:1.36", + imagePullPolicy: "IfNotPresent", + command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"], + env: [{ name: "PROMPT_CONTENT", value: prompt }], + volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }], + securityContext, + resources: { + requests: { cpu: "10m", memory: "16Mi" }, + limits: { cpu: "100m", memory: "64Mi" }, + }, + }, + ], + containers: [ + { + name: "opencode", + image, + imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"), + workingDir, + command: ["sh", "-c", mainCommand], + env: envVars, + volumeMounts, + securityContext, + resources: containerResources, + }, + ], + volumes, + }, + }, + }, + }; + + return { job, jobName, namespace, prompt, opencodeArgs, promptMetrics }; +} diff --git a/src/server/k8s-client.ts b/src/server/k8s-client.ts new file mode 100644 index 0000000..9f62571 --- /dev/null +++ b/src/server/k8s-client.ts @@ -0,0 +1,172 @@ +import * as k8s from "@kubernetes/client-node"; +import { readFileSync } from "node:fs"; + +/** + * Cached self-pod introspection result. Queried once on first execute(), + * then reused for all subsequent Job builds so every Job inherits the + * Deployment's image, imagePullSecrets, DNS config, and PVC claim. + */ +export interface SelfPodSecretVolume { + volumeName: string; + secretName: string; + mountPath: string; + defaultMode: number | undefined; +} + +export interface SelfPodInfo { + namespace: string; + image: string; + imagePullSecrets: Array<{ name: string }>; + dnsConfig: k8s.V1PodDNSConfig | undefined; + pvcClaimName: string | null; + secretVolumes: SelfPodSecretVolume[]; + /** Env vars inherited from the Deployment container. */ + inheritedEnv: Record; +} + +/** Keys forwarded from the Deployment container env into Job pods. */ +const INHERITED_ENV_KEYS = [ + "CLAUDE_CODE_USE_BEDROCK", + "AWS_REGION", + "AWS_BEARER_TOKEN_BEDROCK", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "PAPERCLIP_API_URL", +]; + +let cachedSelfPod: SelfPodInfo | null = null; + +/** + * Cache keyed by kubeconfig path (empty string = in-cluster). + * Supports multiple agents with different kubeconfigs. + */ +const kcCache = new Map(); + +function getKubeConfig(kubeconfigPath?: string): k8s.KubeConfig { + const key = kubeconfigPath ?? ""; + let kc = kcCache.get(key); + if (!kc) { + kc = new k8s.KubeConfig(); + if (kubeconfigPath) { + kc.loadFromFile(kubeconfigPath); + } else { + kc.loadFromCluster(); + } + kcCache.set(key, kc); + } + return kc; +} + +export function getBatchApi(kubeconfigPath?: string): k8s.BatchV1Api { + return getKubeConfig(kubeconfigPath).makeApiClient(k8s.BatchV1Api); +} + +export function getCoreApi(kubeconfigPath?: string): k8s.CoreV1Api { + return getKubeConfig(kubeconfigPath).makeApiClient(k8s.CoreV1Api); +} + +export function getAuthzApi(kubeconfigPath?: string): k8s.AuthorizationV1Api { + return getKubeConfig(kubeconfigPath).makeApiClient(k8s.AuthorizationV1Api); +} + +export function getLogApi(kubeconfigPath?: string): k8s.Log { + return new k8s.Log(getKubeConfig(kubeconfigPath)); +} + +/** + * Read the current pod's namespace. Checks (in order): + * 1. PAPERCLIP_NAMESPACE env var (set explicitly in Deployment) + * 2. Service account namespace file (standard in-cluster path) + * 3. POD_NAMESPACE env var (Downward API convention) + * Falls back to "default" only if none of the above are available. + */ +function readInClusterNamespace(): string { + const fromEnv = process.env.PAPERCLIP_NAMESPACE ?? process.env.POD_NAMESPACE; + if (fromEnv?.trim()) return fromEnv.trim(); + try { + return readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf-8").trim(); + } catch { + return "default"; + } +} + +/** + * Query the K8s API for our own pod spec and cache the result. + * Extracts image, imagePullSecrets, dnsConfig, PVC claim name, + * and environment variables to forward to Job pods. + */ +export async function getSelfPodInfo(kubeconfigPath?: string): Promise { + if (cachedSelfPod) return cachedSelfPod; + + const hostname = process.env.HOSTNAME; + if (!hostname) { + throw new Error("claude_k8s: HOSTNAME env var not set — cannot introspect running pod"); + } + + const namespace = readInClusterNamespace(); + const coreApi = getCoreApi(kubeconfigPath); + const pod = await coreApi.readNamespacedPod({ name: hostname, namespace }); + + const spec = pod.spec; + if (!spec) { + throw new Error(`claude_k8s: pod ${hostname} has no spec`); + } + + const mainContainer = spec.containers[0]; + if (!mainContainer?.image) { + throw new Error(`claude_k8s: pod ${hostname} has no container image`); + } + + // Find PVC claim name from volumes mounted at /paperclip + let pvcClaimName: string | null = null; + const dataMount = mainContainer.volumeMounts?.find( + (vm) => vm.mountPath === "/paperclip", + ); + if (dataMount) { + const volume = spec.volumes?.find((v) => v.name === dataMount.name); + pvcClaimName = volume?.persistentVolumeClaim?.claimName ?? null; + } + + // Discover secret volumes mounted on the main container + const secretVolumes: SelfPodSecretVolume[] = []; + for (const vm of mainContainer.volumeMounts ?? []) { + const vol = spec.volumes?.find((v) => v.name === vm.name); + if (vol?.secret?.secretName) { + secretVolumes.push({ + volumeName: vm.name, + secretName: vol.secret.secretName, + mountPath: vm.mountPath, + defaultMode: vol.secret.defaultMode, + }); + } + } + + // Collect inherited env vars from process.env (these came from the Deployment spec) + const inheritedEnv: Record = {}; + for (const key of INHERITED_ENV_KEYS) { + const value = process.env[key]; + if (value !== undefined) { + inheritedEnv[key] = value; + } + } + + cachedSelfPod = { + namespace, + image: mainContainer.image, + imagePullSecrets: (spec.imagePullSecrets ?? []).map((s) => ({ + name: s.name ?? "", + })).filter((s) => s.name.length > 0), + dnsConfig: spec.dnsConfig, + pvcClaimName, + secretVolumes, + inheritedEnv, + }; + + return cachedSelfPod; +} + +/** Reset cached state — useful for tests. */ +export function resetCache(): void { + kcCache.clear(); + cachedSelfPod = null; +} diff --git a/src/server/parse.ts b/src/server/parse.ts new file mode 100644 index 0000000..96af0ed --- /dev/null +++ b/src/server/parse.ts @@ -0,0 +1,99 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = asString(rec.message, "").trim(); + if (message) return message; + const data = parseObject(rec.data); + const nestedMessage = asString(data.message, "").trim(); + if (nestedMessage) return nestedMessage; + const name = asString(rec.name, "").trim(); + if (name) return name; + const code = asString(rec.code, "").trim(); + if (code) return code; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseOpenCodeJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + const errors: string[] = []; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + let costUsd = 0; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const currentSessionId = asString(event.sessionID, "").trim(); + if (currentSessionId) sessionId = currentSessionId; + + const type = asString(event.type, ""); + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish") { + const part = parseObject(event.part); + const tokens = parseObject(part.tokens); + const cache = parseObject(tokens.cache); + usage.inputTokens += asNumber(tokens.input, 0); + usage.cachedInputTokens += asNumber(cache.read, 0); + usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); + costUsd += asNumber(part.cost, 0); + continue; + } + + if (type === "tool_use") { + const part = parseObject(event.part); + const state = parseObject(part.state); + if (asString(state.status, "") === "error") { + const text = asString(state.error, "").trim(); + if (text) errors.push(text); + } + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message).trim(); + if (text) errors.push(text); + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + costUsd, + errorMessage: errors.length > 0 ? errors.join("\n") : null, + }; +} + +export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( + haystack, + ); +} diff --git a/src/server/session.ts b/src/server/session.ts new file mode 100644 index 0000000..d0100c2 --- /dev/null +++ b/src/server/session.ts @@ -0,0 +1,61 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; diff --git a/src/server/test.ts b/src/server/test.ts new file mode 100644 index 0000000..5f7af8f --- /dev/null +++ b/src/server/test.ts @@ -0,0 +1,241 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; +import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((c) => c.level === "error")) return "fail"; + if (checks.some((c) => c.level === "warn")) return "warn"; + return "pass"; +} + +async function checkApiReachable(checks: AdapterEnvironmentCheck[], kubeconfigPath?: string): Promise { + try { + const selfPod = await getSelfPodInfo(kubeconfigPath); + checks.push({ + code: "k8s_api_reachable", + level: "info", + message: `Kubernetes API reachable; running in namespace ${selfPod.namespace}`, + detail: `Image: ${selfPod.image}`, + }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + checks.push({ + code: "k8s_api_unreachable", + level: "error", + message: `Cannot reach Kubernetes API: ${msg}`, + hint: "Ensure the pod has a valid service account token mounted and the API server is accessible.", + }); + return false; + } +} + +async function checkNamespace( + namespace: string, + selfPodNamespace: string, + checks: AdapterEnvironmentCheck[], + kubeconfigPath?: string, +): Promise { + // If targeting the same namespace we're running in, skip the cluster-scoped + // readNamespace call — we know it exists, and the SA may lack cluster-level + // namespace get permissions. + if (namespace === selfPodNamespace) { + checks.push({ + code: "k8s_namespace_exists", + level: "info", + message: `Target namespace is the pod namespace: ${namespace}`, + }); + return true; + } + + try { + const coreApi = getCoreApi(kubeconfigPath); + await coreApi.readNamespace({ name: namespace }); + checks.push({ + code: "k8s_namespace_exists", + level: "info", + message: `Target namespace exists: ${namespace}`, + }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + checks.push({ + code: "k8s_namespace_check_failed", + level: "warn", + message: `Cannot verify namespace "${namespace}": ${msg}`, + hint: "The service account may lack cluster-level namespace read permissions. The namespace may still be usable — verify RBAC checks below.", + }); + // Don't block on this — RBAC checks below will catch actual permission issues + return true; + } +} + +async function checkRbac( + namespace: string, + checks: AdapterEnvironmentCheck[], + kubeconfigPath?: string, +): Promise { + const authzApi = getAuthzApi(kubeconfigPath); + + const rbacChecks = [ + { resource: "jobs", group: "batch", verb: "create", code: "k8s_rbac_job_create", label: "create Jobs" }, + { resource: "jobs", group: "batch", verb: "delete", code: "k8s_rbac_job_delete", label: "delete Jobs" }, + { resource: "jobs", group: "batch", verb: "get", code: "k8s_rbac_job_get", label: "get Jobs" }, + { resource: "pods", group: "", verb: "list", code: "k8s_rbac_pod_list", label: "list Pods" }, + { resource: "pods/log", group: "", verb: "get", code: "k8s_rbac_pod_log", label: "get Pod logs" }, + ]; + + for (const check of rbacChecks) { + try { + const review = await authzApi.createSelfSubjectAccessReview({ + body: { + apiVersion: "authorization.k8s.io/v1", + kind: "SelfSubjectAccessReview", + spec: { + resourceAttributes: { + namespace, + verb: check.verb, + resource: check.resource, + group: check.group, + }, + }, + }, + }); + if (review.status?.allowed) { + checks.push({ + code: check.code, + level: "info", + message: `RBAC: allowed to ${check.label} in ${namespace}`, + }); + } else { + checks.push({ + code: check.code, + level: "error", + message: `RBAC: not allowed to ${check.label} in ${namespace}`, + hint: `Grant the service account permission to ${check.verb} ${check.resource} in namespace ${namespace}.`, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + checks.push({ + code: check.code, + level: "warn", + message: `RBAC check failed for ${check.label}: ${msg}`, + hint: "SelfSubjectAccessReview may not be available; verify permissions manually.", + }); + } + } +} + +async function checkSecret( + namespace: string, + secretName: string, + checks: AdapterEnvironmentCheck[], + kubeconfigPath?: string, +): Promise { + try { + const coreApi = getCoreApi(kubeconfigPath); + await coreApi.readNamespacedSecret({ name: secretName, namespace }); + checks.push({ + code: "k8s_secret_exists", + level: "info", + message: `Secret "${secretName}" exists in namespace ${namespace}`, + }); + } catch { + checks.push({ + code: "k8s_secret_missing", + level: "warn", + message: `Secret "${secretName}" not found in namespace ${namespace}`, + hint: `Ensure the paperclip-secrets Secret exists with keys for ANTHROPIC_API_KEY and/or AWS_BEARER_TOKEN_BEDROCK.`, + }); + } +} + +async function checkPvc( + selfPod: { pvcClaimName: string | null; namespace: string }, + checks: AdapterEnvironmentCheck[], + kubeconfigPath?: string, +): Promise { + if (!selfPod.pvcClaimName) { + checks.push({ + code: "k8s_pvc_not_detected", + level: "warn", + message: "No PVC detected on /paperclip mount — session resume and workspace sharing will not work.", + hint: "Ensure the Paperclip Deployment has a PVC mounted at /paperclip with ReadWriteMany access mode.", + }); + return; + } + + try { + const coreApi = getCoreApi(kubeconfigPath); + const pvc = await coreApi.readNamespacedPersistentVolumeClaim({ + name: selfPod.pvcClaimName, + namespace: selfPod.namespace, + }); + const accessModes = pvc.spec?.accessModes ?? []; + const isRwx = accessModes.includes("ReadWriteMany"); + if (isRwx) { + checks.push({ + code: "k8s_pvc_rwx", + level: "info", + message: `PVC "${selfPod.pvcClaimName}" has ReadWriteMany access — Job pods can mount it.`, + }); + } else { + checks.push({ + code: "k8s_pvc_not_rwx", + level: "warn", + message: `PVC "${selfPod.pvcClaimName}" access modes: ${accessModes.join(", ")}. ReadWriteMany is required for Job pods to share the volume.`, + hint: "Change the PVC accessMode to ReadWriteMany in Helm values.", + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + checks.push({ + code: "k8s_pvc_check_failed", + level: "warn", + message: `Could not read PVC "${selfPod.pvcClaimName}": ${msg}`, + }); + } +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const secretRef = asString(config.secretRef, "paperclip-secrets"); + const kubeconfigPath = asString(config.kubeconfig, "") || undefined; + + // 1. K8s API reachable + self-pod introspection + const apiOk = await checkApiReachable(checks, kubeconfigPath); + if (!apiOk) { + return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() }; + } + + const selfPod = await getSelfPodInfo(kubeconfigPath); + const namespace = asString(config.namespace, "") || selfPod.namespace; + + // 2. Target namespace exists + const nsOk = await checkNamespace(namespace, selfPod.namespace, checks, kubeconfigPath); + if (!nsOk) { + return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() }; + } + + // 3-5. Run remaining checks in parallel + await Promise.all([ + checkRbac(namespace, checks, kubeconfigPath), + checkSecret(namespace, secretRef, checks, kubeconfigPath), + checkPvc(selfPod, checks, kubeconfigPath), + ]); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/src/ui-parser.ts b/src/ui-parser.ts new file mode 100644 index 0000000..28b5859 --- /dev/null +++ b/src/ui-parser.ts @@ -0,0 +1,13 @@ +/** + * Self-contained stdout parser for OpenCode JSONL output. + * Zero external imports — required by the Paperclip adapter plugin UI parser contract. + */ + +type TranscriptEntry = + | { kind: "stdout"; ts: string; text: string } + | { kind: "stderr"; ts: string; text: string } + | { kind: "system"; ts: string; text: string }; + +export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] { + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5ff4203 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +}