@harness-control/runner 0.1.0 → 0.2.0
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 +4 -6
- package/dist/connect.d.ts +11 -0
- package/dist/connect.js +155 -0
- package/dist/index.js +11 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -5,16 +5,14 @@ Local runner for Harness Control Protocol, with Codex, Claude Code, OpenCode, an
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install --global @harness-control/runner
|
|
7
7
|
hcp-runner version
|
|
8
|
-
hcp-runner
|
|
8
|
+
hcp-runner connect https://your-app.example/hcp/runner
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Choose which detected agents to enable in the terminal, then approve this computer in the browser that opens. HCP saves its configuration and starts the connection. Keep the terminal open; run the same command to reconnect. Add existing project folders from your app after connecting.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
hcp-runner run --config runner.json
|
|
15
|
-
```
|
|
13
|
+
Use your application's actual runner URL. Guided setup allows folder registration under the local filesystem root (the home drive on Windows), but adds no folders automatically. Custom roots and provider settings can be changed in the printed configuration path. Existing settings are preserved on reconnect. Credentials stay in the local credentials file; do not commit it. The runner connects outward, so the local machine needs no inbound port.
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
`connect` stores endpoint-specific configuration under `~/.hcp-runner/connections/`. Use `--config <path>` for an existing installation, `--providers codex,claude` for explicit noninteractive selection, `--no-browser` to open the printed link manually, or `--pair` to replace revoked credentials. Run `codex login` or `claude auth login` locally if needed, then restart HCP. The lower-level `pair --out runner.json` and `run --config runner.json` commands remain available for custom integrations.
|
|
18
16
|
|
|
19
17
|
For embedding, public modules are available at `/connection`, `/config`, `/harnesses`, `/mcp`, `/state`, and `/pairing`. Importing the package does not start a runner. See [configuration and examples](https://github.com/qazisaad/harness-control-protocol#runner-configuration), [workspace management](https://github.com/qazisaad/harness-control-protocol/blob/main/docs/workspace-management.md), and [provider support](https://github.com/qazisaad/harness-control-protocol/blob/main/docs/native-providers.md).
|
|
20
18
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ConnectOptions = {
|
|
2
|
+
controlPlaneUrl: string;
|
|
3
|
+
configPath: string;
|
|
4
|
+
providers?: string[];
|
|
5
|
+
pair: boolean;
|
|
6
|
+
openBrowser: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function parseConnectOptions(args: string[], home?: string): ConnectOptions;
|
|
9
|
+
export declare function openApprovalBrowser(value: string): Promise<boolean>;
|
|
10
|
+
export declare function connectMachine(options: ConnectOptions, run: (path: string) => Promise<number>): Promise<number>;
|
|
11
|
+
//# sourceMappingURL=connect.d.ts.map
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { loadRunnerConfig, ProviderInstanceConfigSchema, RunnerConfigSchema } from "./config/index.js";
|
|
9
|
+
import { createDefaultHarnessAdapterRegistry } from "./harnesses/adapters/registry.js";
|
|
10
|
+
import { loadRunnerCredential, normalizeControlPlaneUrl, pairWithReferenceControlPlane, writeRunnerCredentials } from "./pairing/index.js";
|
|
11
|
+
export function parseConnectOptions(args, home = homedir()) {
|
|
12
|
+
if (!args[0])
|
|
13
|
+
throw new Error("Usage: hcp-runner connect <control-plane-url> [--config path] [--providers codex,claude,opencode] [--pair] [--no-browser]");
|
|
14
|
+
const controlPlaneUrl = normalizeControlPlaneUrl(args[0]);
|
|
15
|
+
const key = createHash("sha256").update(controlPlaneUrl).digest("hex").slice(0, 16);
|
|
16
|
+
const options = { controlPlaneUrl, configPath: join(home, ".hcp-runner", "connections", key, "runner.json"), pair: false, openBrowser: true };
|
|
17
|
+
for (let index = 1; index < args.length; index++) {
|
|
18
|
+
const arg = args[index];
|
|
19
|
+
if (arg === "--pair")
|
|
20
|
+
options.pair = true;
|
|
21
|
+
else if (arg === "--no-browser")
|
|
22
|
+
options.openBrowser = false;
|
|
23
|
+
else if (arg === "--config" || arg === "--providers") {
|
|
24
|
+
const value = args[++index];
|
|
25
|
+
if (!value || value.startsWith("--"))
|
|
26
|
+
throw new Error(`${arg} requires a value.`);
|
|
27
|
+
if (arg === "--config")
|
|
28
|
+
options.configPath = resolve(value);
|
|
29
|
+
else
|
|
30
|
+
options.providers = parseProviders(value);
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
throw new Error(`Unknown connect argument: ${arg}`);
|
|
34
|
+
}
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
function parseProviders(value) {
|
|
38
|
+
const providers = value.split(",").map(item => item.trim()).filter(Boolean);
|
|
39
|
+
if (!providers.length || providers.some(item => !["codex", "claude", "opencode"].includes(item)) || new Set(providers).size !== providers.length) {
|
|
40
|
+
throw new Error("Choose one or more agents: codex,claude,opencode (comma-separated, without duplicates).");
|
|
41
|
+
}
|
|
42
|
+
return providers;
|
|
43
|
+
}
|
|
44
|
+
export async function openApprovalBrowser(value) {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
47
|
+
throw new Error("Invalid pairing approval URL.");
|
|
48
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
49
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url.href] : [url.href];
|
|
50
|
+
return new Promise(resolveOpened => {
|
|
51
|
+
const child = spawn(command, args, { stdio: "ignore", timeout: 5_000 });
|
|
52
|
+
child.once("error", () => resolveOpened(false));
|
|
53
|
+
child.once("exit", code => resolveOpened(code === 0));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function saveConfig(path, config) {
|
|
57
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
58
|
+
try {
|
|
59
|
+
await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
60
|
+
await rename(temp, path);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await rm(temp, { force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function connectMachine(options, run) {
|
|
67
|
+
const configPath = options.configPath;
|
|
68
|
+
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
69
|
+
const lockPath = `${configPath}.lock`;
|
|
70
|
+
let lock;
|
|
71
|
+
try {
|
|
72
|
+
lock = await open(lockPath, "wx", 0o600);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code !== "EEXIST")
|
|
76
|
+
throw error;
|
|
77
|
+
throw new Error(`This connection is already in use. Stop its runner first. If it crashed, remove ${lockPath} after confirming it is stopped.`);
|
|
78
|
+
}
|
|
79
|
+
await lock.writeFile(String(process.pid));
|
|
80
|
+
await lock.close();
|
|
81
|
+
const release = () => { if (existsSync(lockPath))
|
|
82
|
+
unlinkSync(lockPath); };
|
|
83
|
+
process.once("exit", release);
|
|
84
|
+
const setupAbort = new AbortController();
|
|
85
|
+
const cancelSetup = () => setupAbort.abort(new Error("Setup cancelled. Run the command again when ready."));
|
|
86
|
+
process.once("SIGINT", cancelSetup);
|
|
87
|
+
process.once("SIGTERM", cancelSetup);
|
|
88
|
+
try {
|
|
89
|
+
const existing = existsSync(configPath);
|
|
90
|
+
let config;
|
|
91
|
+
if (existing) {
|
|
92
|
+
config = await loadRunnerConfig(configPath);
|
|
93
|
+
if (normalizeControlPlaneUrl(config.control_plane_url) !== options.controlPlaneUrl)
|
|
94
|
+
throw new Error("This config belongs to another control plane. Use its URL or a different --config path.");
|
|
95
|
+
if (options.providers && options.providers.slice().sort().join(",") !== config.provider_instances.filter(provider => provider.enabled).map(provider => provider.driver_kind).sort().join(",")) {
|
|
96
|
+
throw new Error("This connection already has different agent settings. Edit its config to change them; reconnect preserves existing settings.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const providers = ["codex", "claude", "opencode"].map(driver => ProviderInstanceConfigSchema.parse({ id: `${driver}-local`, driver_kind: driver, display_name: driver === "claude" ? "Claude Code" : driver === "codex" ? "Codex" : "OpenCode" }));
|
|
101
|
+
console.log("Checking installed coding agents…");
|
|
102
|
+
const statuses = await createDefaultHarnessAdapterRegistry().probeProviders(providers);
|
|
103
|
+
for (const status of statuses)
|
|
104
|
+
console.log(` ${status.driver_kind}: ${!status.installed ? "not installed" : status.authStatus === "unauthenticated" ? "sign in required" : status.status ?? "unknown"}${status.message ? ` — ${status.message}` : ""}`);
|
|
105
|
+
const installed = statuses.filter(status => status.installed).map(status => status.driver_kind);
|
|
106
|
+
let selected = options.providers;
|
|
107
|
+
if (!selected && process.stdin.isTTY && process.stdout.isTTY && installed.length) {
|
|
108
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
109
|
+
prompt.once("SIGINT", cancelSetup);
|
|
110
|
+
try {
|
|
111
|
+
selected = parseProviders((await prompt.question(`Agents to enable [${installed.join(",")}]: `, { signal: setupAbort.signal })).trim() || installed.join(","));
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
prompt.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
selected ??= installed;
|
|
118
|
+
if (!selected.length)
|
|
119
|
+
throw new Error("No coding agents found. Install and sign in to an agent, then run this command again.");
|
|
120
|
+
if (selected.some(driver => !installed.includes(driver)))
|
|
121
|
+
throw new Error("A selected agent is not installed. Install it before connecting.");
|
|
122
|
+
const identity = randomUUID();
|
|
123
|
+
config = RunnerConfigSchema.parse({ runner_id: `runner-${identity}`, host_id: hostname(), control_plane_url: options.controlPlaneUrl,
|
|
124
|
+
credentials_path: join(dirname(configPath), "credentials.json"), state_path: join(dirname(configPath), "state.json"),
|
|
125
|
+
workspaces: [], workspace_management: { allowed_roots: [parse(homedir()).root] }, provider_instances: providers.filter(provider => selected.includes(provider.driver_kind)) });
|
|
126
|
+
await saveConfig(configPath, config);
|
|
127
|
+
}
|
|
128
|
+
setupAbort.signal.throwIfAborted();
|
|
129
|
+
const credential = await loadRunnerCredential(config);
|
|
130
|
+
if (!credential || options.pair) {
|
|
131
|
+
console.log("Approve this computer in your browser. You can then register existing folders in P2A; no folder is added automatically.");
|
|
132
|
+
const pairing = await pairWithReferenceControlPlane({ controlPlaneUrl: config.control_plane_url, runnerId: config.runner_id, hostId: config.host_id ?? config.runner_id, signal: setupAbort.signal,
|
|
133
|
+
onPairingCode: async (code) => {
|
|
134
|
+
console.log(`Approval link: ${code.pairing_url}\nFallback pairing code: ${code.pairing_code}`);
|
|
135
|
+
if (options.openBrowser && !await openApprovalBrowser(code.pairing_url))
|
|
136
|
+
console.log("Could not open a browser. Open the approval link yourself.");
|
|
137
|
+
console.log("Waiting for approval…");
|
|
138
|
+
} });
|
|
139
|
+
config.credentials_path ??= join(dirname(configPath), "credentials.json");
|
|
140
|
+
await writeRunnerCredentials(config.credentials_path, pairing.credential);
|
|
141
|
+
await saveConfig(configPath, config);
|
|
142
|
+
}
|
|
143
|
+
process.removeListener("SIGINT", cancelSetup);
|
|
144
|
+
process.removeListener("SIGTERM", cancelSetup);
|
|
145
|
+
console.log(`Configuration: ${configPath}\nKeep this terminal open. Press Ctrl+C to disconnect; run the same command to reconnect.`);
|
|
146
|
+
return await run(configPath);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
process.removeListener("SIGINT", cancelSetup);
|
|
150
|
+
process.removeListener("SIGTERM", cancelSetup);
|
|
151
|
+
process.removeListener("exit", release);
|
|
152
|
+
release();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=connect.js.map
|
package/dist/index.js
CHANGED
|
@@ -9,12 +9,22 @@ import { loadRunnerConfig } from "./config/index.js";
|
|
|
9
9
|
import { RunnerConnection } from "./connection/index.js";
|
|
10
10
|
import { HarnessSessionManager } from "./harnesses/index.js";
|
|
11
11
|
import { consoleLogger } from "./logs/index.js";
|
|
12
|
+
import { connectMachine, parseConnectOptions } from "./connect.js";
|
|
12
13
|
import { createDevelopmentHmacProofSigner } from "./mcp/McpAttachmentClient.js";
|
|
13
14
|
import { JsonRunnerStateStore, defaultRunnerStatePath } from "./state/index.js";
|
|
14
15
|
import { defaultCredentialsPath, loadRunnerCredential, normalizeControlPlaneUrl, pairWithReferenceControlPlane, requestConnectionToken, writeRunnerCredentials, } from "./pairing/index.js";
|
|
15
16
|
const RUNNER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
17
|
export async function main(argv = process.argv.slice(2)) {
|
|
17
18
|
const command = argv[0];
|
|
19
|
+
if (command === "connect") {
|
|
20
|
+
try {
|
|
21
|
+
return await connectMachine(parseConnectOptions(argv.slice(1)), path => main(["run", "--config", path]));
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
18
28
|
if (command === "version") {
|
|
19
29
|
console.log(`hcp-runner ${RUNNER_VERSION} (${HCP_VERSION})`);
|
|
20
30
|
return 0;
|
|
@@ -119,7 +129,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
119
129
|
});
|
|
120
130
|
return new Promise(() => undefined);
|
|
121
131
|
}
|
|
122
|
-
console.log("Usage: hcp-runner <version|pair|run>");
|
|
132
|
+
console.log("Usage: hcp-runner <version|connect|pair|run>");
|
|
123
133
|
return command ? 1 : 0;
|
|
124
134
|
}
|
|
125
135
|
function parseConfigPath(args) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-control/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -b",
|
|
43
43
|
"check": "tsc -b --pretty false",
|
|
44
|
-
"test": "node --import tsx --test --test-concurrency=1 src/**/*.test.ts"
|
|
44
|
+
"test": "node --import tsx --test --test-concurrency=1 'src/**/*.test.ts'"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@anthropic-ai/claude-agent-sdk": "0.3.267",
|
|
48
|
-
"@harness-control/protocol": "0.
|
|
48
|
+
"@harness-control/protocol": "0.2.0",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"ws": "^8.21.0",
|
|
51
51
|
"zod": "^4.4.3"
|