@nihaopaul/opencode-agent-controlplane 0.0.0-stage → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -2
- package/index.js +127 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Control Plane OpenCode plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connects a local OpenCode instance to an Agent Control Plane without a second
|
|
4
|
+
tunnel implementation. The plugin invokes the released `node-tunnel` helper in
|
|
5
|
+
`connect` mode. It does **not** restart OpenCode or manage the local OpenCode
|
|
6
|
+
version.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
opencode plugin add @nihaopaul/opencode-agent-controlplane@0.1.7
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Point it at a short enrol link with either an environment variable:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
export AGENT_CONTROLPLANE_ENROLL_URL='https://control.example.com/_/enroll/7K3M9Q2B'
|
|
18
|
+
opencode
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
or an OpenCode plugin entry:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"plugin": [
|
|
26
|
+
[
|
|
27
|
+
"@nihaopaul/opencode-agent-controlplane@0.1.7",
|
|
28
|
+
{ "enroll_url": "https://control.example.com/_/enroll/7K3M9Q2B" }
|
|
29
|
+
]
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The helper registers, waits for approval in the control plane, stores its secret
|
|
35
|
+
under `$XDG_STATE_HOME/opencode`, enrols a certificate, and runs the tunnel. It
|
|
36
|
+
does not start, stop, upgrade, or restart OpenCode.
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
|
|
40
|
+
| Option | Environment | Default |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `enroll_url` | `AGENT_CONTROLPLANE_ENROLL_URL` | required |
|
|
43
|
+
| `node_tunnel` | `AGENT_CONTROLPLANE_NODE_TUNNEL` | `node-tunnel` on `PATH` |
|
|
44
|
+
| - | `AGENT_CONTROLPLANE_LOG` | `$XDG_STATE_HOME/opencode/agent-controlplane.log` |
|
|
45
|
+
|
|
46
|
+
Plain HTTP enrol URLs are accepted only for loopback. The helper log is created
|
|
47
|
+
with mode `0600`.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- the released `node-tunnel` binary on `PATH`, or an explicit `node_tunnel`;
|
|
52
|
+
- Node.js 22.14 or newer;
|
|
53
|
+
- outbound access to the control plane, directly or through the standard proxy
|
|
54
|
+
variables supported by `node-tunnel`.
|
|
55
|
+
|
|
56
|
+
## Publishing
|
|
57
|
+
|
|
58
|
+
This package is designed to publish with **npm trusted publishing (OIDC)** and
|
|
59
|
+
no long-lived `NPM_TOKEN`.
|
|
60
|
+
|
|
61
|
+
On npmjs.com, open the package settings and add a trusted publisher:
|
|
62
|
+
|
|
63
|
+
- **Organization or user:** `nihaopaul`
|
|
64
|
+
- **Repository:** `agent-controlplane`
|
|
65
|
+
- **Workflow filename:** `publish-plugin.yml`
|
|
66
|
+
- **Allowed actions:** `npm publish`
|
|
67
|
+
- **Environment name:** optional; leave empty unless you add one to the workflow
|
|
68
|
+
|
|
69
|
+
Then push a `plugin-v*` tag. `.github/workflows/publish-plugin.yml` uses
|
|
70
|
+
`id-token: write` and runs `npm publish --access public` without a token.
|
|
71
|
+
|
|
72
|
+
The repository is private, so npm cannot generate provenance attestations even
|
|
73
|
+
with trusted publishing. Authentication is still OIDC-bound to this repository
|
|
74
|
+
and workflow. If provenance is required, publish from a public source mirror.
|
|
75
|
+
|
|
76
|
+
The package name is `@nihaopaul/opencode-agent-controlplane`; change the name in
|
|
77
|
+
`package.json`, the workflow, and the trusted-publisher configuration together.
|
package/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BINARY = "node-tunnel";
|
|
7
|
+
const ENV_URL = "AGENT_CONTROLPLANE_ENROLL_URL";
|
|
8
|
+
const ENV_BINARY = "AGENT_CONTROLPLANE_NODE_TUNNEL";
|
|
9
|
+
const ENV_LOG = "AGENT_CONTROLPLANE_LOG";
|
|
10
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
11
|
+
const ENROLL_PATH = /^\/_\/(?:enroll\/[A-Za-z0-9-]{1,64}|connect)$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validate a short enrol link (or the legacy connect link) before it is passed
|
|
15
|
+
* to the helper. Plain HTTP is only accepted for loopback development.
|
|
16
|
+
*/
|
|
17
|
+
export function validateEnrollUrl(value) {
|
|
18
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
19
|
+
throw new Error("enrol URL is required");
|
|
20
|
+
}
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value.trim());
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error("enrol URL is not a valid URL");
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
28
|
+
throw new Error("enrol URL must use https://");
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== "https:" && !LOOPBACK.has(url.hostname)) {
|
|
31
|
+
throw new Error("plain HTTP enrol URLs are only allowed for loopback");
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password) {
|
|
34
|
+
throw new Error("enrol URL must not contain credentials");
|
|
35
|
+
}
|
|
36
|
+
if (!ENROLL_PATH.test(url.pathname)) {
|
|
37
|
+
throw new Error("enrol URL must be /_/enroll/<code> or /_/connect");
|
|
38
|
+
}
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function enrollUrlFrom(options = {}, env = process.env) {
|
|
43
|
+
const value = options?.enroll_url ?? options?.enrollUrl ?? env[ENV_URL];
|
|
44
|
+
return value ? validateEnrollUrl(value) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function logPath(env = process.env) {
|
|
48
|
+
if (env[ENV_LOG]) return env[ENV_LOG];
|
|
49
|
+
const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), ".local", "state");
|
|
50
|
+
return join(stateHome, "opencode", "agent-controlplane.log");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function binaryFrom(options = {}, env = process.env) {
|
|
54
|
+
const configured = options?.node_tunnel ?? options?.binary ?? env[ENV_BINARY];
|
|
55
|
+
return typeof configured === "string" && configured.trim() ? configured.trim() : DEFAULT_BINARY;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start `node-tunnel connect <url>` in the background. The helper owns
|
|
60
|
+
* registration, approval, secret storage, enrolment, and the tunnel. The plugin
|
|
61
|
+
* never restarts OpenCode and never manages the local OpenCode version.
|
|
62
|
+
*/
|
|
63
|
+
export function startHelper({
|
|
64
|
+
url,
|
|
65
|
+
binary = DEFAULT_BINARY,
|
|
66
|
+
logFile = logPath(),
|
|
67
|
+
env = process.env,
|
|
68
|
+
spawnImpl = spawn,
|
|
69
|
+
} = {}) {
|
|
70
|
+
mkdirSync(dirname(logFile), { recursive: true, mode: 0o700 });
|
|
71
|
+
const fd = openSync(logFile, "a", 0o600);
|
|
72
|
+
let child;
|
|
73
|
+
try {
|
|
74
|
+
try {
|
|
75
|
+
chmodSync(logFile, 0o600);
|
|
76
|
+
} catch {
|
|
77
|
+
// The file may be on a filesystem without POSIX modes.
|
|
78
|
+
}
|
|
79
|
+
child = spawnImpl(binary, ["connect", url], {
|
|
80
|
+
detached: false,
|
|
81
|
+
stdio: ["ignore", fd, fd],
|
|
82
|
+
env: { ...env, RUST_LOG: env.RUST_LOG || "info" },
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
} finally {
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
child.on?.("error", (error) => {
|
|
89
|
+
console.error(`[agent-controlplane] failed to start ${binary}: ${error.message}`);
|
|
90
|
+
});
|
|
91
|
+
return child;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stopHelper(child) {
|
|
95
|
+
if (!child || child.killed) return;
|
|
96
|
+
try {
|
|
97
|
+
child.kill("SIGTERM");
|
|
98
|
+
} catch {
|
|
99
|
+
// The process may already be gone.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let activeChild = null;
|
|
104
|
+
|
|
105
|
+
export const AgentControlPlane = async (_input, options = {}) => {
|
|
106
|
+
try {
|
|
107
|
+
const url = enrollUrlFrom(options);
|
|
108
|
+
if (!url) return {};
|
|
109
|
+
if (activeChild && !activeChild.killed) {
|
|
110
|
+
console.error("[agent-controlplane] helper already running; leaving it in place");
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
const child = startHelper({ url, binary: binaryFrom(options) });
|
|
114
|
+
activeChild = child;
|
|
115
|
+
return {
|
|
116
|
+
dispose: async () => {
|
|
117
|
+
if (activeChild === child) activeChild = null;
|
|
118
|
+
stopHelper(child);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error(`[agent-controlplane] ${error instanceof Error ? error.message : String(error)}`);
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default AgentControlPlane;
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nihaopaul/opencode-agent-controlplane",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Connect a local OpenCode instance to an Agent Control Plane by invoking the released node-tunnel helper.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": "./index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.js",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/nihaopaul/agent-controlplane.git",
|
|
15
|
+
"directory": "plugin"
|
|
16
|
+
},
|
|
17
|
+
"license": "UNLICENSED",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.14"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.js",
|
|
23
|
+
"pack:check": "npm pack --dry-run"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
}
|
|
28
|
+
}
|