@kylecheng3146/agent-ops 0.0.1
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/LICENSE +21 -0
- package/README.md +83 -0
- package/SECURITY.md +22 -0
- package/dist/packages/cli/src/args.js +322 -0
- package/dist/packages/cli/src/bin.js +290 -0
- package/dist/packages/cli/src/cli.js +80 -0
- package/dist/packages/cli/src/commands/config.js +8 -0
- package/dist/packages/cli/src/commands/doctor.js +32 -0
- package/dist/packages/cli/src/commands/index.js +16 -0
- package/dist/packages/cli/src/commands/init.js +65 -0
- package/dist/packages/cli/src/commands/review.js +71 -0
- package/dist/packages/cli/src/commands/task.js +141 -0
- package/dist/packages/cli/src/commands/trust.js +48 -0
- package/dist/packages/cli/src/commands/uninstall.js +58 -0
- package/dist/packages/cli/src/commands/update.js +58 -0
- package/dist/packages/cli/src/commands/verify.js +108 -0
- package/dist/packages/cli/src/output.js +53 -0
- package/dist/packages/cli/src/plan-output.js +28 -0
- package/dist/packages/cli/src/wizard.js +75 -0
- package/dist/runtime/src/adapters/claude/config.js +109 -0
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/claude/input.js +38 -0
- package/dist/runtime/src/adapters/claude/output.js +30 -0
- package/dist/runtime/src/adapters/codex/config.js +93 -0
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/codex/input.js +41 -0
- package/dist/runtime/src/adapters/codex/output.js +24 -0
- package/dist/runtime/src/config/explain.js +34 -0
- package/dist/runtime/src/config/load.js +25 -0
- package/dist/runtime/src/config/merge.js +170 -0
- package/dist/runtime/src/config/migrate.js +61 -0
- package/dist/runtime/src/contracts.js +1 -0
- package/dist/runtime/src/discovery/go.js +145 -0
- package/dist/runtime/src/discovery/index.js +40 -0
- package/dist/runtime/src/discovery/make.js +162 -0
- package/dist/runtime/src/discovery/node.js +175 -0
- package/dist/runtime/src/discovery/python.js +163 -0
- package/dist/runtime/src/discovery/rust.js +159 -0
- package/dist/runtime/src/discovery/types.js +1 -0
- package/dist/runtime/src/fs/hash.js +19 -0
- package/dist/runtime/src/fs/managed-block.js +90 -0
- package/dist/runtime/src/fs/manifest.js +24 -0
- package/dist/runtime/src/fs/mutation-worker.js +185 -0
- package/dist/runtime/src/fs/paths.js +96 -0
- package/dist/runtime/src/fs/transaction.js +498 -0
- package/dist/runtime/src/guardrails/destructive.js +207 -0
- package/dist/runtime/src/guardrails/evaluate.js +9 -0
- package/dist/runtime/src/guardrails/exceptions.js +49 -0
- package/dist/runtime/src/guardrails/secrets.js +97 -0
- package/dist/runtime/src/guardrails/types.js +9 -0
- package/dist/runtime/src/hooks/dispatch.js +78 -0
- package/dist/runtime/src/hooks/events.js +1 -0
- package/dist/runtime/src/hooks/hook-entry.js +19 -0
- package/dist/runtime/src/hooks/normalize.js +59 -0
- package/dist/runtime/src/hooks/output.js +12 -0
- package/dist/runtime/src/hooks/shell.js +138 -0
- package/dist/runtime/src/hooks/stop-verify.js +70 -0
- package/dist/runtime/src/install/apply.js +70 -0
- package/dist/runtime/src/install/doctor.js +196 -0
- package/dist/runtime/src/install/harness.js +87 -0
- package/dist/runtime/src/install/ownership.js +84 -0
- package/dist/runtime/src/install/plan.js +257 -0
- package/dist/runtime/src/install/profiles.js +28 -0
- package/dist/runtime/src/install/types.js +1 -0
- package/dist/runtime/src/install/uninstall.js +206 -0
- package/dist/runtime/src/install/update.js +123 -0
- package/dist/runtime/src/logging/local-log.js +158 -0
- package/dist/runtime/src/registry/npm.js +141 -0
- package/dist/runtime/src/review/claude-runner.js +4 -0
- package/dist/runtime/src/review/codex-runner.js +4 -0
- package/dist/runtime/src/review/packet.js +10 -0
- package/dist/runtime/src/review/result.js +24 -0
- package/dist/runtime/src/review/roles.js +3 -0
- package/dist/runtime/src/review/runner.js +45 -0
- package/dist/runtime/src/schema/validate.js +584 -0
- package/dist/runtime/src/security/permissions.js +654 -0
- package/dist/runtime/src/security/redact.js +41 -0
- package/dist/runtime/src/security/trust.js +209 -0
- package/dist/runtime/src/task/render.js +43 -0
- package/dist/runtime/src/task/service.js +235 -0
- package/dist/runtime/src/task/store.js +265 -0
- package/dist/runtime/src/verify/change-surface.js +86 -0
- package/dist/runtime/src/verify/evidence.js +89 -0
- package/dist/runtime/src/verify/fingerprint.js +67 -0
- package/dist/runtime/src/verify/scope.js +69 -0
- package/dist/runtime/src/verify/service.js +217 -0
- package/dist/runtime/src/verify/spawn.js +326 -0
- package/dist/runtime/src/verify/test-count.js +148 -0
- package/docs/en/spec/README.md +13 -0
- package/docs/en/spec/acceptance-and-evidence.md +21 -0
- package/docs/en/spec/delegation.md +21 -0
- package/docs/en/spec/guardrails.md +21 -0
- package/docs/en/spec/harness-adapters.md +21 -0
- package/docs/en/spec/judgment.md +21 -0
- package/docs/en/spec/loop-engineering.md +23 -0
- package/docs/en/spec/maintenance.md +21 -0
- package/docs/en/spec/review.md +21 -0
- package/docs/en/spec/troubleshooting.md +21 -0
- package/docs/zh-TW/spec/README.md +13 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +23 -0
- package/docs/zh-TW/spec/delegation.md +23 -0
- package/docs/zh-TW/spec/guardrails.md +23 -0
- package/docs/zh-TW/spec/harness-adapters.md +23 -0
- package/docs/zh-TW/spec/judgment.md +23 -0
- package/docs/zh-TW/spec/loop-engineering.md +23 -0
- package/docs/zh-TW/spec/maintenance.md +23 -0
- package/docs/zh-TW/spec/review.md +23 -0
- package/docs/zh-TW/spec/troubleshooting.md +23 -0
- package/package.json +41 -0
- package/schemas/config.schema.json +231 -0
- package/schemas/evidence.schema.json +115 -0
- package/schemas/manifest.schema.json +116 -0
- package/schemas/task.schema.json +59 -0
- package/templates/common/AGENTS.block.md +3 -0
- package/templates/common/CLAUDE.block.md +3 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { constants, rmSync } from "node:fs";
|
|
4
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
9
|
+
export function localStatePaths(homeDirectory) {
|
|
10
|
+
const root = join(homeDirectory, ".agent-ops", "state");
|
|
11
|
+
return {
|
|
12
|
+
anchorDirectory: homeDirectory,
|
|
13
|
+
root,
|
|
14
|
+
trustStore: join(root, "trust.json"),
|
|
15
|
+
logDirectory: join(root, "logs"),
|
|
16
|
+
failureDirectory: join(root, "failures")
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function isMissing(error) {
|
|
20
|
+
return (typeof error === "object" &&
|
|
21
|
+
error !== null &&
|
|
22
|
+
"code" in error &&
|
|
23
|
+
error.code === "ENOENT");
|
|
24
|
+
}
|
|
25
|
+
function isAlreadyExists(error) {
|
|
26
|
+
return (typeof error === "object" &&
|
|
27
|
+
error !== null &&
|
|
28
|
+
"code" in error &&
|
|
29
|
+
error.code === "EEXIST");
|
|
30
|
+
}
|
|
31
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 6_000;
|
|
32
|
+
const HEARTBEAT_INTERVAL_MS = 500;
|
|
33
|
+
const HEARTBEAT_MAX_AGE_MS = 3_000;
|
|
34
|
+
const PROCESS_IDENTITY_CACHE_MS = 250;
|
|
35
|
+
const PROCESS_INSTANCE_TOKEN = randomUUID();
|
|
36
|
+
const execFile = promisify(execFileCallback);
|
|
37
|
+
const lockQueues = new Map();
|
|
38
|
+
const heartbeatFailures = new Map();
|
|
39
|
+
const heartbeatPaths = new Set();
|
|
40
|
+
const processHeartbeats = new Map();
|
|
41
|
+
const processIdentityCache = new Map();
|
|
42
|
+
let heartbeatCleanupRegistered = false;
|
|
43
|
+
function isValidProcessIdentity(identity, heartbeatFile, processId) {
|
|
44
|
+
if (/^windows:\d+$/u.test(identity)) {
|
|
45
|
+
return heartbeatFile === null;
|
|
46
|
+
}
|
|
47
|
+
const heartbeatMatch = typeof heartbeatFile === "string"
|
|
48
|
+
? new RegExp(`^\\.agent-ops-process-${processId}-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, "u").exec(heartbeatFile)
|
|
49
|
+
: null;
|
|
50
|
+
if (heartbeatMatch === null) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const runtimeMatch = /^runtime:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
|
|
54
|
+
.test(identity);
|
|
55
|
+
if (runtimeMatch) {
|
|
56
|
+
return heartbeatMatch[1] === identity.slice(8);
|
|
57
|
+
}
|
|
58
|
+
return (/^linux:\d+$/u.test(identity) ||
|
|
59
|
+
/^posix:[^\0\r\n]{1,240}$/u.test(identity));
|
|
60
|
+
}
|
|
61
|
+
function parseLockRecord(source) {
|
|
62
|
+
let value;
|
|
63
|
+
try {
|
|
64
|
+
value = JSON.parse(source);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== "object" ||
|
|
70
|
+
value === null ||
|
|
71
|
+
Array.isArray(value) ||
|
|
72
|
+
Object.keys(value).length !== 7 ||
|
|
73
|
+
!("choosing" in value) ||
|
|
74
|
+
!("heartbeatFile" in value) ||
|
|
75
|
+
!("processId" in value) ||
|
|
76
|
+
!("processIdentity" in value) ||
|
|
77
|
+
!("createdAt" in value) ||
|
|
78
|
+
!("ticket" in value) ||
|
|
79
|
+
!("token" in value) ||
|
|
80
|
+
typeof value.choosing !== "boolean" ||
|
|
81
|
+
!Number.isSafeInteger(value.processId) ||
|
|
82
|
+
value.processId <= 0 ||
|
|
83
|
+
typeof value.processIdentity !== "string" ||
|
|
84
|
+
value.processIdentity.length === 0 ||
|
|
85
|
+
value.processIdentity.length > 256 ||
|
|
86
|
+
!isValidProcessIdentity(value.processIdentity, value.heartbeatFile, value.processId) ||
|
|
87
|
+
typeof value.createdAt !== "string" ||
|
|
88
|
+
!Number.isFinite(Date.parse(value.createdAt)) ||
|
|
89
|
+
!Number.isSafeInteger(value.ticket) ||
|
|
90
|
+
value.ticket < 0 ||
|
|
91
|
+
typeof value.token !== "string" ||
|
|
92
|
+
value.token.length === 0 ||
|
|
93
|
+
value.token.length > 128) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
choosing: value.choosing,
|
|
98
|
+
createdAt: value.createdAt,
|
|
99
|
+
heartbeatFile: value.heartbeatFile,
|
|
100
|
+
processId: value.processId,
|
|
101
|
+
processIdentity: value.processIdentity,
|
|
102
|
+
ticket: value.ticket,
|
|
103
|
+
token: value.token
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function registerHeartbeatCleanup() {
|
|
107
|
+
if (heartbeatCleanupRegistered) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
heartbeatCleanupRegistered = true;
|
|
111
|
+
process.once("exit", () => {
|
|
112
|
+
for (const heartbeatPath of heartbeatPaths) {
|
|
113
|
+
try {
|
|
114
|
+
rmSync(heartbeatPath, { force: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
process.emitWarning("A private-state heartbeat could not be removed during exit.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async function ensureProcessHeartbeat(parent, anchorDirectory) {
|
|
123
|
+
const cacheKey = `${resolve(anchorDirectory)}\0${resolve(parent)}`;
|
|
124
|
+
const existing = processHeartbeats.get(cacheKey);
|
|
125
|
+
if (existing !== undefined) {
|
|
126
|
+
const heartbeat = await existing;
|
|
127
|
+
await assertProcessHeartbeat(parent, heartbeat);
|
|
128
|
+
return heartbeat.file;
|
|
129
|
+
}
|
|
130
|
+
const heartbeatFile = `.agent-ops-process-${process.pid}-${PROCESS_INSTANCE_TOKEN}`;
|
|
131
|
+
const heartbeatPath = join(parent, heartbeatFile);
|
|
132
|
+
const creating = (async () => {
|
|
133
|
+
await ensurePrivateDirectory(parent, anchorDirectory);
|
|
134
|
+
const handle = await open(heartbeatPath, "wx", 0o600);
|
|
135
|
+
let device;
|
|
136
|
+
let inode;
|
|
137
|
+
try {
|
|
138
|
+
await handle.writeFile(`runtime:${PROCESS_INSTANCE_TOKEN}\n`, "utf8");
|
|
139
|
+
await handle.sync();
|
|
140
|
+
const status = await handle.stat({ bigint: true });
|
|
141
|
+
device = status.dev.toString();
|
|
142
|
+
inode = status.ino.toString();
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
await handle.close().catch(() => {
|
|
146
|
+
process.emitWarning("A private-state heartbeat handle could not be closed.");
|
|
147
|
+
});
|
|
148
|
+
await rm(heartbeatPath, { force: true }).catch(() => {
|
|
149
|
+
process.emitWarning("An incomplete private-state heartbeat could not be removed.");
|
|
150
|
+
});
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
heartbeatPaths.add(heartbeatPath);
|
|
154
|
+
registerHeartbeatCleanup();
|
|
155
|
+
const heartbeat = {
|
|
156
|
+
device,
|
|
157
|
+
file: heartbeatFile,
|
|
158
|
+
inode
|
|
159
|
+
};
|
|
160
|
+
const timer = setInterval(() => {
|
|
161
|
+
void handle.utimes(new Date(), new Date()).catch((error) => {
|
|
162
|
+
heartbeatFailures.set(cacheKey, error);
|
|
163
|
+
clearInterval(timer);
|
|
164
|
+
void handle.close().catch(() => {
|
|
165
|
+
process.emitWarning("A failed private-state heartbeat handle could not be closed.");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
169
|
+
timer.unref();
|
|
170
|
+
return heartbeat;
|
|
171
|
+
})();
|
|
172
|
+
processHeartbeats.set(cacheKey, creating);
|
|
173
|
+
try {
|
|
174
|
+
return (await creating).file;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
processHeartbeats.delete(cacheKey);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function assertProcessHeartbeat(parent, heartbeat) {
|
|
182
|
+
const heartbeatPath = join(parent, heartbeat.file);
|
|
183
|
+
try {
|
|
184
|
+
const status = await lstat(heartbeatPath, { bigint: true });
|
|
185
|
+
if (status.isSymbolicLink() ||
|
|
186
|
+
!status.isFile() ||
|
|
187
|
+
status.dev.toString() !== heartbeat.device ||
|
|
188
|
+
status.ino.toString() !== heartbeat.inode) {
|
|
189
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_IDENTITY_UNAVAILABLE", "The private-state heartbeat path changed after creation.");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof AgentOpsError &&
|
|
194
|
+
error.code === "PRIVATE_STATE_LOCK_IDENTITY_UNAVAILABLE") {
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_IDENTITY_UNAVAILABLE", "The private-state heartbeat could not be verified.", { cause: error });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function readLinuxProcessIdentity(processId) {
|
|
201
|
+
try {
|
|
202
|
+
const source = await readFile(`/proc/${processId}/stat`, "utf8");
|
|
203
|
+
const commandEnd = source.lastIndexOf(")");
|
|
204
|
+
if (commandEnd < 0) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const fields = source.slice(commandEnd + 1).trim().split(/\s+/);
|
|
208
|
+
const startTime = fields[19];
|
|
209
|
+
return startTime === undefined ? null : `linux:${startTime}`;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function readPosixProcessIdentity(processId) {
|
|
216
|
+
try {
|
|
217
|
+
const result = await execFile("/bin/ps", ["-o", "lstart=", "-p", String(processId)], {
|
|
218
|
+
encoding: "utf8",
|
|
219
|
+
maxBuffer: 4096,
|
|
220
|
+
timeout: 1000
|
|
221
|
+
});
|
|
222
|
+
const startedAt = result.stdout.trim();
|
|
223
|
+
return startedAt.length === 0 ? null : `posix:${startedAt}`;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function readWindowsProcessIdentity(processId) {
|
|
230
|
+
try {
|
|
231
|
+
const script = [
|
|
232
|
+
`$process = Get-Process -Id ${processId} -ErrorAction Stop`,
|
|
233
|
+
"$process.StartTime.ToUniversalTime().Ticks"
|
|
234
|
+
].join("; ");
|
|
235
|
+
const result = await execFile("powershell.exe", [
|
|
236
|
+
"-NoLogo",
|
|
237
|
+
"-NoProfile",
|
|
238
|
+
"-NonInteractive",
|
|
239
|
+
"-Command",
|
|
240
|
+
script
|
|
241
|
+
], {
|
|
242
|
+
encoding: "utf8",
|
|
243
|
+
maxBuffer: 4096,
|
|
244
|
+
timeout: 2000,
|
|
245
|
+
windowsHide: true
|
|
246
|
+
});
|
|
247
|
+
const startedAt = result.stdout.trim();
|
|
248
|
+
return startedAt.length === 0 ? null : `windows:${startedAt}`;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function readProcessIdentity(processId) {
|
|
255
|
+
const cached = processIdentityCache.get(processId);
|
|
256
|
+
if (cached !== undefined &&
|
|
257
|
+
Date.now() - cached.checkedAt <= PROCESS_IDENTITY_CACHE_MS) {
|
|
258
|
+
return cached.identity;
|
|
259
|
+
}
|
|
260
|
+
let identity;
|
|
261
|
+
if (process.platform === "linux") {
|
|
262
|
+
identity = await readLinuxProcessIdentity(processId);
|
|
263
|
+
}
|
|
264
|
+
else if (process.platform === "win32") {
|
|
265
|
+
identity = await readWindowsProcessIdentity(processId);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
identity = await readPosixProcessIdentity(processId);
|
|
269
|
+
}
|
|
270
|
+
if (identity === null && processId === process.pid) {
|
|
271
|
+
identity = `runtime:${PROCESS_INSTANCE_TOKEN}`;
|
|
272
|
+
}
|
|
273
|
+
processIdentityCache.set(processId, {
|
|
274
|
+
checkedAt: Date.now(),
|
|
275
|
+
identity
|
|
276
|
+
});
|
|
277
|
+
return identity;
|
|
278
|
+
}
|
|
279
|
+
function isProcessActive(processId) {
|
|
280
|
+
if (processId === process.pid) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
process.kill(processId, 0);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (typeof error === "object" &&
|
|
289
|
+
error !== null &&
|
|
290
|
+
"code" in error) {
|
|
291
|
+
if (error.code === "ESRCH" || error.code === "EINVAL") {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
if (error.code === "EPERM") {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function isProcessInstanceActive(record, parent, anchorDirectory) {
|
|
302
|
+
if (!isProcessActive(record.processId)) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (record.processId === process.pid) {
|
|
306
|
+
const localIdentity = await readProcessIdentity(process.pid);
|
|
307
|
+
return localIdentity === record.processIdentity;
|
|
308
|
+
}
|
|
309
|
+
if (record.heartbeatFile === null) {
|
|
310
|
+
const identity = await readProcessIdentity(record.processId);
|
|
311
|
+
return identity === null || identity === record.processIdentity;
|
|
312
|
+
}
|
|
313
|
+
const heartbeatPath = join(parent, record.heartbeatFile);
|
|
314
|
+
const heartbeatIdentity = `runtime:${record.heartbeatFile.slice(-36)}`;
|
|
315
|
+
const source = await readPrivateFile(heartbeatPath, anchorDirectory);
|
|
316
|
+
if (source !== null && source.trimEnd() === heartbeatIdentity) {
|
|
317
|
+
try {
|
|
318
|
+
const status = await lstat(heartbeatPath);
|
|
319
|
+
const ageMs = Date.now() - status.mtimeMs;
|
|
320
|
+
if (ageMs >= -HEARTBEAT_MAX_AGE_MS &&
|
|
321
|
+
ageMs <= HEARTBEAT_MAX_AGE_MS) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (!isMissing(error)) {
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (record.processIdentity.startsWith("runtime:")) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const identity = await readProcessIdentity(record.processId);
|
|
335
|
+
return identity === null || identity === record.processIdentity;
|
|
336
|
+
}
|
|
337
|
+
function privateStateError(path, cause) {
|
|
338
|
+
return new AgentOpsError("PRIVATE_STATE_PATH_INVALID", `Private state path is outside its anchor or contains a symlink: ${path}`, { cause });
|
|
339
|
+
}
|
|
340
|
+
function containedSegments(path, anchorDirectory) {
|
|
341
|
+
const anchor = resolve(anchorDirectory);
|
|
342
|
+
const candidate = resolve(path);
|
|
343
|
+
const fromAnchor = relative(anchor, candidate);
|
|
344
|
+
if (!isAbsolute(anchorDirectory) ||
|
|
345
|
+
!isAbsolute(path) ||
|
|
346
|
+
fromAnchor === ".." ||
|
|
347
|
+
fromAnchor.startsWith(`..${sep}`) ||
|
|
348
|
+
isAbsolute(fromAnchor)) {
|
|
349
|
+
throw privateStateError(path);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
anchor,
|
|
353
|
+
segments: fromAnchor.length === 0 ? [] : fromAnchor.split(sep)
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
async function assertAnchor(anchor) {
|
|
357
|
+
let status;
|
|
358
|
+
try {
|
|
359
|
+
status = await lstat(anchor);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
throw privateStateError(anchor, error);
|
|
363
|
+
}
|
|
364
|
+
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
365
|
+
throw privateStateError(anchor);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function inspectPath(path, anchorDirectory, leafKind) {
|
|
369
|
+
const { anchor, segments } = containedSegments(path, anchorDirectory);
|
|
370
|
+
await assertAnchor(anchor);
|
|
371
|
+
let current = anchor;
|
|
372
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
373
|
+
current = join(current, segments[index]);
|
|
374
|
+
let status;
|
|
375
|
+
try {
|
|
376
|
+
status = await lstat(current);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (isMissing(error)) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
const isLeaf = index === segments.length - 1;
|
|
385
|
+
if (status.isSymbolicLink() ||
|
|
386
|
+
(!isLeaf && !status.isDirectory()) ||
|
|
387
|
+
(isLeaf &&
|
|
388
|
+
((leafKind === "directory" && !status.isDirectory()) ||
|
|
389
|
+
(leafKind === "file" && !status.isFile())))) {
|
|
390
|
+
throw privateStateError(current);
|
|
391
|
+
}
|
|
392
|
+
if (!isLeaf) {
|
|
393
|
+
await chmod(current, 0o700);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
async function syncDirectory(path) {
|
|
399
|
+
try {
|
|
400
|
+
const directory = await open(path, "r");
|
|
401
|
+
try {
|
|
402
|
+
await directory.sync();
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await directory.close();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (typeof error === "object" &&
|
|
410
|
+
error !== null &&
|
|
411
|
+
"code" in error &&
|
|
412
|
+
["EACCES", "EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(String(error.code))) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
export async function ensurePrivateDirectory(path, anchorDirectory) {
|
|
419
|
+
const { anchor, segments } = containedSegments(path, anchorDirectory);
|
|
420
|
+
await assertAnchor(anchor);
|
|
421
|
+
let current = anchor;
|
|
422
|
+
for (const segment of segments) {
|
|
423
|
+
current = join(current, segment);
|
|
424
|
+
try {
|
|
425
|
+
await mkdir(current, { mode: 0o700 });
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (!isAlreadyExists(error)) {
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const status = await lstat(current);
|
|
433
|
+
if (!status.isDirectory() || status.isSymbolicLink()) {
|
|
434
|
+
throw privateStateError(current);
|
|
435
|
+
}
|
|
436
|
+
await chmod(current, 0o700);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export async function readPrivateFile(path, anchorDirectory) {
|
|
440
|
+
const exists = await inspectPath(path, anchorDirectory, "file");
|
|
441
|
+
if (!exists) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
let handle;
|
|
445
|
+
try {
|
|
446
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
447
|
+
const status = await handle.stat();
|
|
448
|
+
if (!status.isFile()) {
|
|
449
|
+
throw privateStateError(path);
|
|
450
|
+
}
|
|
451
|
+
await handle.chmod(0o600);
|
|
452
|
+
return await handle.readFile("utf8");
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
if (isMissing(error)) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
if (typeof error === "object" &&
|
|
459
|
+
error !== null &&
|
|
460
|
+
"code" in error &&
|
|
461
|
+
["ELOOP", "EMLINK"].includes(String(error.code))) {
|
|
462
|
+
throw privateStateError(path, error);
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
await handle?.close().catch(() => undefined);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
export async function writePrivateFile(path, content, anchorDirectory) {
|
|
471
|
+
const parent = dirname(path);
|
|
472
|
+
await ensurePrivateDirectory(parent, anchorDirectory);
|
|
473
|
+
await inspectPath(path, anchorDirectory, "file");
|
|
474
|
+
const temporaryPath = join(parent, `.${basename(path)}.agent-ops-private-${randomUUID()}`);
|
|
475
|
+
let handle;
|
|
476
|
+
try {
|
|
477
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
478
|
+
await handle.writeFile(content, "utf8");
|
|
479
|
+
await handle.sync();
|
|
480
|
+
await handle.close();
|
|
481
|
+
handle = undefined;
|
|
482
|
+
await chmod(temporaryPath, 0o600);
|
|
483
|
+
await rename(temporaryPath, path);
|
|
484
|
+
await chmod(path, 0o600);
|
|
485
|
+
await syncDirectory(parent);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
await handle?.close().catch(() => undefined);
|
|
489
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function lockParticipantPrefix(path) {
|
|
494
|
+
return `.${basename(path)}.agent-ops-lock-`;
|
|
495
|
+
}
|
|
496
|
+
function lockParticipantPath(path, processId, token) {
|
|
497
|
+
return join(dirname(path), `${lockParticipantPrefix(path)}${processId}-${token}`);
|
|
498
|
+
}
|
|
499
|
+
async function removeOwnedParticipant(participantPath, anchorDirectory) {
|
|
500
|
+
const source = await readPrivateFile(participantPath, anchorDirectory);
|
|
501
|
+
if (source === null) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const record = parseLockRecord(source);
|
|
505
|
+
if (record === null) {
|
|
506
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_INVALID", `Private state lock record is invalid: ${participantPath}`);
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
await rm(participantPath);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (isMissing(error)) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async function readActiveParticipants(path, anchorDirectory, ownParticipantPath) {
|
|
519
|
+
const parent = dirname(path);
|
|
520
|
+
const prefix = lockParticipantPrefix(path);
|
|
521
|
+
const names = await readdir(parent);
|
|
522
|
+
const participants = [];
|
|
523
|
+
for (const name of names) {
|
|
524
|
+
if (!name.startsWith(prefix)) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const participantPath = join(parent, name);
|
|
528
|
+
const source = await readPrivateFile(participantPath, anchorDirectory);
|
|
529
|
+
if (source === null) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const record = parseLockRecord(source);
|
|
533
|
+
if (record === null) {
|
|
534
|
+
if (participantPath === ownParticipantPath) {
|
|
535
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_INVALID", `The current private state lock record is invalid: ${participantPath}`);
|
|
536
|
+
}
|
|
537
|
+
await rm(participantPath, { force: true });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (!await isProcessInstanceActive(record, parent, anchorDirectory)) {
|
|
541
|
+
if (participantPath !== ownParticipantPath) {
|
|
542
|
+
await removeOwnedParticipant(participantPath, anchorDirectory);
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
participants.push({ path: participantPath, record });
|
|
547
|
+
}
|
|
548
|
+
return participants;
|
|
549
|
+
}
|
|
550
|
+
async function refreshBlockers(blockers, record, parent, anchorDirectory) {
|
|
551
|
+
const refreshed = [];
|
|
552
|
+
for (const blocker of blockers) {
|
|
553
|
+
const source = await readPrivateFile(blocker.path, anchorDirectory);
|
|
554
|
+
if (source === null) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const current = parseLockRecord(source);
|
|
558
|
+
if (current === null) {
|
|
559
|
+
await rm(blocker.path, { force: true });
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (!await isProcessInstanceActive(current, parent, anchorDirectory)) {
|
|
563
|
+
await removeOwnedParticipant(blocker.path, anchorDirectory);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (current.choosing || precedes(current, record)) {
|
|
567
|
+
refreshed.push({ path: blocker.path, record: current });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return refreshed;
|
|
571
|
+
}
|
|
572
|
+
function precedes(left, right) {
|
|
573
|
+
return (left.ticket < right.ticket ||
|
|
574
|
+
(left.ticket === right.ticket && left.token < right.token));
|
|
575
|
+
}
|
|
576
|
+
export async function withPrivateFileLock(path, anchorDirectory, action) {
|
|
577
|
+
const queueKey = `${resolve(anchorDirectory)}\0${resolve(path)}`;
|
|
578
|
+
const previous = lockQueues.get(queueKey);
|
|
579
|
+
let releaseQueue = () => undefined;
|
|
580
|
+
const current = new Promise((resolveQueue) => {
|
|
581
|
+
releaseQueue = resolveQueue;
|
|
582
|
+
});
|
|
583
|
+
lockQueues.set(queueKey, current);
|
|
584
|
+
await previous;
|
|
585
|
+
try {
|
|
586
|
+
return await withTicketLock(path, anchorDirectory, action);
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
releaseQueue();
|
|
590
|
+
if (lockQueues.get(queueKey) === current) {
|
|
591
|
+
lockQueues.delete(queueKey);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async function withTicketLock(path, anchorDirectory, action) {
|
|
596
|
+
const parent = dirname(path);
|
|
597
|
+
await ensurePrivateDirectory(parent, anchorDirectory);
|
|
598
|
+
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
599
|
+
const token = randomUUID();
|
|
600
|
+
const participantPath = lockParticipantPath(path, process.pid, token);
|
|
601
|
+
const processIdentity = await readProcessIdentity(process.pid) ??
|
|
602
|
+
`runtime:${PROCESS_INSTANCE_TOKEN}`;
|
|
603
|
+
const heartbeatFile = process.platform === "win32"
|
|
604
|
+
? null
|
|
605
|
+
: await ensureProcessHeartbeat(parent, anchorDirectory);
|
|
606
|
+
if (process.platform === "win32" &&
|
|
607
|
+
!processIdentity.startsWith("windows:")) {
|
|
608
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_IDENTITY_UNAVAILABLE", "Windows process identity could not be verified.");
|
|
609
|
+
}
|
|
610
|
+
const heartbeatFailure = heartbeatFailures.get(`${resolve(anchorDirectory)}\0${resolve(parent)}`);
|
|
611
|
+
if (heartbeatFailure !== undefined) {
|
|
612
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_IDENTITY_UNAVAILABLE", "The private-state heartbeat could not be refreshed.", { cause: heartbeatFailure });
|
|
613
|
+
}
|
|
614
|
+
let record = {
|
|
615
|
+
choosing: true,
|
|
616
|
+
createdAt: new Date().toISOString(),
|
|
617
|
+
heartbeatFile,
|
|
618
|
+
processId: process.pid,
|
|
619
|
+
processIdentity,
|
|
620
|
+
ticket: 0,
|
|
621
|
+
token
|
|
622
|
+
};
|
|
623
|
+
await writePrivateFile(participantPath, `${JSON.stringify(record)}\n`, anchorDirectory);
|
|
624
|
+
try {
|
|
625
|
+
const initialParticipants = await readActiveParticipants(path, anchorDirectory, participantPath);
|
|
626
|
+
const maximumTicket = initialParticipants.reduce((maximum, participant) => Math.max(maximum, participant.record.ticket), 0);
|
|
627
|
+
if (maximumTicket >= Number.MAX_SAFE_INTEGER) {
|
|
628
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_EXHAUSTED", `Private state lock ticket space is exhausted: ${path}`);
|
|
629
|
+
}
|
|
630
|
+
record = {
|
|
631
|
+
...record,
|
|
632
|
+
choosing: false,
|
|
633
|
+
ticket: maximumTicket + 1
|
|
634
|
+
};
|
|
635
|
+
await writePrivateFile(participantPath, `${JSON.stringify(record)}\n`, anchorDirectory);
|
|
636
|
+
let blockers = (await readActiveParticipants(path, anchorDirectory, participantPath)).filter((participant) => participant.record.token !== token &&
|
|
637
|
+
(participant.record.choosing ||
|
|
638
|
+
precedes(participant.record, record)));
|
|
639
|
+
while (blockers.length > 0) {
|
|
640
|
+
blockers = await refreshBlockers(blockers, record, parent, anchorDirectory);
|
|
641
|
+
if (blockers.length === 0) {
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
if (Date.now() >= deadline) {
|
|
645
|
+
throw new AgentOpsError("PRIVATE_STATE_LOCK_TIMEOUT", `Timed out waiting for private state lock: ${path}`);
|
|
646
|
+
}
|
|
647
|
+
await delay(10);
|
|
648
|
+
}
|
|
649
|
+
return await action();
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
await removeOwnedParticipant(participantPath, anchorDirectory);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const PRIVATE_KEY_LABEL = "(?:[A-Z0-9]+ )?PRIVATE KEY";
|
|
2
|
+
const PRIVATE_KEY_BLOCK = new RegExp([
|
|
3
|
+
"-----BEGIN ",
|
|
4
|
+
`(${PRIVATE_KEY_LABEL})`,
|
|
5
|
+
"-----[\\s\\S]*?-----END \\1-----"
|
|
6
|
+
].join(""), "g");
|
|
7
|
+
const AUTHORIZATION_HEADER = new RegExp([
|
|
8
|
+
"(",
|
|
9
|
+
"Authorization",
|
|
10
|
+
"[ \\t]*:[ \\t]*)",
|
|
11
|
+
"[^\\r\\n]+"
|
|
12
|
+
].join(""), "gi");
|
|
13
|
+
const KNOWN_TOKEN = new RegExp([
|
|
14
|
+
"\\b(?:",
|
|
15
|
+
"gh[pousr]_[A-Za-z0-9]{20,}",
|
|
16
|
+
"|github_pat_[A-Za-z0-9_]{20,}",
|
|
17
|
+
"|sk-[A-Za-z0-9_-]{20,}",
|
|
18
|
+
")\\b"
|
|
19
|
+
].join(""), "g");
|
|
20
|
+
const SENSITIVE_KEY = [
|
|
21
|
+
"(?:access[_-]?token|api[_-]?key|client[_-]?secret|",
|
|
22
|
+
"password|token)"
|
|
23
|
+
].join("");
|
|
24
|
+
const QUOTED_KEY_VALUE = new RegExp([
|
|
25
|
+
`(\\b${SENSITIVE_KEY}\\b["']?[ \\t]*[:=][ \\t]*)`,
|
|
26
|
+
"([\"'])",
|
|
27
|
+
"(?:\\\\.|(?!\\2)[^\\r\\n\\\\])*",
|
|
28
|
+
"\\2"
|
|
29
|
+
].join(""), "gi");
|
|
30
|
+
const UNQUOTED_KEY_VALUE = new RegExp([
|
|
31
|
+
`(\\b${SENSITIVE_KEY}\\b[ \\t]*[:=][ \\t]*)`,
|
|
32
|
+
"([^\\s\"'&;,}]+)"
|
|
33
|
+
].join(""), "gi");
|
|
34
|
+
export function redactSecrets(value) {
|
|
35
|
+
return value
|
|
36
|
+
.replace(PRIVATE_KEY_BLOCK, "-----BEGIN $1-----\n[REDACTED_PRIVATE_KEY]\n-----END $1-----")
|
|
37
|
+
.replace(AUTHORIZATION_HEADER, "$1[REDACTED_AUTHORIZATION]")
|
|
38
|
+
.replace(KNOWN_TOKEN, "[REDACTED_TOKEN]")
|
|
39
|
+
.replace(QUOTED_KEY_VALUE, "$1$2[REDACTED_VALUE]$2")
|
|
40
|
+
.replace(UNQUOTED_KEY_VALUE, "$1[REDACTED_VALUE]");
|
|
41
|
+
}
|