@parall/daemon 1.29.0 → 1.29.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,439 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { clearAllProviderCreds } from "./runtimes.js";
6
+ const OUTPUT_TAIL_LIMIT = 32 * 1024;
7
+ const DEFAULT_SETUP_TIMEOUT_SEC = 600;
8
+ export async function prepareWorkspace(opts) {
9
+ const prior = opts.attached.workspace_state;
10
+ const plan = buildWorkspacePlan(opts.attached.daemon_config, opts.defaultWorkspaceDir, prior?.config_hash);
11
+ if (!opts.attached.daemon_config && !prior) {
12
+ return plan.workspaceDir;
13
+ }
14
+ let forceSetup = false;
15
+ if (prior?.status === "ready" && prior.config_hash === plan.configHash) {
16
+ if (await verifyExistingWorkspace(plan, opts.log)) {
17
+ return plan.workspaceDir;
18
+ }
19
+ forceSetup = true;
20
+ }
21
+ await opts.client.reportAgentWorkspaceState(opts.agentId, {
22
+ config_hash: plan.configHash,
23
+ status: "preparing",
24
+ });
25
+ let outputTail = "";
26
+ try {
27
+ await ensureWorkspace(plan, opts.log);
28
+ if (shouldRunSetup(plan.workspace.setup, prior, plan.configHash, forceSetup)) {
29
+ outputTail = await runSetup(plan.workspaceDir, plan.workspace.setup.command, plan.workspace.setup?.timeout_sec);
30
+ }
31
+ await opts.client.reportAgentWorkspaceState(opts.agentId, {
32
+ config_hash: plan.configHash,
33
+ status: "ready",
34
+ output_tail: outputTail || null,
35
+ });
36
+ return plan.workspaceDir;
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ await opts.client.reportAgentWorkspaceState(opts.agentId, {
41
+ config_hash: plan.configHash,
42
+ status: "failed",
43
+ last_error: message,
44
+ output_tail: outputTail || null,
45
+ }).catch((reportErr) => {
46
+ opts.log.warn(`workspace state report failed after setup error: ${String(reportErr)}`);
47
+ });
48
+ throw err;
49
+ }
50
+ }
51
+ function buildWorkspacePlan(input, defaultWorkspaceDir, serverConfigHash) {
52
+ const config = normalizeConfig(input, defaultWorkspaceDir);
53
+ const workspace = config.workspace;
54
+ const { workspaceDir, customWorkspaceField } = resolveWorkspaceDir(workspace, defaultWorkspaceDir);
55
+ // The server is the source of truth for config_hash. Go and TypeScript JSON
56
+ // normalization can differ in harmless ways (field order, omitted defaults),
57
+ // so use the server-computed pending/ready row hash whenever it is present.
58
+ const configHash = serverConfigHash || createHash("sha256")
59
+ .update(JSON.stringify(config))
60
+ .digest("hex");
61
+ return { config, workspace, workspaceDir, defaultWorkspaceDir, customWorkspaceField, configHash };
62
+ }
63
+ function normalizeConfig(input, defaultWorkspaceDir) {
64
+ const cfg = input ? JSON.parse(JSON.stringify(input)) : {};
65
+ cfg.workspace_path = cfg.workspace_path?.trim() || undefined;
66
+ if (!cfg.workspace && cfg.workspace_path) {
67
+ cfg.workspace = { mode: "local_path", path: cfg.workspace_path };
68
+ }
69
+ if (!cfg.workspace) {
70
+ cfg.workspace = { mode: "default" };
71
+ }
72
+ const ws = cfg.workspace;
73
+ ws.mode = (ws.mode?.trim() || undefined);
74
+ ws.path = ws.path?.trim() || undefined;
75
+ if (ws.git) {
76
+ ws.git.remote = ws.git.remote?.trim() || undefined;
77
+ ws.git.ref = ws.git.ref?.trim() || undefined;
78
+ ws.git.target_path = ws.git.target_path?.trim() || undefined;
79
+ }
80
+ ws.mode ||= ws.git ? "git" : ws.path ? "local_path" : "default";
81
+ if (ws.mode === "local_path" && !ws.path) {
82
+ ws.mode = "default";
83
+ }
84
+ if (ws.mode === "git") {
85
+ ws.git ||= {};
86
+ if (!ws.git.target_path && ws.path) {
87
+ ws.git.target_path = ws.path;
88
+ }
89
+ }
90
+ if (ws.mode === "default") {
91
+ ws.path = undefined;
92
+ ws.git = undefined;
93
+ }
94
+ if (ws.mode === "local_path") {
95
+ ws.git = undefined;
96
+ }
97
+ if (ws.setup) {
98
+ ws.setup.command = ws.setup.command?.trim();
99
+ ws.setup.run_on ||= "first_attach";
100
+ if (!ws.setup.command) {
101
+ ws.setup = undefined;
102
+ }
103
+ }
104
+ // Keep hash stable when the default dir changes by not materializing it
105
+ // into config. It is still used as the execution directory below.
106
+ void defaultWorkspaceDir;
107
+ return cfg;
108
+ }
109
+ function resolveWorkspaceDir(workspace, defaultWorkspaceDir) {
110
+ if (workspace.mode === "local_path") {
111
+ if (!workspace.path)
112
+ return { workspaceDir: defaultWorkspaceDir };
113
+ return {
114
+ workspaceDir: requireAbsolute(workspace.path, "workspace.path"),
115
+ customWorkspaceField: "workspace.path",
116
+ };
117
+ }
118
+ if (workspace.mode === "git") {
119
+ return workspace.git?.target_path
120
+ ? {
121
+ workspaceDir: requireAbsolute(workspace.git.target_path, "workspace.git.target_path"),
122
+ customWorkspaceField: "workspace.git.target_path",
123
+ }
124
+ : { workspaceDir: defaultWorkspaceDir };
125
+ }
126
+ return { workspaceDir: defaultWorkspaceDir };
127
+ }
128
+ async function ensureWorkspace(plan, log) {
129
+ const ws = plan.workspace;
130
+ if (ws.mode === "default") {
131
+ fs.mkdirSync(plan.workspaceDir, { recursive: true });
132
+ assertWritableWorkspaceDir(plan.workspaceDir);
133
+ return;
134
+ }
135
+ if (ws.mode === "local_path") {
136
+ assertSafeCustomWorkspacePath(plan);
137
+ let st;
138
+ try {
139
+ st = fs.statSync(plan.workspaceDir);
140
+ }
141
+ catch (err) {
142
+ if (isNodeError(err) && err.code === "ENOENT") {
143
+ throw new Error(`workspace path does not exist: ${plan.workspaceDir}`);
144
+ }
145
+ throw err;
146
+ }
147
+ if (!st.isDirectory()) {
148
+ throw new Error(`workspace path is not a directory: ${plan.workspaceDir}`);
149
+ }
150
+ assertSafeCustomWorkspacePath(plan, fs.realpathSync(plan.workspaceDir));
151
+ assertWritableWorkspaceDir(plan.workspaceDir);
152
+ return;
153
+ }
154
+ if (ws.mode === "git") {
155
+ const remote = ws.git?.remote?.trim();
156
+ if (!remote)
157
+ throw new Error("workspace git remote is required");
158
+ if (plan.customWorkspaceField) {
159
+ assertSafeCustomWorkspacePath(plan);
160
+ }
161
+ if (!fs.existsSync(plan.workspaceDir)) {
162
+ fs.mkdirSync(path.dirname(plan.workspaceDir), { recursive: true });
163
+ assertWritableWorkspaceDir(path.dirname(plan.workspaceDir));
164
+ await runCommand("git", ["clone", remote, plan.workspaceDir], process.cwd());
165
+ }
166
+ else {
167
+ const st = fs.statSync(plan.workspaceDir);
168
+ if (!st.isDirectory()) {
169
+ throw new Error(`workspace path is not a directory: ${plan.workspaceDir}`);
170
+ }
171
+ if (plan.customWorkspaceField) {
172
+ assertSafeCustomWorkspacePath(plan, fs.realpathSync(plan.workspaceDir));
173
+ }
174
+ assertWritableWorkspaceDir(plan.workspaceDir);
175
+ await ensureGitWorktree(plan.workspaceDir);
176
+ await ensureGitRemote(plan.workspaceDir, remote);
177
+ }
178
+ if (ws.git?.ref) {
179
+ const dirty = await commandOutput("git", ["status", "--porcelain"], plan.workspaceDir);
180
+ if (dirty.trim()) {
181
+ throw new Error(`workspace git tree has local changes; refusing checkout: ${plan.workspaceDir}`);
182
+ }
183
+ await runCommand("git", ["fetch", "origin", "--prune"], plan.workspaceDir);
184
+ await checkoutGitRef(plan.workspaceDir, ws.git.ref);
185
+ }
186
+ log.info(`workspace ready: git ${remote} -> ${plan.workspaceDir}`);
187
+ return;
188
+ }
189
+ throw new Error(`unsupported workspace mode: ${ws.mode ?? "(empty)"}`);
190
+ }
191
+ async function verifyExistingWorkspace(plan, log) {
192
+ try {
193
+ if (plan.customWorkspaceField) {
194
+ assertSafeCustomWorkspacePath(plan);
195
+ }
196
+ const st = fs.statSync(plan.workspaceDir);
197
+ if (!st.isDirectory()) {
198
+ log.warn(`workspace ready state ignored: path is not a directory: ${plan.workspaceDir}`);
199
+ return false;
200
+ }
201
+ if (plan.customWorkspaceField) {
202
+ assertSafeCustomWorkspacePath(plan, fs.realpathSync(plan.workspaceDir));
203
+ }
204
+ assertWritableWorkspaceDir(plan.workspaceDir);
205
+ if (plan.workspace.mode === "git") {
206
+ await ensureGitWorktree(plan.workspaceDir);
207
+ const remote = plan.workspace.git?.remote?.trim();
208
+ if (remote) {
209
+ await ensureGitRemote(plan.workspaceDir, remote);
210
+ }
211
+ if (plan.workspace.git?.ref) {
212
+ const head = (await commandOutput("git", ["rev-parse", "HEAD"], plan.workspaceDir)).trim();
213
+ const expected = await resolveConfiguredGitRef(plan.workspaceDir, plan.workspace.git.ref);
214
+ if (head !== expected) {
215
+ log.warn(`workspace ready state ignored: git HEAD ${head} does not match ${plan.workspace.git.ref} (${expected})`);
216
+ return false;
217
+ }
218
+ }
219
+ }
220
+ return true;
221
+ }
222
+ catch (err) {
223
+ log.warn(`workspace ready state ignored: ${String(err)}`);
224
+ return false;
225
+ }
226
+ }
227
+ async function ensureGitWorktree(cwd) {
228
+ const result = await commandOutput("git", ["rev-parse", "--is-inside-work-tree"], cwd);
229
+ if (result.trim() !== "true") {
230
+ throw new Error(`workspace path exists but is not a git worktree: ${cwd}`);
231
+ }
232
+ }
233
+ async function ensureGitRemote(cwd, expectedRemote) {
234
+ const actualRemote = (await commandOutput("git", ["remote", "get-url", "origin"], cwd)).trim();
235
+ if (actualRemote !== expectedRemote) {
236
+ throw new Error(`workspace git remote mismatch: expected ${expectedRemote}, got ${actualRemote}`);
237
+ }
238
+ }
239
+ async function checkoutGitRef(cwd, ref) {
240
+ const remoteCommit = await resolveRemoteGitRef(cwd, ref);
241
+ if (remoteCommit) {
242
+ await runCommand("git", ["checkout", "-B", ref, remoteCommit], cwd);
243
+ return;
244
+ }
245
+ const targetCommit = await resolveGitCommit(cwd, ref);
246
+ await runCommand("git", ["checkout", "--detach", targetCommit], cwd);
247
+ }
248
+ async function resolveConfiguredGitRef(cwd, ref) {
249
+ const remote = await resolveRemoteGitRef(cwd, ref);
250
+ return remote || await resolveGitCommit(cwd, ref);
251
+ }
252
+ async function resolveRemoteGitRef(cwd, ref) {
253
+ return (await tryGitOutput("git", ["rev-parse", "--verify", `refs/remotes/origin/${ref}^{commit}`], cwd)).trim();
254
+ }
255
+ async function resolveGitCommit(cwd, ref) {
256
+ const candidates = [`refs/tags/${ref}^{commit}`, `${ref}^{commit}`];
257
+ for (const candidate of candidates) {
258
+ const commit = await tryGitOutput("git", ["rev-parse", "--verify", candidate], cwd);
259
+ if (commit.trim()) {
260
+ return commit.trim();
261
+ }
262
+ }
263
+ throw new Error(`workspace git ref not found: ${ref}`);
264
+ }
265
+ function shouldRunSetup(setup, prior, configHash, forceSetup = false) {
266
+ if (!setup?.command)
267
+ return false;
268
+ if (forceSetup)
269
+ return true;
270
+ if (setup.run_on === "config_change") {
271
+ return prior?.config_hash !== configHash || prior.status === "pending" || prior.status === "failed";
272
+ }
273
+ return prior?.status !== "ready" || !prior.prepared_at;
274
+ }
275
+ async function runSetup(cwd, command, timeoutSec) {
276
+ const effectiveTimeoutSec = timeoutSec && timeoutSec > 0 ? timeoutSec : DEFAULT_SETUP_TIMEOUT_SEC;
277
+ return runCommand(process.env.SHELL || "/bin/sh", ["-lc", command], cwd, effectiveTimeoutSec * 1000, scrubSetupEnv());
278
+ }
279
+ function scrubSetupEnv() {
280
+ const env = { ...process.env };
281
+ clearAllProviderCreds(env);
282
+ delete env.PRLL_API_KEY;
283
+ return env;
284
+ }
285
+ async function commandOutput(cmd, args, cwd) {
286
+ return runCommand(cmd, args, cwd);
287
+ }
288
+ async function tryGitOutput(cmd, args, cwd) {
289
+ try {
290
+ return await runCommand(cmd, args, cwd);
291
+ }
292
+ catch {
293
+ return "";
294
+ }
295
+ }
296
+ function runCommand(cmd, args, cwd, timeoutMs = 120_000, env = process.env) {
297
+ return new Promise((resolve, reject) => {
298
+ let tail = "";
299
+ let timedOut = false;
300
+ let settled = false;
301
+ let timeoutTimer = null;
302
+ let killTimer = null;
303
+ const append = (chunk) => {
304
+ tail += chunk.toString();
305
+ if (tail.length > OUTPUT_TAIL_LIMIT) {
306
+ tail = tail.slice(-OUTPUT_TAIL_LIMIT);
307
+ }
308
+ };
309
+ const settle = (fn) => {
310
+ if (settled)
311
+ return;
312
+ settled = true;
313
+ if (timeoutTimer)
314
+ clearTimeout(timeoutTimer);
315
+ if (killTimer)
316
+ clearTimeout(killTimer);
317
+ fn();
318
+ };
319
+ const child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
320
+ timeoutTimer = setTimeout(() => {
321
+ timedOut = true;
322
+ child.kill("SIGTERM");
323
+ killTimer = setTimeout(() => {
324
+ child.kill("SIGKILL");
325
+ }, 5_000);
326
+ }, timeoutMs);
327
+ child.stdout?.on("data", append);
328
+ child.stderr?.on("data", append);
329
+ child.once("error", (err) => {
330
+ settle(() => reject(err));
331
+ });
332
+ child.once("close", (code, signal) => {
333
+ if (timedOut) {
334
+ settle(() => reject(new Error(`command timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}\n${tail}`)));
335
+ return;
336
+ }
337
+ if (code === 0) {
338
+ settle(() => resolve(tail));
339
+ }
340
+ else {
341
+ settle(() => reject(new Error(`command failed (${code ?? signal}): ${cmd} ${args.join(" ")}\n${tail}`)));
342
+ }
343
+ });
344
+ });
345
+ }
346
+ function requireAbsolute(value, field) {
347
+ if (!value || !path.isAbsolute(value)) {
348
+ throw new Error(`${field} must be an absolute path`);
349
+ }
350
+ return path.resolve(value);
351
+ }
352
+ function assertSafeCustomWorkspacePath(plan, candidate = plan.workspaceDir) {
353
+ if (!plan.customWorkspaceField)
354
+ return;
355
+ const normalized = requireAbsolute(candidate, plan.customWorkspaceField);
356
+ const reason = workspacePathDenyReason(toPolicyPath(normalized));
357
+ if (reason) {
358
+ throw new Error(`${plan.customWorkspaceField} must point to a project folder, not ${reason}: ${normalized}`);
359
+ }
360
+ const defaultWorkspace = path.resolve(plan.defaultWorkspaceDir);
361
+ if (isAncestorPath(normalized, defaultWorkspace) || normalized === defaultWorkspace) {
362
+ throw new Error(`${plan.customWorkspaceField} must not point at daemon state directories: ${normalized}`);
363
+ }
364
+ }
365
+ function assertWritableWorkspaceDir(dir) {
366
+ fs.accessSync(dir, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
367
+ const probe = path.join(dir, `.parall-workspace-check-${process.pid}-${Date.now()}`);
368
+ const fd = fs.openSync(probe, "wx", 0o600);
369
+ fs.closeSync(fd);
370
+ fs.unlinkSync(probe);
371
+ }
372
+ function workspacePathDenyReason(value) {
373
+ if (value === "/")
374
+ return "the filesystem root";
375
+ if (["/tmp", "/private/tmp", "/var/tmp", "/Users", "/home"].includes(value)) {
376
+ return "a shared or home root directory";
377
+ }
378
+ for (const root of [
379
+ "/Applications",
380
+ "/bin",
381
+ "/boot",
382
+ "/dev",
383
+ "/etc",
384
+ "/Library",
385
+ "/private/etc",
386
+ "/private/var/db",
387
+ "/proc",
388
+ "/root",
389
+ "/run",
390
+ "/sbin",
391
+ "/System",
392
+ "/sys",
393
+ "/usr",
394
+ "/var/db",
395
+ "/var/root",
396
+ ]) {
397
+ if (value === root || value.startsWith(`${root}/`)) {
398
+ return "a system directory";
399
+ }
400
+ }
401
+ const parts = value.split("/").filter(Boolean);
402
+ if (parts.includes(".git"))
403
+ return "a git metadata directory";
404
+ if (parts.length === 2 && (parts[0] === "Users" || parts[0] === "home")) {
405
+ return "a home directory";
406
+ }
407
+ if (parts.length >= 3 && (parts[0] === "Users" || parts[0] === "home")) {
408
+ const homeChild = parts[2];
409
+ if ([
410
+ ".aws",
411
+ ".azure",
412
+ ".claude",
413
+ ".codex",
414
+ ".config",
415
+ ".docker",
416
+ ".gnupg",
417
+ ".kube",
418
+ ".local",
419
+ ".npm",
420
+ ".ssh",
421
+ ".parall-agent",
422
+ ".parall-daemon",
423
+ "Library",
424
+ ].includes(homeChild)) {
425
+ return "a credential or application state directory";
426
+ }
427
+ }
428
+ return "";
429
+ }
430
+ function isAncestorPath(parent, child) {
431
+ const relative = path.relative(parent, child);
432
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
433
+ }
434
+ function toPolicyPath(value) {
435
+ return path.resolve(value).split(path.sep).join("/");
436
+ }
437
+ function isNodeError(err) {
438
+ return err instanceof Error && "code" in err;
439
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/daemon",
3
- "version": "1.29.0",
3
+ "version": "1.29.2",
4
4
  "description": "Parall local agent runtime — daemon supervisor + bridge runtimes, bundled as standalone JS files",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,11 +29,11 @@
29
29
  "dist"
30
30
  ],
31
31
  "dependencies": {
32
- "@parall/agent-core": "1.29.0",
33
- "@parall/sdk": "1.29.0",
34
- "@parall/claude-agent": "1.29.0",
35
- "@parall/codex-agent": "1.29.0",
36
- "@parall/openclaw-agent": "1.29.0"
32
+ "@parall/sdk": "1.29.2",
33
+ "@parall/codex-agent": "1.29.2",
34
+ "@parall/claude-agent": "1.29.2",
35
+ "@parall/agent-core": "1.29.2",
36
+ "@parall/openclaw-agent": "1.29.2"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22.0.0",