clawlabor 1.11.3 → 1.14.13

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,40 @@
1
+ function installerArgsFromFlags(flags) {
2
+ const args = [];
3
+ for (const flag of flags) {
4
+ args.push(`--${flag}`);
5
+ }
6
+ return args;
7
+ }
8
+
9
+ async function commandUpgrade(_options, deps, flags) {
10
+ const spawnSync = deps.spawnSync;
11
+ const install = spawnSync("npm", ["install", "-g", "clawlabor@latest"], {
12
+ encoding: "utf8",
13
+ stdio: "inherit",
14
+ });
15
+ if (install.status !== 0) {
16
+ throw new Error("Failed to upgrade ClawLabor with `npm install -g clawlabor@latest`");
17
+ }
18
+
19
+ const reinstallArgs = ["install", ...installerArgsFromFlags(flags)];
20
+ const reinstall = spawnSync("clawlabor", reinstallArgs, {
21
+ encoding: "utf8",
22
+ stdio: "inherit",
23
+ });
24
+ if (reinstall.status !== 0) {
25
+ return JSON.stringify({
26
+ action: "upgraded",
27
+ package: "clawlabor@latest",
28
+ skill_reinstall: "failed",
29
+ next: `Package upgrade succeeded. Refresh skill files manually with: clawlabor ${reinstallArgs.join(" ")}`,
30
+ });
31
+ }
32
+
33
+ return JSON.stringify({
34
+ action: "upgraded",
35
+ package: "clawlabor@latest",
36
+ skill_reinstall: "ok",
37
+ });
38
+ }
39
+
40
+ module.exports = { commandUpgrade };
@@ -10,7 +10,19 @@ const { commandComplete } = require("./command-complete");
10
10
  const { commandConfirm } = require("./command-confirm");
11
11
  const { commandCredentialsPath } = require("./command-credentials-path");
12
12
  const { commandDeleteAttachment } = require("./command-delete-attachment");
13
+ const { commandDownloadAttachment } = require("./command-download-attachment");
13
14
  const { commandDoctor } = require("./command-doctor");
15
+ const {
16
+ commandLaborAgents,
17
+ commandLaborList,
18
+ commandHire,
19
+ commandLaborChat,
20
+ commandLaborPublish,
21
+ commandLaborStart,
22
+ commandLaborUnpublish,
23
+ commandLaborServe,
24
+ commandLaborCleanup,
25
+ } = require("./command-labor");
14
26
  const { commandInspect } = require("./command-inspect");
15
27
  const { commandInstall } = require("./command-install");
16
28
  const { commandListAttachments } = require("./command-list-attachments");
@@ -30,6 +42,7 @@ const { commandStatus } = require("./command-status");
30
42
  const { commandUploadAttachment } = require("./command-upload-attachment");
31
43
  const { commandValidate } = require("./command-validate");
32
44
  const { commandWait } = require("./command-wait");
45
+ const { commandUpgrade } = require("./command-upgrade");
33
46
 
34
47
  module.exports = {
35
48
  ...shared,
@@ -43,7 +56,10 @@ module.exports = {
43
56
  commandConfirm,
44
57
  commandCredentialsPath,
45
58
  commandDeleteAttachment,
59
+ commandDownloadAttachment,
46
60
  commandDoctor,
61
+ commandLaborAgents,
62
+ commandLaborList,
47
63
  commandInspect,
48
64
  commandInstall,
49
65
  commandListAttachments,
@@ -64,6 +80,14 @@ module.exports = {
64
80
  commandSolve,
65
81
  commandStatus,
66
82
  commandUploadAttachment,
83
+ commandUpgrade,
67
84
  commandValidate,
68
85
  commandWait,
86
+ commandHire,
87
+ commandLaborChat,
88
+ commandLaborPublish,
89
+ commandLaborStart,
90
+ commandLaborUnpublish,
91
+ commandLaborServe,
92
+ commandLaborCleanup,
69
93
  };
@@ -0,0 +1,314 @@
1
+ const { spawnSync } = require("node:child_process");
2
+
3
+ function shellQuote(value) {
4
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
5
+ }
6
+
7
+ function dockerName(value) {
8
+ return String(value).replace(/[^a-zA-Z0-9_.-]/g, "-");
9
+ }
10
+
11
+ function runtimeStateMounts(runtime, hireId) {
12
+ const source = `clawlabor-hire-${dockerName(hireId)}-state`;
13
+ const targets = {
14
+ claude: ["/home/sandbox/.claude"],
15
+ codex: ["/home/sandbox/.codex"],
16
+ opencode: ["/home/sandbox/.local/share/opencode"],
17
+ }[runtime] || [];
18
+ return targets.map((target) => ({ source, target, type: "volume" }));
19
+ }
20
+
21
+ function runtimeStateInitCommand(mounts, { excludePaths = [] } = {}) {
22
+ const targets = [
23
+ "/home/sandbox/.local",
24
+ "/home/sandbox/.cache",
25
+ "/home/sandbox/.config",
26
+ ...mounts.map((m) => m.target),
27
+ ];
28
+ if (targets.length === 0) return "true";
29
+ const quoted = targets.map(shellQuote).join(" ");
30
+ const excludes = excludePaths.map((p) => `! -path ${shellQuote(p)}`).join(" ");
31
+ const recursiveChowns = targets
32
+ .map((target) => `find ${shellQuote(target)} -mindepth 1 ${excludes} -exec chown sandbox:sandbox {} +`)
33
+ .join(" && ");
34
+ // Docker creates named volumes as root-owned directories. The runtime agents
35
+ // run as the sandbox user, so normalize ownership before agent startup. Some
36
+ // credentials are mounted read-only under these dirs and must be skipped.
37
+ return `mkdir -p ${quoted} && chown sandbox:sandbox ${quoted} && ${recursiveChowns}`;
38
+ }
39
+
40
+ function sandboxUserCommand(command) {
41
+ const path = [
42
+ "/home/sandbox/.local/share/sandbox-clawlabor/bin",
43
+ "/usr/local/sbin",
44
+ "/usr/local/bin",
45
+ "/usr/sbin",
46
+ "/usr/bin",
47
+ "/sbin",
48
+ "/bin",
49
+ ].join(":");
50
+ return `setpriv --reuid=sandbox --regid=sandbox --init-groups env HOME=/home/sandbox PATH=${shellQuote(path)} ${command}`;
51
+ }
52
+
53
+ function dockerContainerRunning(name, deps = {}) {
54
+ return dockerContainerState(name, deps) === "running";
55
+ }
56
+
57
+ function dockerContainerState(name, deps = {}) {
58
+ const run = deps.spawnSync || spawnSync;
59
+ const result = run("docker", ["inspect", "-f", "{{.State.Status}}", name], {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ });
63
+ return result.status === 0 ? String(result.stdout || "").trim() : null;
64
+ }
65
+
66
+ function hireStateVolumeName(hireId) {
67
+ return `clawlabor-hire-${dockerName(hireId)}-state`;
68
+ }
69
+
70
+ function dockerVolumeExists(name, deps = {}) {
71
+ const run = deps.spawnSync || spawnSync;
72
+ const result = run("docker", ["volume", "inspect", name], {
73
+ stdio: ["ignore", "ignore", "ignore"],
74
+ });
75
+ return result.status === 0;
76
+ }
77
+
78
+ function dockerRemoveVolume(name, deps = {}) {
79
+ const run = deps.spawnSync || spawnSync;
80
+ const result = run("docker", ["volume", "rm", name], {
81
+ stdio: ["ignore", "ignore", "ignore"],
82
+ });
83
+ return result.status === 0;
84
+ }
85
+
86
+ function stopContainerByName(name, deps = {}) {
87
+ const run = deps.spawnSync || spawnSync;
88
+ run("docker", ["stop", name], { stdio: "ignore" });
89
+ }
90
+
91
+ function removeContainerByName(name, deps = {}) {
92
+ const run = deps.spawnSync || spawnSync;
93
+ run("docker", ["rm", "-f", name], { stdio: "ignore" });
94
+ }
95
+
96
+ function startContainerByName(name, deps = {}) {
97
+ const run = deps.spawnSync || spawnSync;
98
+ return run("docker", ["start", name], { stdio: "ignore" }).status === 0;
99
+ }
100
+
101
+ function restartContainerByName(name, deps = {}) {
102
+ const run = deps.spawnSync || spawnSync;
103
+ return run("docker", ["restart", name], { stdio: "ignore" }).status === 0;
104
+ }
105
+
106
+ function terminateChild(child, signal = "SIGTERM") {
107
+ if (!child || typeof child.kill !== "function") return;
108
+ try {
109
+ child.kill(signal);
110
+ } catch (_err) { /* noop */ }
111
+ }
112
+
113
+ function terminateProcessGroup(child, signal = "SIGTERM", deps = {}) {
114
+ if (!child) return;
115
+ if (child.pid) {
116
+ try {
117
+ const killProcessGroup = deps.killProcessGroup || process.kill;
118
+ killProcessGroup(-child.pid, signal);
119
+ return;
120
+ } catch (_err) { /* fall back to child kill */ }
121
+ }
122
+ terminateChild(child, signal);
123
+ }
124
+
125
+ function forceKillProcess(child, timeoutMs = 5000, deps = {}) {
126
+ if (!child || child.exitCode !== null) return Promise.resolve();
127
+ if (typeof child.once !== "function") return Promise.resolve();
128
+ return new Promise((resolve) => {
129
+ let done = false;
130
+ function finish() {
131
+ if (done) return;
132
+ done = true;
133
+ clearTimeout(timer);
134
+ clearTimeout(killTimer);
135
+ resolve();
136
+ }
137
+ const timer = setTimeout(() => {
138
+ terminateProcessGroup(child, "SIGKILL", deps);
139
+ killTimer = setTimeout(finish, Math.min(1000, timeoutMs));
140
+ }, timeoutMs);
141
+ let killTimer = null;
142
+ child.once("exit", finish);
143
+ });
144
+ }
145
+
146
+ function dockerImagePresent(image, deps = {}) {
147
+ const run = deps.spawnSync || spawnSync;
148
+ const result = run("docker", ["image", "inspect", image], {
149
+ stdio: ["ignore", "ignore", "ignore"],
150
+ });
151
+ return result.status === 0;
152
+ }
153
+
154
+ function dockerPullImage(image, deps = {}) {
155
+ const run = deps.spawnSync || spawnSync;
156
+ const result = run("docker", ["pull", image], {
157
+ stdio: ["ignore", "inherit", "inherit"],
158
+ });
159
+ return result.status === 0;
160
+ }
161
+
162
+ function ensureDockerImage(image, deps = {}, stdout = () => {}, { logPrefix = "[6/7]" } = {}) {
163
+ if (dockerImagePresent(image, deps)) return;
164
+ stdout(`${logPrefix} Pulling sandbox image ${image}...`);
165
+ if (!dockerPullImage(image, deps)) {
166
+ throw new Error(`Docker image ${image} is not available locally and docker pull failed`);
167
+ }
168
+ }
169
+
170
+ async function removeContainerByNameAsync({ spawn, containerName, timeoutMs = 1500 }) {
171
+ try {
172
+ await new Promise((resolve) => {
173
+ const dockerRm = spawn("docker", ["rm", "-f", containerName], { stdio: "ignore" });
174
+ if (dockerRm && typeof dockerRm.once === "function") {
175
+ dockerRm.once("exit", () => resolve());
176
+ setTimeout(resolve, timeoutMs);
177
+ } else {
178
+ resolve();
179
+ }
180
+ });
181
+ } catch (_err) {
182
+ /* noop */
183
+ }
184
+ }
185
+
186
+ function clearPortOccupant({ port, containerName, stdout }) {
187
+ try {
188
+ const { execSync } = require("child_process");
189
+ const occupied = execSync(
190
+ `docker ps --filter "publish=${port}" --format '{{.Names}}'`,
191
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
192
+ ).trim();
193
+ if (occupied && occupied !== containerName) {
194
+ execSync(`docker rm -f ${occupied}`, { stdio: "ignore" });
195
+ stdout(`Stopped existing container ${occupied} occupying port ${port}`);
196
+ }
197
+ } catch (_err) {
198
+ /* best effort - if the command fails, let docker run surface the real error */
199
+ }
200
+ }
201
+
202
+ function startSandboxContainer({
203
+ spawn,
204
+ stdout,
205
+ image,
206
+ port,
207
+ runtime,
208
+ hireId,
209
+ containerName,
210
+ sandboxToken,
211
+ sandboxCreds,
212
+ runtimeEnv,
213
+ logPrefix = "[6/7]",
214
+ }) {
215
+ clearPortOccupant({ port, containerName, stdout });
216
+ stdout(`${logPrefix} Starting sandbox container (${image})...`);
217
+ const credEnvFlags = Object.keys(sandboxCreds.env).flatMap((envName) => ["-e", envName]);
218
+ const stateMounts = runtimeStateMounts(runtime, hireId);
219
+ const readOnlyCredPaths = sandboxCreds.mounts.filter((m) => m.ro).map((m) => m.container);
220
+ const stateMountFlags = stateMounts.flatMap((m) => [
221
+ "--mount", `type=${m.type},source=${m.source},target=${m.target}`,
222
+ ]);
223
+ const credMountFlags = sandboxCreds.mounts.flatMap((m) => ["-v", `${m.host}:${m.container}${m.ro ? ":ro" : ""}`]);
224
+ return spawn(
225
+ "docker",
226
+ [
227
+ "run", "-d", "--name", containerName, "-p", `127.0.0.1:${port}:2468`,
228
+ // Start as root only long enough to repair fresh volume ownership;
229
+ // agent install and the long-running server run as sandbox below.
230
+ "-u", "root",
231
+ "-e", "CLAWLABOR_AGENT_RUNTIME",
232
+ ...credEnvFlags,
233
+ ...stateMountFlags,
234
+ ...credMountFlags,
235
+ "--entrypoint", "sh",
236
+ image,
237
+ "-lc",
238
+ [
239
+ runtimeStateInitCommand(stateMounts, { excludePaths: readOnlyCredPaths }),
240
+ sandboxUserCommand(`sandbox-clawlabor install-agent ${shellQuote(runtime)}`),
241
+ runtimeStateInitCommand(stateMounts, { excludePaths: readOnlyCredPaths }),
242
+ `exec ${sandboxUserCommand(`sandbox-clawlabor server --token=${shellQuote(sandboxToken)} --host 0.0.0.0 --port 2468`)}`,
243
+ ].join(" && "),
244
+ ],
245
+ { stdio: "ignore", env: runtimeEnv },
246
+ );
247
+ }
248
+
249
+ function dockerListHireStateVolumes(deps = {}) {
250
+ const run = deps.spawnSync || spawnSync;
251
+ const result = run(
252
+ "docker",
253
+ ["volume", "ls", "--filter", "name=clawlabor-hire-", "--format", "{{.Name}}"],
254
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
255
+ );
256
+ if (result.status !== 0) return [];
257
+ return String(result.stdout || "")
258
+ .split("\n")
259
+ .map((line) => line.trim())
260
+ .filter((line) => line.startsWith("clawlabor-hire-") && line.endsWith("-state"));
261
+ }
262
+
263
+ function dockerListHireContainers(deps = {}) {
264
+ const run = deps.spawnSync || spawnSync;
265
+ const result = run(
266
+ "docker",
267
+ ["ps", "-a", "--filter", "name=clawlabor-hire-", "--format", "{{.Names}}"],
268
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
269
+ );
270
+ if (result.status !== 0) return [];
271
+ return String(result.stdout || "")
272
+ .split("\n")
273
+ .map((line) => line.trim())
274
+ .filter((line) => line.startsWith("clawlabor-hire-"));
275
+ }
276
+
277
+ function hireIdFromVolumeName(volumeName) {
278
+ const m = /^clawlabor-hire-(.+)-state$/.exec(volumeName);
279
+ return m ? m[1] : null;
280
+ }
281
+
282
+ function hireIdFromContainerName(containerName) {
283
+ const m = /^clawlabor-hire-(.+)$/.exec(containerName);
284
+ return m ? m[1] : null;
285
+ }
286
+
287
+ module.exports = {
288
+ dockerName,
289
+ shellQuote,
290
+ runtimeStateMounts,
291
+ runtimeStateInitCommand,
292
+ sandboxUserCommand,
293
+ dockerContainerRunning,
294
+ dockerContainerState,
295
+ hireStateVolumeName,
296
+ dockerVolumeExists,
297
+ dockerRemoveVolume,
298
+ stopContainerByName,
299
+ removeContainerByName,
300
+ startContainerByName,
301
+ restartContainerByName,
302
+ terminateChild,
303
+ terminateProcessGroup,
304
+ forceKillProcess,
305
+ dockerImagePresent,
306
+ dockerPullImage,
307
+ ensureDockerImage,
308
+ removeContainerByNameAsync,
309
+ startSandboxContainer,
310
+ dockerListHireContainers,
311
+ dockerListHireStateVolumes,
312
+ hireIdFromVolumeName,
313
+ hireIdFromContainerName,
314
+ };
@@ -0,0 +1,250 @@
1
+ const dns = require("node:dns").promises;
2
+ const https = require("node:https");
3
+
4
+ const CLOUDFLARE_RESOLVERS = ["1.1.1.1", "1.0.0.1"];
5
+ const TUNNEL_LOG_LINE_LIMIT = 12;
6
+ const TUNNEL_AVAILABILITY_TIMEOUT_MS = 180_000;
7
+ const DEFAULT_CLOUDFLARED_PROTOCOL = "http2";
8
+
9
+ function tunnelAvailabilityTimeoutSeconds(timeoutMs = TUNNEL_AVAILABILITY_TIMEOUT_MS) {
10
+ return Math.ceil(timeoutMs / 1000);
11
+ }
12
+
13
+ async function resolveViaCloudflare(hostname) {
14
+ const previous = dns.getServers();
15
+ try {
16
+ dns.setServers(CLOUDFLARE_RESOLVERS);
17
+ const [v4, v6] = await Promise.allSettled([
18
+ dns.resolve4(hostname),
19
+ dns.resolve6(hostname),
20
+ ]);
21
+ return [
22
+ ...(v4.status === "fulfilled" ? v4.value : []),
23
+ ...(v6.status === "fulfilled" ? v6.value : []),
24
+ ];
25
+ } finally {
26
+ try { dns.setServers(previous); } catch (_err) { /* noop */ }
27
+ }
28
+ }
29
+
30
+ function httpsGetViaResolvedIp(url, token, ip, timeoutMs) {
31
+ return new Promise((resolve) => {
32
+ const parsed = new URL(url);
33
+ const family = ip.includes(":") ? 6 : 4;
34
+ const req = https.request(
35
+ {
36
+ protocol: parsed.protocol,
37
+ hostname: ip,
38
+ family,
39
+ path: `${parsed.pathname}${parsed.search}`,
40
+ method: "GET",
41
+ headers: {
42
+ Authorization: `Bearer ${token}`,
43
+ Host: parsed.hostname,
44
+ },
45
+ servername: parsed.hostname,
46
+ timeout: timeoutMs,
47
+ },
48
+ (resp) => {
49
+ resp.resume();
50
+ resp.once("end", () => resolve(resp.statusCode >= 200 && resp.statusCode < 300));
51
+ },
52
+ );
53
+ req.once("timeout", () => req.destroy(new Error("health probe timeout")));
54
+ req.once("error", () => resolve(false));
55
+ req.end();
56
+ });
57
+ }
58
+
59
+ async function probePublicHealthWithDnsFallback(url, token, timeoutMs) {
60
+ try {
61
+ const ips = await resolveViaCloudflare(new URL(url).hostname);
62
+ for (const ip of ips) {
63
+ if (await httpsGetViaResolvedIp(url, token, ip, timeoutMs)) return true;
64
+ }
65
+ } catch (_err) {
66
+ return false;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ function appendLogLines(buffer, chunk, limit = TUNNEL_LOG_LINE_LIMIT) {
72
+ const lines = String(chunk || "")
73
+ .split(/\r?\n/)
74
+ .map((line) => line.trim())
75
+ .filter(Boolean);
76
+ buffer.push(...lines);
77
+ if (buffer.length > limit) {
78
+ buffer.splice(0, buffer.length - limit);
79
+ }
80
+ }
81
+
82
+ function cloudflaredArgs(tunnelToken, protocol = DEFAULT_CLOUDFLARED_PROTOCOL) {
83
+ const args = ["tunnel", "--no-autoupdate", "--grace-period=3s"];
84
+ if (protocol) args.push("--protocol", protocol);
85
+ args.push("run", "--token", tunnelToken);
86
+ return args;
87
+ }
88
+
89
+ function formatRecentLogs(buffer) {
90
+ if (!buffer.length) return "";
91
+ return `\nRecent cloudflared logs:\n${buffer.map((line) => ` ${line}`).join("\n")}\n`;
92
+ }
93
+
94
+ function createTunnelAvailabilityState({
95
+ now,
96
+ publicHealthUrl,
97
+ localHealthUrl,
98
+ tunnelState,
99
+ tunnelLogs,
100
+ timeoutMs = TUNNEL_AVAILABILITY_TIMEOUT_MS,
101
+ }) {
102
+ let unavailableSince = null;
103
+ return {
104
+ markUnavailable() {
105
+ if (unavailableSince === null) unavailableSince = now();
106
+ },
107
+ reset() {
108
+ unavailableSince = null;
109
+ },
110
+ elapsedMs() {
111
+ return unavailableSince === null ? 0 : now() - unavailableSince;
112
+ },
113
+ withinGracePeriod() {
114
+ return unavailableSince !== null && this.elapsedMs() < timeoutMs;
115
+ },
116
+ remainingSeconds() {
117
+ return Math.max(0, Math.ceil((timeoutMs - this.elapsedMs()) / 1000));
118
+ },
119
+ failurePayload() {
120
+ return {
121
+ reason: "tunnel_unreachable",
122
+ detail: `Public tunnel health check failed for ${publicHealthUrl}`,
123
+ public_health_url: publicHealthUrl,
124
+ local_health_url: localHealthUrl,
125
+ tunnel_unavailable_since_ms: this.elapsedMs(),
126
+ tunnel_exit_summary: tunnelState.exitSummary,
127
+ recent_cloudflared_logs: tunnelLogs.slice(-TUNNEL_LOG_LINE_LIMIT),
128
+ };
129
+ },
130
+ };
131
+ }
132
+
133
+ function formatTunnelUnavailableWarning({ publicHealthUrl, laborId, tunnelState, tunnelLogs }) {
134
+ const tunnelStatus = tunnelState.exited && tunnelState.exitSummary
135
+ ? `\n ${tunnelState.exitSummary}.`
136
+ : "";
137
+ return (
138
+ `\n⚠️ Sandbox is healthy locally but unreachable over the public tunnel ` +
139
+ `(${publicHealthUrl}). Buyers can't reach it, so the platform will mark this ` +
140
+ `hire sandbox OFFLINE.${tunnelStatus}\n The Cloudflare tunnel likely dropped (free-plan tunnels often ` +
141
+ `exit with error 1033). To recover: stop this process (Ctrl+C) and re-run\n` +
142
+ ` clawlabor labor-serve --labor ${laborId}\n` +
143
+ formatRecentLogs(tunnelLogs)
144
+ );
145
+ }
146
+
147
+ function startCloudflareTunnel({
148
+ spawn,
149
+ stdout,
150
+ tunnelToken,
151
+ cleanedUpRef,
152
+ isStopRequested,
153
+ stopTunnel,
154
+ logPrefix = "[7/7]",
155
+ }) {
156
+ stdout(`${logPrefix} Starting Cloudflare tunnel...`);
157
+ const logs = [];
158
+ const state = { exited: false, exitSummary: null, protocol: DEFAULT_CLOUDFLARED_PROTOCOL };
159
+ let currentTunnel = null;
160
+
161
+ function spawnTunnel(protocol = DEFAULT_CLOUDFLARED_PROTOCOL) {
162
+ const tunnel = spawn(
163
+ "cloudflared",
164
+ cloudflaredArgs(tunnelToken, protocol),
165
+ { stdio: ["ignore", "pipe", "pipe"], detached: true },
166
+ );
167
+ currentTunnel = tunnel;
168
+ state.exited = false;
169
+ state.exitSummary = null;
170
+ state.protocol = protocol || "auto";
171
+ return tunnel;
172
+ }
173
+
174
+ function attachHandlers(tunnel) {
175
+ tunnel.stdout?.on("data", (chunk) => appendLogLines(logs, chunk));
176
+ tunnel.stderr?.on("data", (chunk) => appendLogLines(logs, chunk));
177
+ if (!tunnel.stdout || !tunnel.stderr || typeof tunnel.once !== "function") return;
178
+ tunnel.once("error", (err) => {
179
+ state.exited = true;
180
+ state.exitSummary = `cloudflared failed to start: ${(err && err.message) || err || "unknown error"}`;
181
+ if (!cleanedUpRef.value && !isStopRequested()) {
182
+ stdout(`\n⚠️ ${state.exitSummary}${formatRecentLogs(logs)}`);
183
+ }
184
+ });
185
+ tunnel.once("exit", (code, signal) => {
186
+ const isCurrent = currentTunnel === tunnel;
187
+ if (!isCurrent) {
188
+ return;
189
+ }
190
+ state.exited = true;
191
+ state.exitSummary = `cloudflared exited${code === null || code === undefined ? "" : ` with code ${code}`}${signal ? ` (${signal})` : ""}`;
192
+ if (!cleanedUpRef.value && !isStopRequested()) {
193
+ stdout(`\n⚠️ ${state.exitSummary}${formatRecentLogs(logs)}`);
194
+ }
195
+ });
196
+ }
197
+
198
+ attachHandlers(spawnTunnel());
199
+
200
+ async function restart(reason = "public tunnel unreachable") {
201
+ const previousTunnel = currentTunnel;
202
+ if (stopTunnel) {
203
+ await stopTunnel(previousTunnel);
204
+ } else if (previousTunnel && typeof previousTunnel.kill === "function") {
205
+ try { previousTunnel.kill("SIGTERM"); } catch (_err) { /* noop */ }
206
+ }
207
+ if (cleanedUpRef.value || isStopRequested()) return currentTunnel;
208
+ stdout(`Restarting Cloudflare tunnel (${reason})...`);
209
+ const nextTunnel = spawnTunnel(state.protocol === "auto" ? DEFAULT_CLOUDFLARED_PROTOCOL : state.protocol);
210
+ attachHandlers(nextTunnel);
211
+ return nextTunnel;
212
+ }
213
+
214
+ return { tunnel: currentTunnel, currentTunnel: () => currentTunnel, logs, state, restart };
215
+ }
216
+
217
+ function createSandboxHealthProbe({ deps, sandboxToken, timeoutMs }) {
218
+ return async function probeHealth(url, { publicTunnel = false } = {}) {
219
+ try {
220
+ const resp = await deps.withTimeout(
221
+ deps.fetch(url, { headers: { Authorization: `Bearer ${sandboxToken}` } }),
222
+ timeoutMs,
223
+ "health probe",
224
+ );
225
+ return !!resp.ok;
226
+ } catch (_err) {
227
+ if (!publicTunnel) return false;
228
+ const fallbackProbe = deps.probePublicHealthWithDnsFallback ||
229
+ ((probeUrl, token) => probePublicHealthWithDnsFallback(probeUrl, token, timeoutMs));
230
+ try {
231
+ return await fallbackProbe(url, sandboxToken);
232
+ } catch (_fallbackErr) {
233
+ return false;
234
+ }
235
+ }
236
+ };
237
+ }
238
+
239
+ module.exports = {
240
+ TUNNEL_AVAILABILITY_TIMEOUT_MS,
241
+ TUNNEL_LOG_LINE_LIMIT,
242
+ tunnelAvailabilityTimeoutSeconds,
243
+ createTunnelAvailabilityState,
244
+ createSandboxHealthProbe,
245
+ formatRecentLogs,
246
+ formatTunnelUnavailableWarning,
247
+ cloudflaredArgs,
248
+ probePublicHealthWithDnsFallback,
249
+ startCloudflareTunnel,
250
+ };
package/runtime/http.js CHANGED
@@ -5,8 +5,9 @@ const path = require("path");
5
5
 
6
6
  const DEFAULT_API_BASE = "https://www.clawlabor.com/api";
7
7
 
8
- function apiBase(_env) {
9
- return DEFAULT_API_BASE;
8
+ function apiBase(env) {
9
+ // CLAWLABOR_API_BASE overrides for local/dev (e.g. http://localhost:8000/api).
10
+ return (env && env.CLAWLABOR_API_BASE) || DEFAULT_API_BASE;
10
11
  }
11
12
 
12
13
  function readCredentialsFile(env) {
@@ -40,6 +41,10 @@ function resolveApiKey(env) {
40
41
  return env.CLAWLABOR_API_KEY || readCredentialsFile(env);
41
42
  }
42
43
 
44
+ function envWithApiKey(env, apiKey) {
45
+ return { ...env, CLAWLABOR_API_KEY: apiKey };
46
+ }
47
+
43
48
  function credentialState(env) {
44
49
  const credentialsPath = credentialsFilePath(env);
45
50
  const fileExists = fs.existsSync(credentialsPath);
@@ -211,5 +216,6 @@ module.exports = {
211
216
  requestJsonNoAuth,
212
217
  requestMultipart,
213
218
  resolveApiKey,
219
+ envWithApiKey,
214
220
  writeCredentialsFile,
215
221
  };