@task-handoff/node-agent 0.0.8 → 0.0.9-alpha.2

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.
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ mkdir -p \
5
+ "${TASK_HANDOFF_DATA_DIR:-/data/task-handoff}" \
6
+ "${TASK_HANDOFF_APP_CATALOG_DIR:-/data/task-handoff/app-catalog}" \
7
+ "${TASK_HANDOFF_APP_SESSION_DIR:-/data/task-handoff/app-sessions}" \
8
+ "${TASK_HANDOFF_RUNTIME_DIR:-/data/task-handoff/runtime}" \
9
+ "${TASK_HANDOFF_EVENTS_DIR:-/data/task-handoff/events}" \
10
+ "${TASK_HANDOFF_ARTIFACT_DIR:-/data/artifacts}" \
11
+ "${TASK_HANDOFF_LOG_DIR:-/data/logs}" \
12
+ "${CODEX_HOME:-/home/agent/.codex}" \
13
+ "${CLAUDE_HOME:-/home/agent/.claude}" \
14
+ "${TASK_HANDOFF_WORKSPACE:-/workspace}"
15
+
16
+ bootstrap_workspace() {
17
+ local workspace="${TASK_HANDOFF_WORKSPACE:-/workspace}"
18
+ local mode="${TASK_HANDOFF_WORKSPACE_MODE:-}"
19
+ local git_url="${TASK_HANDOFF_GIT_URL:-}"
20
+ local git_ref="${TASK_HANDOFF_GIT_REF:-}"
21
+
22
+ if [ "${mode}" != "git-clone" ] || [ -z "${git_url}" ]; then
23
+ return
24
+ fi
25
+
26
+ if ! command -v git >/dev/null 2>&1; then
27
+ echo "Git workspace bootstrap requested, but git is not installed." >&2
28
+ return 1
29
+ fi
30
+
31
+ mkdir -p "${workspace}"
32
+ if [ -n "$(find "${workspace}" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then
33
+ echo "Workspace ${workspace} is not empty; skipping git clone."
34
+ return
35
+ fi
36
+
37
+ local clone_args=(clone)
38
+ if [ -n "${TASK_HANDOFF_GIT_DEPTH:-}" ]; then
39
+ clone_args+=(--depth "${TASK_HANDOFF_GIT_DEPTH}")
40
+ fi
41
+ if [ "${TASK_HANDOFF_GIT_SUBMODULES:-false}" = "true" ]; then
42
+ clone_args+=(--recurse-submodules)
43
+ fi
44
+ if [ -n "${git_ref}" ]; then
45
+ clone_args+=(--branch "${git_ref}")
46
+ fi
47
+ clone_args+=("${git_url}" "${workspace}")
48
+
49
+ echo "Cloning workspace ${git_url} into ${workspace}."
50
+ git "${clone_args[@]}"
51
+
52
+ if [ -n "${TASK_HANDOFF_GIT_COMMIT:-}" ]; then
53
+ git -C "${workspace}" checkout "${TASK_HANDOFF_GIT_COMMIT}"
54
+ fi
55
+ }
56
+
57
+ if command -v web-cap >/dev/null 2>&1 && [ -d /tmp/task-handoff-web-cap-skill ]; then
58
+ for skills_dir in \
59
+ /home/agent/.agents/skills \
60
+ "${CODEX_HOME:-/home/agent/.codex}/skills" \
61
+ "${CLAUDE_HOME:-/home/agent/.claude}/skills"
62
+ do
63
+ rm -rf "${skills_dir}/web-cap"
64
+ mkdir -p "${skills_dir}"
65
+ cp -R /tmp/task-handoff-web-cap-skill "${skills_dir}/web-cap"
66
+ done
67
+ fi
68
+
69
+ start_web_cap_daemon() {
70
+ if ! command -v web-cap >/dev/null 2>&1; then
71
+ return
72
+ fi
73
+
74
+ local log_file="${TASK_HANDOFF_LOG_DIR:-/data/logs}/web-cap-daemon.log"
75
+ local idle_timeout="${WEB_CAP_DAEMON_IDLE_TIMEOUT_MS:-0}"
76
+
77
+ if WEB_CAP_DAEMON_IDLE_TIMEOUT_MS="${idle_timeout}" timeout 15s web-cap session-status >"${log_file}" 2>&1; then
78
+ echo "Web Cap daemon startup probe completed."
79
+ else
80
+ echo "Web Cap daemon startup probe failed; see ${log_file}."
81
+ fi
82
+ }
83
+
84
+ if [ "${1:-}" = "task-handoff" ] && [ "${2:-}" = "web" ]; then
85
+ bootstrap_workspace
86
+ start_web_cap_daemon
87
+ exec task-handoff-instance-launcher
88
+ fi
89
+
90
+ if [ "${1:-}" = "task-handoff" ]; then
91
+ shift
92
+ if [ "$#" -eq 0 ] || { [ "${1:-}" = "web" ] && [ "$#" -eq 1 ]; }; then
93
+ exec task-handoff-instance-launcher
94
+ fi
95
+ echo "Managed container commands must be launched through the active controlled-instance runtime." >&2
96
+ exit 64
97
+ fi
98
+
99
+ exec "$@"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Stable launcher ABI 1. Application releases are installed independently of
5
+ # the image and selected through the atomic `current` symlink.
6
+ readonly runtime_root="${TASK_HANDOFF_INSTANCE_RUNTIME_ROOT:-/opt/task-handoff/instance-runtime}"
7
+ readonly current="${runtime_root}/current"
8
+
9
+ bootstrap_wait() {
10
+ echo "No controlled-instance runtime is active; waiting for node-agent bootstrap."
11
+ trap 'exit 0' TERM INT
12
+ while [ ! -L "${current}" ]; do
13
+ sleep 2 &
14
+ wait "$!"
15
+ done
16
+ echo "A controlled-instance runtime is now active; restart the container to launch it."
17
+ while true; do
18
+ sleep 3600 &
19
+ wait "$!"
20
+ done
21
+ }
22
+
23
+ if [ ! -L "${current}" ]; then
24
+ if command -v task-handoff-controlled-instance >/dev/null 2>&1; then
25
+ exec task-handoff-controlled-instance web \
26
+ --host "${TASK_HANDOFF_WEB_HOST:-0.0.0.0}" \
27
+ --port "${TASK_HANDOFF_WEB_PORT:-8080}"
28
+ fi
29
+ bootstrap_wait
30
+ fi
31
+
32
+ exec node --input-type=module - "${current}" <<'NODE'
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import { pathToFileURL } from "node:url";
36
+
37
+ const current = process.argv[2];
38
+ const releaseRoot = fs.realpathSync(current);
39
+ const manifestPath = path.join(releaseRoot, "runtime-manifest.json");
40
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
41
+ if (manifest.packageName !== "@task-handoff/controlled-instance" || typeof manifest.entrypoint !== "string") {
42
+ throw new Error("Active controlled-instance runtime manifest is invalid.");
43
+ }
44
+ const entrypoint = path.resolve(releaseRoot, manifest.entrypoint);
45
+ if (!entrypoint.startsWith(`${releaseRoot}${path.sep}`) || !fs.statSync(entrypoint).isFile()) {
46
+ throw new Error("Active controlled-instance runtime entrypoint is invalid.");
47
+ }
48
+ process.argv = [process.execPath, entrypoint, "web", "--host", process.env.TASK_HANDOFF_WEB_HOST || "0.0.0.0", "--port", process.env.TASK_HANDOFF_WEB_PORT || "8080"];
49
+ await import(pathToFileURL(entrypoint).href);
50
+ NODE
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ const LAUNCHER_ABI = 1;
9
+ const PACKAGE_NAME = "@task-handoff/controlled-instance";
10
+
11
+ function fail(message) {
12
+ process.stderr.write(`${message}\n`);
13
+ process.exit(1);
14
+ }
15
+
16
+ function parseArgs(argv) {
17
+ if (!["install", "rollback", "verify-active"].includes(argv[0])) fail("Usage: task-handoff-runtime <install|rollback|verify-active> [...]");
18
+ const result = { command: argv[0] };
19
+ for (let index = 1; index < argv.length; index += 2) {
20
+ const key = argv[index];
21
+ const value = argv[index + 1];
22
+ if (!key?.startsWith("--") || value === undefined) fail("Invalid runtime installer arguments.");
23
+ result[key.slice(2)] = value;
24
+ }
25
+ return result;
26
+ }
27
+
28
+ function listPayloadFiles(root, relative = "") {
29
+ const files = [];
30
+ for (const entry of fs.readdirSync(path.join(root, relative), { withFileTypes: true })) {
31
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
32
+ if (child === "runtime-manifest.json") continue;
33
+ if (!entry.isSymbolicLink() && !entry.isDirectory() && !entry.isFile()) fail(`Runtime artifact contains an unsupported entry: ${child}`);
34
+ if (entry.isDirectory()) files.push(...listPayloadFiles(root, child));
35
+ else files.push({ relative: child, type: entry.isSymbolicLink() ? "L" : "F" });
36
+ }
37
+ return files;
38
+ }
39
+
40
+ function payloadSha256(root) {
41
+ const hash = crypto.createHash("sha256");
42
+ const resolvedRoot = fs.realpathSync(root);
43
+ const files = listPayloadFiles(root).sort((left, right) => Buffer.compare(Buffer.from(left.relative), Buffer.from(right.relative)));
44
+ for (const item of files) {
45
+ const absolute = path.join(root, item.relative);
46
+ const contents = item.type === "L" ? Buffer.from(fs.readlinkSync(absolute), "utf8") : fs.readFileSync(absolute);
47
+ if (item.type === "L") {
48
+ const resolved = fs.realpathSync(absolute);
49
+ if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) fail(`Runtime artifact symlink escapes its root: ${item.relative}`);
50
+ }
51
+ hash.update(Buffer.from(item.type, "ascii"));
52
+ hash.update(Buffer.from(item.relative, "utf8"));
53
+ hash.update(Buffer.from([0]));
54
+ hash.update(Buffer.from(String(contents.byteLength), "ascii"));
55
+ hash.update(Buffer.from([0]));
56
+ hash.update(contents);
57
+ hash.update(Buffer.from([0]));
58
+ }
59
+ return hash.digest("hex");
60
+ }
61
+
62
+ function validateVersion(value) {
63
+ return typeof value === "string" && /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,79}$/.test(value);
64
+ }
65
+
66
+ function lockReleaseTree(root) {
67
+ const owner = typeof process.getuid === "function" ? process.getuid() : 0;
68
+ const group = typeof process.getgid === "function" ? process.getgid() : 0;
69
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
70
+ const absolute = path.join(root, entry.name);
71
+ if (entry.isDirectory()) lockReleaseTree(absolute);
72
+ fs.lchownSync(absolute, owner, group);
73
+ if (!entry.isSymbolicLink()) {
74
+ const mode = fs.lstatSync(absolute).mode & 0o777;
75
+ fs.chmodSync(absolute, entry.isDirectory() || (mode & 0o111) !== 0 ? 0o755 : 0o644);
76
+ }
77
+ }
78
+ fs.chownSync(root, owner, group);
79
+ fs.chmodSync(root, 0o755);
80
+ }
81
+
82
+ const args = parseArgs(process.argv.slice(2));
83
+ const runtimeRoot = process.env.TASK_HANDOFF_INSTANCE_RUNTIME_ROOT || "/opt/task-handoff/instance-runtime";
84
+ function validateRelease(release, expectedVersion) {
85
+ const manifest = JSON.parse(fs.readFileSync(path.join(release, "runtime-manifest.json"), "utf8"));
86
+ if (manifest.packageName !== PACKAGE_NAME || (expectedVersion && manifest.version !== expectedVersion) || manifest.formatVersion !== 1 || manifest.launcherAbi !== LAUNCHER_ABI) fail("Runtime release manifest is invalid.");
87
+ const compatible = (manifest.platform === "universal" || manifest.platform === process.platform)
88
+ && (manifest.arch === "universal" || manifest.arch === process.arch);
89
+ if (!compatible || payloadSha256(release) !== manifest.sha256) fail("Runtime release is incompatible or corrupt.");
90
+ const entrypoint = path.resolve(release, manifest.entrypoint);
91
+ if (!entrypoint.startsWith(`${release}${path.sep}`) || !fs.statSync(entrypoint).isFile()) fail("Runtime release entrypoint is invalid.");
92
+ return manifest;
93
+ }
94
+ if (args.command === "verify-active") {
95
+ const release = fs.realpathSync(path.join(runtimeRoot, "current"));
96
+ const manifest = validateRelease(release);
97
+ process.stdout.write(`${JSON.stringify(manifest)}\n`);
98
+ process.exit(0);
99
+ }
100
+ if (args.command === "rollback") {
101
+ const previousRelease = fs.readFileSync(path.join(runtimeRoot, "previous-release"), "utf8").trim();
102
+ if (!validateVersion(previousRelease)) fail("No previous runtime release is available.");
103
+ const release = path.join(runtimeRoot, "releases", previousRelease);
104
+ const manifest = validateRelease(release);
105
+ const next = path.join(runtimeRoot, `.current-${process.pid}-${Date.now()}`);
106
+ fs.symlinkSync(path.relative(runtimeRoot, release), next);
107
+ fs.renameSync(next, path.join(runtimeRoot, "current"));
108
+ process.stdout.write(`${JSON.stringify({ version: manifest.version, sha256: manifest.sha256, release })}\n`);
109
+ process.exit(0);
110
+ }
111
+ const expected = {
112
+ version: args.version,
113
+ sha256: args.sha256,
114
+ platform: args.platform,
115
+ arch: args.arch,
116
+ launcherAbi: Number(args["launcher-abi"]),
117
+ };
118
+ if (!args.artifact || !validateVersion(expected.version) || !/^[a-f0-9]{64}$/.test(expected.sha256 || "")) fail("Invalid runtime artifact identity.");
119
+ if (expected.launcherAbi !== LAUNCHER_ABI) fail(`Unsupported launcher ABI: ${expected.launcherAbi}`);
120
+
121
+ const stagingRoot = path.join(runtimeRoot, "staging");
122
+ const releasesRoot = path.join(runtimeRoot, "releases");
123
+ const releaseKey = `${expected.version}-${expected.sha256}`;
124
+ fs.mkdirSync(stagingRoot, { recursive: true });
125
+ fs.mkdirSync(releasesRoot, { recursive: true });
126
+ const staging = fs.mkdtempSync(path.join(stagingRoot, `${expected.version}-`));
127
+ let preserveStaging = false;
128
+
129
+ try {
130
+ // A production runtime contains thousands of dependency files, so its tar
131
+ // listing legitimately exceeds spawnSync's small default output buffer.
132
+ // The archive itself is already pinned by the release identity; keep a
133
+ // bounded but production-sized buffer while validating every entry type.
134
+ const listing = spawnSync("tar", ["-tvzf", args.artifact], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
135
+ if (listing.status !== 0) fail(listing.stderr || "Could not inspect runtime artifact.");
136
+ for (const line of listing.stdout.split(/\r?\n/).filter(Boolean)) {
137
+ if (!["-", "d", "l"].includes(line[0])) fail("Runtime artifact contains a hard link or special file.");
138
+ }
139
+ const unpacked = spawnSync("tar", ["-xzf", args.artifact, "--no-same-owner", "--no-same-permissions", "-C", staging], { encoding: "utf8" });
140
+ if (unpacked.status !== 0) fail(unpacked.stderr || "Could not unpack runtime artifact.");
141
+ const manifestPath = path.join(staging, "runtime-manifest.json");
142
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
143
+ const manifestKeys = ["arch", "entrypoint", "formatVersion", "launcherAbi", "packageName", "platform", "sha256", "version"];
144
+ const exactIdentity = Object.keys(manifest).sort().join("\0") === manifestKeys.join("\0")
145
+ && manifest.packageName === PACKAGE_NAME
146
+ && manifest.version === expected.version
147
+ && manifest.platform === expected.platform
148
+ && manifest.arch === expected.arch
149
+ && manifest.formatVersion === 1
150
+ && manifest.launcherAbi === expected.launcherAbi
151
+ && manifest.sha256 === expected.sha256
152
+ && typeof manifest.entrypoint === "string";
153
+ if (!exactIdentity) fail("Runtime artifact manifest does not match the requested identity.");
154
+ const compatible = (manifest.platform === "universal" || manifest.platform === process.platform)
155
+ && (manifest.arch === "universal" || manifest.arch === process.arch);
156
+ if (!compatible) fail(`Runtime artifact is incompatible with ${process.platform}/${process.arch}.`);
157
+ const entrypoint = path.resolve(staging, manifest.entrypoint);
158
+ if (!entrypoint.startsWith(`${staging}${path.sep}`) || !fs.statSync(entrypoint).isFile()) fail("Runtime artifact entrypoint is invalid.");
159
+ if (payloadSha256(staging) !== expected.sha256) fail("Runtime artifact payload SHA-256 is invalid.");
160
+
161
+ const release = path.join(releasesRoot, releaseKey);
162
+ if (fs.existsSync(release)) {
163
+ const existingManifest = JSON.parse(fs.readFileSync(path.join(release, "runtime-manifest.json"), "utf8"));
164
+ const existingIdentityMatches = existingManifest.packageName === PACKAGE_NAME
165
+ && existingManifest.version === expected.version
166
+ && existingManifest.platform === expected.platform
167
+ && existingManifest.arch === expected.arch
168
+ && existingManifest.formatVersion === 1
169
+ && existingManifest.launcherAbi === expected.launcherAbi
170
+ && existingManifest.sha256 === expected.sha256
171
+ && typeof existingManifest.entrypoint === "string";
172
+ if (!existingIdentityMatches || payloadSha256(release) !== expected.sha256) fail(`Existing release ${releaseKey} is invalid.`);
173
+ } else {
174
+ fs.renameSync(staging, release);
175
+ preserveStaging = true;
176
+ }
177
+ lockReleaseTree(release);
178
+
179
+ let previousRelease = "";
180
+ try {
181
+ previousRelease = path.basename(fs.realpathSync(path.join(runtimeRoot, "current")));
182
+ } catch {}
183
+ const next = path.join(runtimeRoot, `.current-${process.pid}-${Date.now()}`);
184
+ fs.symlinkSync(path.relative(runtimeRoot, release), next);
185
+ fs.renameSync(next, path.join(runtimeRoot, "current"));
186
+ if (previousRelease && previousRelease !== releaseKey) fs.writeFileSync(path.join(runtimeRoot, "previous-release"), `${previousRelease}\n`);
187
+ process.stdout.write(`${JSON.stringify({ version: expected.version, sha256: expected.sha256, release })}\n`);
188
+ } finally {
189
+ if (!preserveStaging) fs.rmSync(staging, { recursive: true, force: true });
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@task-handoff/node-agent",
3
- "version": "0.0.8",
3
+ "version": "0.0.9-alpha.2",
4
4
  "description": "Prebuilt TaskHandoff node agent runtime.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "commonjs",
@@ -10,6 +10,8 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "dist",
13
+ "docker",
14
+ "runtime-artifacts",
13
15
  "README.md",
14
16
  "LICENSE",
15
17
  "NOTICE"
@@ -26,6 +28,7 @@
26
28
  "fastify": "5.8.5",
27
29
  "node-pty": "1.1.0",
28
30
  "semver": "7.8.5",
31
+ "tar": "7.5.20",
29
32
  "telegramify-markdown": "1.3.3",
30
33
  "write-file-atomic": "8.0.0",
31
34
  "ws": "8.21.0",
@@ -0,0 +1,10 @@
1
+ {
2
+ "packageName": "@task-handoff/controlled-instance",
3
+ "version": "0.0.9-alpha.2",
4
+ "platform": "linux",
5
+ "arch": "universal",
6
+ "formatVersion": 1,
7
+ "launcherAbi": 1,
8
+ "entrypoint": "dist/controlled-instance-cli.js",
9
+ "sha256": "443ac2b1efaf7cb2c76641740550b9ac5cd351604ba73f36c7faa88a3ef136f8"
10
+ }
@@ -0,0 +1 @@
1
+ f3a14a8289b749efe45a83c8cd45bdb687d80d7a9f3a8e23e00834d8e04520ef controlled-instance-runtime-0.0.9-alpha.2-linux-universal.tar.gz