Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:08:05 -04:00
commit be7c525063
13 changed files with 2436 additions and 0 deletions
+54
View File
@@ -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
+834
View File
@@ -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
}
}
}
}
}
+34
View File
@@ -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"
}
}
+58
View File
@@ -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";
+451
View File
@@ -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<string, string>, _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<string> {
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<string> {
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<string> {
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<number | null> {
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<void> {
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<AdapterExecutionResult> {
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<typeof onMeta>[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<string, unknown>
: null;
const provider = parseModelProvider(model);
// Build a minimal env record for biller inference
const billerEnv: Record<string, string> = {};
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;
}
+19
View File
@@ -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 };
+381
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, number>;
}
function sanitizeForK8sName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
}
function buildEnvVars(
ctx: AdapterExecutionContext,
selfPod: SelfPodInfo,
config: Record<string, unknown>,
): 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<string, string> = {
...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, unknown>): 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<string, string> = {
"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<string, string> } : {}),
...(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 };
}
+172
View File
@@ -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<string, string>;
}
/** 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<string, k8s.KubeConfig>();
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<SelfPodInfo> {
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<string, string> = {};
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;
}
+99
View File
@@ -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,
);
}
+61
View File
@@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};
+241
View File
@@ -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<boolean> {
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<boolean> {
// 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<void> {
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<void> {
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<void> {
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<AdapterEnvironmentTestResult> {
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(),
};
}
+13
View File
@@ -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 }];
}
+19
View File
@@ -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"]
}