machine-bridge-mcp 0.11.0 → 0.12.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/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +6 -6
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -7
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +15 -3
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +218 -130
- package/src/local/daemon-process.mjs +199 -0
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +115 -36
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import {
|
|
4
|
+
acquireDaemonLock,
|
|
5
|
+
daemonLockPathForState,
|
|
6
|
+
readDaemonLockOwner,
|
|
7
|
+
resolveWorkspace,
|
|
8
|
+
} from "./state.mjs";
|
|
9
|
+
import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCommandLine } from "./process-identity.mjs";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
12
|
+
const DEFAULT_TAKEOVER_POLL_MS = 100;
|
|
13
|
+
|
|
14
|
+
export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
15
|
+
const ownerMetadata = options.ownerMetadata || {};
|
|
16
|
+
let lock = acquireDaemonLock(state, ownerMetadata);
|
|
17
|
+
if (lock.acquired || !options.takeOverServiceOwner) return lock;
|
|
18
|
+
|
|
19
|
+
const stopped = await stopWorkspaceServiceDaemon(state, {
|
|
20
|
+
owner: lock.owner,
|
|
21
|
+
timeoutMs: options.timeoutMs,
|
|
22
|
+
pollMs: options.pollMs,
|
|
23
|
+
logger: options.logger,
|
|
24
|
+
reason: "foreground startup",
|
|
25
|
+
});
|
|
26
|
+
if (!stopped.verified_service_daemon) return lock;
|
|
27
|
+
if (!stopped.ok) {
|
|
28
|
+
const pid = stopped.pid ? `pid ${stopped.pid}` : "unknown pid";
|
|
29
|
+
throw new Error(`background daemon did not release the workspace within ${Math.ceil(stopped.timeout_ms / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
lock = acquireDaemonLock(state, ownerMetadata);
|
|
33
|
+
if (lock.acquired) options.logger?.info?.("background daemon stopped; foreground startup is taking over the workspace");
|
|
34
|
+
return lock;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
38
|
+
const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
|
|
39
|
+
const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
|
|
40
|
+
const logger = options.logger || { info() {}, warn() {} };
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
const signalled = new Set();
|
|
43
|
+
let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
|
|
44
|
+
let verified = false;
|
|
45
|
+
let lastOwner = owner;
|
|
46
|
+
|
|
47
|
+
while (true) {
|
|
48
|
+
if (owner?.pid && isPidAlive(owner.pid)) {
|
|
49
|
+
lastOwner = owner;
|
|
50
|
+
const identity = inspectWorkspaceDaemonOwner(state, owner);
|
|
51
|
+
if (!identity.verified_service_daemon) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
found: true,
|
|
55
|
+
verified_service_daemon: false,
|
|
56
|
+
reason: identity.reason,
|
|
57
|
+
timeout_ms: timeoutMs,
|
|
58
|
+
...publicDaemonOwner(owner),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
verified = true;
|
|
62
|
+
if (!signalled.has(Number(owner.pid))) {
|
|
63
|
+
const purpose = options.reason || "service stop";
|
|
64
|
+
logger.info?.(`stopping detached background daemon (pid ${owner.pid}) for ${purpose}`);
|
|
65
|
+
try {
|
|
66
|
+
process.kill(Number(owner.pid), "SIGTERM");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code !== "ESRCH") {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
found: true,
|
|
72
|
+
verified_service_daemon: true,
|
|
73
|
+
reason: "signal_failed",
|
|
74
|
+
timeout_ms: timeoutMs,
|
|
75
|
+
...publicDaemonOwner(owner),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
signalled.add(Number(owner.pid));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const liveSignalled = [...signalled].filter((pid) => isPidAlive(pid));
|
|
84
|
+
const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
|
|
85
|
+
if (!currentOwnerAlive && liveSignalled.length === 0) break;
|
|
86
|
+
|
|
87
|
+
if (Date.now() >= deadline) {
|
|
88
|
+
logger.warn?.(`detached background daemon did not stop within ${Math.ceil(timeoutMs / 1000)} seconds`);
|
|
89
|
+
const remainingPid = Number(owner?.pid) || liveSignalled[0] || null;
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
found: true,
|
|
93
|
+
stopped: false,
|
|
94
|
+
verified_service_daemon: verified,
|
|
95
|
+
reason: "timeout",
|
|
96
|
+
timeout_ms: timeoutMs,
|
|
97
|
+
pid: remainingPid,
|
|
98
|
+
mode: publicDaemonMode(lastOwner),
|
|
99
|
+
version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
103
|
+
owner = readDaemonLockOwner(daemonLockPathForState(state));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Reclaim a stale lock only through the normal token-aware primitive.
|
|
107
|
+
const staleCleanup = acquireDaemonLock(state, { mode: "service" });
|
|
108
|
+
if (staleCleanup.acquired) staleCleanup.release();
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
found: verified,
|
|
112
|
+
stopped: verified,
|
|
113
|
+
verified_service_daemon: verified,
|
|
114
|
+
reason: verified ? "stopped" : "not_running",
|
|
115
|
+
timeout_ms: timeoutMs,
|
|
116
|
+
...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function inspectWorkspaceDaemon(state) {
|
|
121
|
+
const owner = readDaemonLockOwner(daemonLockPathForState(state));
|
|
122
|
+
if (!owner) return { present: false, alive: false, verified_service_daemon: false };
|
|
123
|
+
const alive = Boolean(owner.pid && isPidAlive(owner.pid));
|
|
124
|
+
const identity = alive
|
|
125
|
+
? inspectWorkspaceDaemonOwner(state, owner)
|
|
126
|
+
: { verified_service_daemon: false, reason: "stale_lock" };
|
|
127
|
+
return {
|
|
128
|
+
present: true,
|
|
129
|
+
alive,
|
|
130
|
+
verified_service_daemon: identity.verified_service_daemon,
|
|
131
|
+
identity_reason: identity.reason,
|
|
132
|
+
...publicDaemonOwner(owner),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function inspectWorkspaceDaemonOwner(state, owner) {
|
|
137
|
+
if (!owner || owner.purpose !== "daemon") return { verified_service_daemon: false, reason: "invalid_lock_owner" };
|
|
138
|
+
const pid = Number(owner.pid);
|
|
139
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return { verified_service_daemon: false, reason: "invalid_pid" };
|
|
140
|
+
const processIdentity = inspectProcessInstance(owner);
|
|
141
|
+
if (!processIdentity.current) return { verified_service_daemon: false, reason: processIdentity.reason };
|
|
142
|
+
if (!sameCanonicalPath(owner.workspace, state.workspace.path)) return { verified_service_daemon: false, reason: "workspace_mismatch" };
|
|
143
|
+
if (owner.mode === "foreground") return { verified_service_daemon: false, reason: "foreground_daemon" };
|
|
144
|
+
|
|
145
|
+
const command = processCommandLine(pid);
|
|
146
|
+
if (!command) {
|
|
147
|
+
return { verified_service_daemon: false, reason: owner.mode === "service" ? "service_identity_unavailable" : "legacy_identity_unavailable" };
|
|
148
|
+
}
|
|
149
|
+
const argv = splitProcessCommandLine(command);
|
|
150
|
+
const entryName = path.basename(String(owner.entryScript || "machine-mcp"));
|
|
151
|
+
const workspaceArg = commandFlagValue(argv, "--workspace");
|
|
152
|
+
const stateRootArg = commandFlagValue(argv, "--state-dir");
|
|
153
|
+
const entryMatches = argv.some((value) => {
|
|
154
|
+
const name = path.basename(value);
|
|
155
|
+
return name === entryName || name === "machine-mcp" || name === "machine-mcp.mjs";
|
|
156
|
+
});
|
|
157
|
+
const matches = argv.includes("--daemon-only")
|
|
158
|
+
&& sameCanonicalPath(workspaceArg, state.workspace.path)
|
|
159
|
+
&& sameCanonicalPath(stateRootArg, state.paths.stateRoot)
|
|
160
|
+
&& entryMatches;
|
|
161
|
+
return { verified_service_daemon: matches, reason: matches ? "service_command" : "command_mismatch" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function commandFlagValue(argv, name) {
|
|
165
|
+
const index = argv.indexOf(name);
|
|
166
|
+
return index >= 0 && index + 1 < argv.length ? argv[index + 1] : "";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sameCanonicalPath(left, right) {
|
|
170
|
+
if (typeof left !== "string" || typeof right !== "string") return false;
|
|
171
|
+
try {
|
|
172
|
+
const a = resolveWorkspace(left);
|
|
173
|
+
const b = resolveWorkspace(right);
|
|
174
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function publicDaemonOwner(owner) {
|
|
181
|
+
return {
|
|
182
|
+
pid: Number(owner?.pid) || null,
|
|
183
|
+
mode: publicDaemonMode(owner),
|
|
184
|
+
version: typeof owner?.version === "string" ? owner.version : "unknown",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function publicDaemonMode(owner) {
|
|
189
|
+
return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "legacy";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function boundedPositiveInt(value, fallback) {
|
|
193
|
+
const parsed = Number(value);
|
|
194
|
+
return Number.isFinite(parsed) ? Math.max(1, parsed) : fallback;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sleep(ms) {
|
|
198
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
199
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
linkSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
14
|
+
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
15
|
+
|
|
16
|
+
export function createExclusiveFileSync(target, content, options = {}) {
|
|
17
|
+
const mode = Number.isInteger(options.mode) ? options.mode : 0o600;
|
|
18
|
+
const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
19
|
+
let fd;
|
|
20
|
+
let linked = false;
|
|
21
|
+
try {
|
|
22
|
+
fd = openSync(temporary, "wx", mode);
|
|
23
|
+
writeFileSync(fd, content);
|
|
24
|
+
fsyncSync(fd);
|
|
25
|
+
closeSync(fd);
|
|
26
|
+
fd = undefined;
|
|
27
|
+
linkSync(temporary, target);
|
|
28
|
+
linked = true;
|
|
29
|
+
try { chmodSync(target, mode); } catch {}
|
|
30
|
+
return { created: true, path: target };
|
|
31
|
+
} finally {
|
|
32
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
33
|
+
try { unlinkSync(temporary); } catch {}
|
|
34
|
+
if (!linked && options.cleanupTargetOnFailure === true) {
|
|
35
|
+
try { unlinkSync(target); } catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function replaceFileAtomicallySync(target, content, options = {}) {
|
|
41
|
+
const mode = Number.isInteger(options.mode) ? options.mode : 0o600;
|
|
42
|
+
const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
fd = openSync(temporary, "wx", mode);
|
|
46
|
+
writeFileSync(fd, content);
|
|
47
|
+
fsyncSync(fd);
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
fd = undefined;
|
|
50
|
+
replaceFileSync(temporary, target);
|
|
51
|
+
try { chmodSync(target, mode); } catch {}
|
|
52
|
+
return { replaced: true, path: target };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
55
|
+
try { unlinkSync(temporary); } catch {}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function removeOwnedJsonFileSync(target, expected = {}, options = {}) {
|
|
61
|
+
const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : 4096;
|
|
62
|
+
const snapshot = ownedJsonSnapshot(target, maxBytes);
|
|
63
|
+
if (!snapshot || !matchesExpected(snapshot.value, expected)) return false;
|
|
64
|
+
let current;
|
|
65
|
+
try { current = lstatSync(target); } catch (error) { return error?.code === "ENOENT"; }
|
|
66
|
+
if (current.isSymbolicLink() || !current.isFile() || !sameIdentity(snapshot.info, current)) return false;
|
|
67
|
+
const currentSnapshot = ownedJsonSnapshot(target, maxBytes);
|
|
68
|
+
if (!currentSnapshot || !sameIdentity(snapshot.info, currentSnapshot.info) || !matchesExpected(currentSnapshot.value, expected)) return false;
|
|
69
|
+
try { unlinkSync(target); return true; } catch (error) { return error?.code === "ENOENT"; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ownedJsonSnapshot(target, maxBytes) {
|
|
73
|
+
let info;
|
|
74
|
+
try { info = lstatSync(target); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
75
|
+
if (info.isSymbolicLink() || !info.isFile()) return null;
|
|
76
|
+
try {
|
|
77
|
+
const value = JSON.parse(readBoundedRegularFileSync(target, maxBytes).toString("utf8"));
|
|
78
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
79
|
+
return { value, info };
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchesExpected(value, expected) {
|
|
86
|
+
return Object.entries(expected).every(([key, expectedValue]) => value?.[key] === expectedValue);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sameIdentity(left, right) {
|
|
90
|
+
return Number(left.dev) === Number(right.dev)
|
|
91
|
+
&& Number(left.ino) === Number(right.ino)
|
|
92
|
+
&& Number(left.size) === Number(right.size)
|
|
93
|
+
&& Number(left.mtimeMs) === Number(right.mtimeMs);
|
|
94
|
+
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash
|
|
3
|
-
import { chmodSync,
|
|
4
|
-
import { basename,
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { executionEnv } from "./shell.mjs";
|
|
6
6
|
import { terminateProcessTree } from "./process-sessions.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
|
+
import { currentProcessStartTimeMs } from "./process-identity.mjs";
|
|
8
9
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
10
|
|
|
10
11
|
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
@@ -22,6 +23,8 @@ if (!jobDirInput) throw new Error("--job-dir is required");
|
|
|
22
23
|
const jobDir = resolve(jobDirInput);
|
|
23
24
|
if (!JOB_ID.test(basename(jobDir))) throw new Error("--job-dir must name a managed job directory");
|
|
24
25
|
const recover = options.recover === true;
|
|
26
|
+
const recoveryLockToken = typeof process.env.MBM_RECOVERY_LOCK_TOKEN === "string" ? process.env.MBM_RECOVERY_LOCK_TOKEN : "";
|
|
27
|
+
delete process.env.MBM_RECOVERY_LOCK_TOKEN;
|
|
25
28
|
const planFile = join(jobDir, "plan.json");
|
|
26
29
|
const statusFile = join(jobDir, "status.json");
|
|
27
30
|
const resultFile = join(jobDir, "result.json");
|
|
@@ -30,6 +33,7 @@ const runtimeDir = join(jobDir, "runtime");
|
|
|
30
33
|
const resourcesDir = join(runtimeDir, "resources");
|
|
31
34
|
const temporaryFilesDir = join(runtimeDir, "files");
|
|
32
35
|
const runnerPidFile = join(jobDir, "runner.pid");
|
|
36
|
+
const RUNNER_PROCESS_STARTED_AT = new Date(currentProcessStartTimeMs()).toISOString();
|
|
33
37
|
|
|
34
38
|
class JobCancelledError extends Error {
|
|
35
39
|
constructor() {
|
|
@@ -49,7 +53,7 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
49
53
|
|
|
50
54
|
const initial = readJson(statusFile, MAX_STATUS_BYTES);
|
|
51
55
|
assertLaunchState(initial);
|
|
52
|
-
|
|
56
|
+
createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
|
|
53
57
|
if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
|
|
54
58
|
try {
|
|
55
59
|
const plan = readJson(planFile, 1024 * 1024);
|
|
@@ -59,10 +63,22 @@ try {
|
|
|
59
63
|
recordFatalRunnerError(error);
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
async function releaseRecoveryClaim() {
|
|
67
|
+
if (!/^[a-f0-9]{32}$/.test(recoveryLockToken)) throw new Error("recovery runner is missing its ownership token");
|
|
68
|
+
const file = join(jobDir, "recovery.lock");
|
|
69
|
+
const deadline = Date.now() + 5000;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
|
|
72
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
73
|
+
}
|
|
74
|
+
throw new Error("recovery runner could not verify ownership of the recovery lock");
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
async function main(plan, initial) {
|
|
63
78
|
const status = {
|
|
64
79
|
...initial,
|
|
65
80
|
runner_pid: process.pid,
|
|
81
|
+
runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
|
|
66
82
|
started_at: initial.started_at || new Date().toISOString(),
|
|
67
83
|
updated_at: new Date().toISOString(),
|
|
68
84
|
status: recover ? "cleaning" : "running",
|
|
@@ -182,6 +198,7 @@ function recordFatalRunnerError(error) {
|
|
|
182
198
|
current_phase: null,
|
|
183
199
|
current_step: null,
|
|
184
200
|
runner_pid: process.pid,
|
|
201
|
+
runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
|
|
185
202
|
updated_at: now,
|
|
186
203
|
finished_at: now,
|
|
187
204
|
error_class: result.error_class,
|
|
@@ -259,7 +276,6 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
259
276
|
timedOut = true;
|
|
260
277
|
terminateProcessTree(child, "SIGTERM");
|
|
261
278
|
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
|
|
262
|
-
killTimer.unref?.();
|
|
263
279
|
}, timeoutMs);
|
|
264
280
|
timer.unref?.();
|
|
265
281
|
const cancellationPoll = setInterval(() => {
|
|
@@ -300,12 +316,14 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
300
316
|
if (closed) return;
|
|
301
317
|
closed = true;
|
|
302
318
|
clearTimeout(timer);
|
|
303
|
-
if (killTimer) clearTimeout(killTimer);
|
|
319
|
+
if (killTimer && !timedOut) clearTimeout(killTimer);
|
|
304
320
|
clearInterval(cancellationPoll);
|
|
305
321
|
activeChild = null;
|
|
306
322
|
activeChildCancellationAware = false;
|
|
307
|
-
if (cancellationEscalation
|
|
308
|
-
|
|
323
|
+
if (cancellationEscalation && !isCancellationRequested()) {
|
|
324
|
+
clearTimeout(cancellationEscalation);
|
|
325
|
+
cancellationEscalation = null;
|
|
326
|
+
}
|
|
309
327
|
callback();
|
|
310
328
|
}
|
|
311
329
|
});
|
|
@@ -440,7 +458,7 @@ function redactOutput(buffer, context) {
|
|
|
440
458
|
}
|
|
441
459
|
|
|
442
460
|
function updateStatus(status, changes) {
|
|
443
|
-
Object.assign(status, changes, { runner_pid: process.pid, updated_at: new Date().toISOString() });
|
|
461
|
+
Object.assign(status, changes, { runner_pid: process.pid, runner_process_started_at: RUNNER_PROCESS_STARTED_AT, updated_at: new Date().toISOString() });
|
|
444
462
|
writeJson(statusFile, status, MAX_STATUS_BYTES);
|
|
445
463
|
}
|
|
446
464
|
|
|
@@ -451,9 +469,9 @@ function requestCancellation() {
|
|
|
451
469
|
terminateProcessTree(child, "SIGTERM");
|
|
452
470
|
if (cancellationEscalation) return;
|
|
453
471
|
cancellationEscalation = setTimeout(() => {
|
|
454
|
-
|
|
472
|
+
terminateProcessTree(child, "SIGKILL");
|
|
473
|
+
cancellationEscalation = null;
|
|
455
474
|
}, 2000);
|
|
456
|
-
cancellationEscalation.unref?.();
|
|
457
475
|
}
|
|
458
476
|
|
|
459
477
|
function isCancellationRequested() {
|
|
@@ -474,12 +492,10 @@ function appendLimited(current, chunk, limit, budget) {
|
|
|
474
492
|
}
|
|
475
493
|
|
|
476
494
|
function writeJson(file, value, maxBytes) {
|
|
477
|
-
const text = `${JSON.stringify(value, null, 2)}
|
|
495
|
+
const text = `${JSON.stringify(value, null, 2)}
|
|
496
|
+
`;
|
|
478
497
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
|
|
479
|
-
|
|
480
|
-
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
481
|
-
replaceFileSync(temp, file);
|
|
482
|
-
chmodSync(file, 0o600);
|
|
498
|
+
replaceFileAtomicallySync(file, text, { mode: 0o600 });
|
|
483
499
|
}
|
|
484
500
|
|
|
485
501
|
function readJson(file, maxBytes) {
|
package/src/local/log.mjs
CHANGED
|
@@ -22,8 +22,15 @@ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
|
|
|
22
22
|
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
23
23
|
const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
|
|
24
24
|
const GITHUB_TOKEN = /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g;
|
|
25
|
+
const GITLAB_TOKEN = /\bglpat-[A-Za-z0-9_-]{20,}\b/g;
|
|
26
|
+
const NPM_TOKEN = /\bnpm_[A-Za-z0-9]{30,}\b/g;
|
|
27
|
+
const SLACK_TOKEN = /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g;
|
|
28
|
+
const GOOGLE_API_KEY = /\bAIza[A-Za-z0-9_-]{30,}\b/g;
|
|
29
|
+
const PAYMENT_API_KEY = /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g;
|
|
30
|
+
const JWT_VALUE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
31
|
+
const URL_CREDENTIALS = /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi;
|
|
25
32
|
const API_SECRET = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g;
|
|
26
|
-
const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g;
|
|
33
|
+
const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g;
|
|
27
34
|
const HOME_PATHS = [...new Set([process.env.HOME, process.env.USERPROFILE, safeHomeDirectory()].filter(value => typeof value === "string" && value.length > 1))]
|
|
28
35
|
.sort((left, right) => right.length - left.length);
|
|
29
36
|
|
|
@@ -54,6 +61,7 @@ export function createLogger(options = {}) {
|
|
|
54
61
|
error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
|
|
55
62
|
debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
|
|
56
63
|
plain(message = "") { if (!quiet) process.stdout.write(`${String(message)}\n`); },
|
|
64
|
+
safePlain(message = "") { if (!quiet) process.stdout.write(`${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}\n`); },
|
|
57
65
|
json(value) { process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); },
|
|
58
66
|
};
|
|
59
67
|
}
|
|
@@ -123,6 +131,13 @@ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
|
|
|
123
131
|
.replace(BEARER_VALUE, "Bearer <redacted>")
|
|
124
132
|
.replace(AWS_ACCESS_KEY, "<redacted-cloud-key>")
|
|
125
133
|
.replace(GITHUB_TOKEN, "<redacted-access-token>")
|
|
134
|
+
.replace(GITLAB_TOKEN, "<redacted-access-token>")
|
|
135
|
+
.replace(NPM_TOKEN, "<redacted-access-token>")
|
|
136
|
+
.replace(SLACK_TOKEN, "<redacted-access-token>")
|
|
137
|
+
.replace(GOOGLE_API_KEY, "<redacted-cloud-key>")
|
|
138
|
+
.replace(PAYMENT_API_KEY, "<redacted-api-secret>")
|
|
139
|
+
.replace(JWT_VALUE, "<redacted-bearer-token>")
|
|
140
|
+
.replace(URL_CREDENTIALS, "<redacted-credential-url>")
|
|
126
141
|
.replace(API_SECRET, "<redacted-api-secret>")
|
|
127
142
|
.replace(PRIVATE_KEY_HEADER, "<redacted-private-key-header>")
|
|
128
143
|
.replace(EMAIL_VALUE, "<redacted-email>");
|