@@ -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
|
||||||
Generated
+834
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }];
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user