@harness-control/runner 0.2.0 → 0.3.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 +5 -1
- package/dist/connect.d.ts +2 -0
- package/dist/connect.js +42 -10
- package/dist/workspaces/index.d.ts +3 -0
- package/dist/workspaces/index.js +72 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,11 @@ Choose which detected agents to enable in the terminal, then approve this comput
|
|
|
12
12
|
|
|
13
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.
|
|
14
14
|
|
|
15
|
-
`connect` stores endpoint-specific configuration under `~/.hcp-runner/connections/`.
|
|
15
|
+
`connect` stores one installation ID in `~/.hcp-runner/identity.json` and endpoint-specific configuration under `~/.hcp-runner/connections/`. On upgrade it adopts the existing runner ID. The ID survives reconnects, package upgrades, and credential replacement; it is not a hardware fingerprint. Do not copy this directory to another computer.
|
|
16
|
+
|
|
17
|
+
Use `--config <path>` to adopt an existing configuration on first setup. Its location is remembered, so the standard command reconnects it afterward. A second configuration for the same endpoint is rejected, and only one `connect` process can use that endpoint at a time. Use `--providers codex,claude` for explicit noninteractive selection, `--no-browser` to open the printed link manually, or `--pair` to replace revoked credentials with browser approval. Control planes should match the approved account and runner ID within their environment, rotate credentials on that existing machine record, and preserve its references. Hostnames are labels, not identity.
|
|
18
|
+
|
|
19
|
+
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 integrations that own their runner identity and configuration lifecycle.
|
|
16
20
|
|
|
17
21
|
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).
|
|
18
22
|
|
package/dist/connect.d.ts
CHANGED
package/dist/connect.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { link, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir, hostname } from "node:os";
|
|
5
5
|
import { dirname, join, parse, resolve } from "node:path";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { z } from "zod";
|
|
8
9
|
import { loadRunnerConfig, ProviderInstanceConfigSchema, RunnerConfigSchema } from "./config/index.js";
|
|
9
10
|
import { createDefaultHarnessAdapterRegistry } from "./harnesses/adapters/registry.js";
|
|
10
11
|
import { loadRunnerCredential, normalizeControlPlaneUrl, pairWithReferenceControlPlane, writeRunnerCredentials } from "./pairing/index.js";
|
|
@@ -13,7 +14,8 @@ export function parseConnectOptions(args, home = homedir()) {
|
|
|
13
14
|
throw new Error("Usage: hcp-runner connect <control-plane-url> [--config path] [--providers codex,claude,opencode] [--pair] [--no-browser]");
|
|
14
15
|
const controlPlaneUrl = normalizeControlPlaneUrl(args[0]);
|
|
15
16
|
const key = createHash("sha256").update(controlPlaneUrl).digest("hex").slice(0, 16);
|
|
16
|
-
const
|
|
17
|
+
const connectionDirectory = join(home, ".hcp-runner", "connections", key);
|
|
18
|
+
const options = { controlPlaneUrl, connectionDirectory, identityPath: join(home, ".hcp-runner", "identity.json"), configPath: join(connectionDirectory, "runner.json"), pair: false, openBrowser: true };
|
|
17
19
|
for (let index = 1; index < args.length; index++) {
|
|
18
20
|
const arg = args[index];
|
|
19
21
|
if (arg === "--pair")
|
|
@@ -53,7 +55,7 @@ export async function openApprovalBrowser(value) {
|
|
|
53
55
|
child.once("exit", code => resolveOpened(code === 0));
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
|
-
async function
|
|
58
|
+
async function saveJson(path, config) {
|
|
57
59
|
const temp = `${path}.${randomUUID()}.tmp`;
|
|
58
60
|
try {
|
|
59
61
|
await writeFile(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
@@ -63,10 +65,29 @@ async function saveConfig(path, config) {
|
|
|
63
65
|
await rm(temp, { force: true });
|
|
64
66
|
}
|
|
65
67
|
}
|
|
68
|
+
async function installationIdentity(path, existingRunnerId) {
|
|
69
|
+
const schema = z.object({ runner_id: z.string().min(1).max(200) }).strict();
|
|
70
|
+
if (!existsSync(path)) {
|
|
71
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(temporary, JSON.stringify({ runner_id: existingRunnerId ?? `runner-${randomUUID()}` }), { mode: 0o600, flag: "wx" });
|
|
74
|
+
try {
|
|
75
|
+
await link(temporary, path);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code !== "EEXIST")
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await rm(temporary, { force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return schema.parse(JSON.parse(await readFile(path, "utf8"))).runner_id;
|
|
87
|
+
}
|
|
66
88
|
export async function connectMachine(options, run) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const lockPath = `${configPath}.lock`;
|
|
89
|
+
await mkdir(options.connectionDirectory, { recursive: true, mode: 0o700 });
|
|
90
|
+
const lockPath = join(options.connectionDirectory, "runner.json.lock");
|
|
70
91
|
let lock;
|
|
71
92
|
try {
|
|
72
93
|
lock = await open(lockPath, "wx", 0o600);
|
|
@@ -86,12 +107,22 @@ export async function connectMachine(options, run) {
|
|
|
86
107
|
process.once("SIGINT", cancelSetup);
|
|
87
108
|
process.once("SIGTERM", cancelSetup);
|
|
88
109
|
try {
|
|
110
|
+
const locationPath = join(options.connectionDirectory, "config-path.json");
|
|
111
|
+
const defaultPath = join(options.connectionDirectory, "runner.json");
|
|
112
|
+
const savedPath = existsSync(locationPath) ? z.string().min(1).parse(JSON.parse(await readFile(locationPath, "utf8"))) : existsSync(defaultPath) ? defaultPath : undefined;
|
|
113
|
+
if (savedPath && options.configPath !== defaultPath && options.configPath !== savedPath) {
|
|
114
|
+
throw new Error(`This machine already has a configuration for this server: ${savedPath}. Run the standard connect command to reuse it.`);
|
|
115
|
+
}
|
|
116
|
+
const configPath = savedPath ?? options.configPath;
|
|
117
|
+
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
89
118
|
const existing = existsSync(configPath);
|
|
90
119
|
let config;
|
|
91
120
|
if (existing) {
|
|
92
121
|
config = await loadRunnerConfig(configPath);
|
|
93
122
|
if (normalizeControlPlaneUrl(config.control_plane_url) !== options.controlPlaneUrl)
|
|
94
123
|
throw new Error("This config belongs to another control plane. Use its URL or a different --config path.");
|
|
124
|
+
if (await installationIdentity(options.identityPath, config.runner_id) !== config.runner_id)
|
|
125
|
+
throw new Error("This configuration belongs to a different HCP installation. Use this installation’s saved configuration; do not copy machine credentials between installations.");
|
|
95
126
|
if (options.providers && options.providers.slice().sort().join(",") !== config.provider_instances.filter(provider => provider.enabled).map(provider => provider.driver_kind).sort().join(",")) {
|
|
96
127
|
throw new Error("This connection already has different agent settings. Edit its config to change them; reconnect preserves existing settings.");
|
|
97
128
|
}
|
|
@@ -119,12 +150,13 @@ export async function connectMachine(options, run) {
|
|
|
119
150
|
throw new Error("No coding agents found. Install and sign in to an agent, then run this command again.");
|
|
120
151
|
if (selected.some(driver => !installed.includes(driver)))
|
|
121
152
|
throw new Error("A selected agent is not installed. Install it before connecting.");
|
|
122
|
-
const
|
|
123
|
-
config = RunnerConfigSchema.parse({ runner_id:
|
|
153
|
+
const runnerId = await installationIdentity(options.identityPath);
|
|
154
|
+
config = RunnerConfigSchema.parse({ runner_id: runnerId, host_id: hostname(), control_plane_url: options.controlPlaneUrl,
|
|
124
155
|
credentials_path: join(dirname(configPath), "credentials.json"), state_path: join(dirname(configPath), "state.json"),
|
|
125
156
|
workspaces: [], workspace_management: { allowed_roots: [parse(homedir()).root] }, provider_instances: providers.filter(provider => selected.includes(provider.driver_kind)) });
|
|
126
|
-
await
|
|
157
|
+
await saveJson(configPath, config);
|
|
127
158
|
}
|
|
159
|
+
await saveJson(locationPath, configPath);
|
|
128
160
|
setupAbort.signal.throwIfAborted();
|
|
129
161
|
const credential = await loadRunnerCredential(config);
|
|
130
162
|
if (!credential || options.pair) {
|
|
@@ -138,7 +170,7 @@ export async function connectMachine(options, run) {
|
|
|
138
170
|
} });
|
|
139
171
|
config.credentials_path ??= join(dirname(configPath), "credentials.json");
|
|
140
172
|
await writeRunnerCredentials(config.credentials_path, pairing.credential);
|
|
141
|
-
await
|
|
173
|
+
await saveJson(configPath, config);
|
|
142
174
|
}
|
|
143
175
|
process.removeListener("SIGINT", cancelSetup);
|
|
144
176
|
process.removeListener("SIGTERM", cancelSetup);
|
|
@@ -8,6 +8,9 @@ export declare class WorkspaceManager {
|
|
|
8
8
|
constructor(config: RunnerConfig, configPath: string | undefined, sessions: Pick<HarnessSessionManager, "updateWorkspaceConfiguration">);
|
|
9
9
|
snapshot(): HcpWorkspaceManagement;
|
|
10
10
|
execute(requestId: string, request: HcpWorkspacesRequestPayload): Promise<HcpWorkspacesResultPayload>;
|
|
11
|
+
private canonicalRoots;
|
|
12
|
+
private allowedDirectory;
|
|
13
|
+
private browse;
|
|
11
14
|
private change;
|
|
12
15
|
}
|
|
13
16
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/workspaces/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { open, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, relative, sep } from "node:path";
|
|
2
|
+
import { open, readdir, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { RunnerConfigSchema } from "../config/index.js";
|
|
@@ -17,25 +18,88 @@ export class WorkspaceManager {
|
|
|
17
18
|
return {
|
|
18
19
|
revision: this.config.workspace_revision ?? createHash("sha256").update(JSON.stringify(this.config.workspaces)).digest("hex"),
|
|
19
20
|
allowed_roots: this.configPath ? this.config.workspace_management?.allowed_roots ?? [] : [],
|
|
21
|
+
directory_browsing: true,
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
async execute(requestId, request) {
|
|
23
25
|
let outcome;
|
|
24
26
|
try {
|
|
25
|
-
if (request.operation.kind === "
|
|
26
|
-
|
|
27
|
-
throw new Error("Request expired. Refresh workspaces.");
|
|
27
|
+
if (request.operation.kind === "browse") {
|
|
28
|
+
outcome = await this.browse(request);
|
|
28
29
|
}
|
|
29
30
|
else {
|
|
30
|
-
|
|
31
|
+
if (request.operation.kind === "list") {
|
|
32
|
+
if (Date.parse(request.expires_at) <= Date.now())
|
|
33
|
+
throw new Error("Request expired. Refresh workspaces.");
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
await this.sessions.updateWorkspaceConfiguration(() => this.change(request));
|
|
37
|
+
}
|
|
38
|
+
outcome = { kind: "success" };
|
|
31
39
|
}
|
|
32
|
-
outcome = { kind: "success" };
|
|
33
40
|
}
|
|
34
41
|
catch (error) {
|
|
35
42
|
outcome = { kind: "error", message: error instanceof Error ? error.message.slice(0, 1000) : "Workspace update failed." };
|
|
36
43
|
}
|
|
37
44
|
return { request_id: requestId, outcome, management: this.snapshot(), workspaces: this.config.workspaces.map(workspace => ({ ...workspace })) };
|
|
38
45
|
}
|
|
46
|
+
async canonicalRoots() {
|
|
47
|
+
const roots = this.snapshot().allowed_roots;
|
|
48
|
+
if (roots.length === 0)
|
|
49
|
+
throw new Error("Folder browsing is disabled. Configure workspace_management.allowed_roots locally, then restart the runner.");
|
|
50
|
+
return Promise.all(roots.map(root => realpath(root)));
|
|
51
|
+
}
|
|
52
|
+
async allowedDirectory(path, roots) {
|
|
53
|
+
if (!isAbsolute(path))
|
|
54
|
+
throw new Error("Use an absolute folder path on this machine.");
|
|
55
|
+
const canonical = await realpath(path);
|
|
56
|
+
if (!roots.some(root => contained(root, canonical)))
|
|
57
|
+
throw new Error("The folder is outside this machine’s allowed roots.");
|
|
58
|
+
if (!(await stat(canonical)).isDirectory())
|
|
59
|
+
throw new Error("Choose an existing folder.");
|
|
60
|
+
return canonical;
|
|
61
|
+
}
|
|
62
|
+
async browse(request) {
|
|
63
|
+
if (request.operation.kind !== "browse")
|
|
64
|
+
throw new Error("Expected a folder browsing request.");
|
|
65
|
+
const operation = request.operation;
|
|
66
|
+
if (Date.parse(request.expires_at) <= Date.now())
|
|
67
|
+
throw new Error("Folder request expired. Try again.");
|
|
68
|
+
const roots = await this.canonicalRoots();
|
|
69
|
+
const home = homedir();
|
|
70
|
+
const path = await this.allowedDirectory(operation.path ?? (roots.some(root => contained(root, home)) ? home : roots[0]), roots);
|
|
71
|
+
const candidates = (await readdir(path, { withFileTypes: true }))
|
|
72
|
+
.filter(entry => (entry.isDirectory() || entry.isSymbolicLink()) && (!operation.cursor || entry.name > operation.cursor))
|
|
73
|
+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
74
|
+
const entries = [];
|
|
75
|
+
let pageBytes = 0;
|
|
76
|
+
let hasMore = false;
|
|
77
|
+
for (const entry of candidates) {
|
|
78
|
+
if (Date.parse(request.expires_at) <= Date.now())
|
|
79
|
+
throw new Error("Folder request expired. Try again.");
|
|
80
|
+
const candidate = join(path, entry.name);
|
|
81
|
+
try {
|
|
82
|
+
const canonical = await realpath(candidate);
|
|
83
|
+
if (roots.some(root => contained(root, canonical)) && (await stat(canonical)).isDirectory()) {
|
|
84
|
+
const item = { name: entry.name, path: canonical };
|
|
85
|
+
const bytes = Buffer.byteLength(JSON.stringify(item));
|
|
86
|
+
if (entries.length === 200 || pageBytes + bytes > 64 * 1024) {
|
|
87
|
+
hasMore = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
entries.push(item);
|
|
91
|
+
pageBytes += bytes;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!(error instanceof Error && "code" in error && ["ENOENT", "EACCES", "EPERM", "ELOOP"].includes(String(error.code))))
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const parent = dirname(path);
|
|
100
|
+
return { kind: "directory", path, ...(parent !== path && roots.some(root => contained(root, parent)) ? { parent } : {}),
|
|
101
|
+
entries, ...(hasMore ? { next_cursor: entries.at(-1).name } : {}) };
|
|
102
|
+
}
|
|
39
103
|
async change(request) {
|
|
40
104
|
if (Date.parse(request.expires_at) <= Date.now())
|
|
41
105
|
throw new Error("Request expired. Refresh workspaces.");
|
|
@@ -48,14 +112,7 @@ export class WorkspaceManager {
|
|
|
48
112
|
const operation = request.operation;
|
|
49
113
|
let workspaces = this.config.workspaces.map(workspace => ({ ...workspace }));
|
|
50
114
|
if (operation.kind === "add") {
|
|
51
|
-
|
|
52
|
-
throw new Error("Use an absolute folder path on this machine.");
|
|
53
|
-
const path = await realpath(operation.path);
|
|
54
|
-
if (!(await stat(path)).isDirectory())
|
|
55
|
-
throw new Error("The workspace must be an existing folder.");
|
|
56
|
-
const canonicalRoots = await Promise.all(roots.map(root => realpath(root)));
|
|
57
|
-
if (!canonicalRoots.some(root => contained(root, path)))
|
|
58
|
-
throw new Error("The folder is outside this machine’s allowed roots.");
|
|
115
|
+
const path = await this.allowedDirectory(operation.path, await this.canonicalRoots());
|
|
59
116
|
const existingPaths = await Promise.all(workspaces.map(async (workspace) => {
|
|
60
117
|
try {
|
|
61
118
|
return await realpath(workspace.path);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-control/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,7 @@
|
|
|
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.3.0",
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
50
|
"ws": "^8.21.0",
|
|
51
51
|
"zod": "^4.4.3"
|