@right-link/paperclip-plugin-codex-remote 0.3.1 → 0.3.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.
- package/dist/chunk-FULSN5VN.js +5348 -0
- package/dist/chunk-YJYVA7CY.js +99 -0
- package/dist/chunk-ZLN6QQMX.js +3267 -0
- package/dist/cli/index.js +190 -2
- package/dist/index.js +28 -84
- package/dist/server/adapter.js +140 -135
- package/dist/server/index.js +41 -56
- package/dist/server-utils-C4H4WJOG.js +104 -0
- package/dist/ui/index.js +318 -3
- package/package.json +7 -5
- package/dist/cli/format-event.js +0 -213
- package/dist/cli/format-event.js.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/quota-probe.js +0 -97
- package/dist/cli/quota-probe.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/server/adapter.js.map +0 -1
- package/dist/server/adapter.test.js +0 -84
- package/dist/server/adapter.test.js.map +0 -1
- package/dist/server/codex-args.js +0 -60
- package/dist/server/codex-args.js.map +0 -1
- package/dist/server/codex-args.test.js +0 -94
- package/dist/server/codex-args.test.js.map +0 -1
- package/dist/server/codex-home.js +0 -378
- package/dist/server/codex-home.js.map +0 -1
- package/dist/server/codex-home.test.js +0 -244
- package/dist/server/codex-home.test.js.map +0 -1
- package/dist/server/execute.js +0 -906
- package/dist/server/execute.js.map +0 -1
- package/dist/server/execute.remote.test.js +0 -487
- package/dist/server/execute.remote.test.js.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/server/parse.js +0 -213
- package/dist/server/parse.js.map +0 -1
- package/dist/server/parse.test.js +0 -107
- package/dist/server/parse.test.js.map +0 -1
- package/dist/server/quota-spawn-error.test.js +0 -77
- package/dist/server/quota-spawn-error.test.js.map +0 -1
- package/dist/server/quota.js +0 -432
- package/dist/server/quota.js.map +0 -1
- package/dist/server/sandbox-env.js +0 -23
- package/dist/server/sandbox-env.js.map +0 -1
- package/dist/server/skills.js +0 -24
- package/dist/server/skills.js.map +0 -1
- package/dist/server/tailscale.js +0 -95
- package/dist/server/tailscale.js.map +0 -1
- package/dist/server/test.js +0 -811
- package/dist/server/test.js.map +0 -1
- package/dist/server/test.remote.test.js +0 -257
- package/dist/server/test.remote.test.js.map +0 -1
- package/dist/ui/build-config.js +0 -113
- package/dist/ui/build-config.js.map +0 -1
- package/dist/ui/build-config.test.js +0 -49
- package/dist/ui/build-config.test.js.map +0 -1
- package/dist/ui/index.js.map +0 -1
- package/dist/ui/parse-stdout.js +0 -261
- package/dist/ui/parse-stdout.js.map +0 -1
- package/dist/ui/parse-stdout.test.js +0 -77
- package/dist/ui/parse-stdout.test.js.map +0 -1
- package/dist/ui-parser.js.map +0 -1
|
@@ -0,0 +1,3267 @@
|
|
|
1
|
+
// ../../adapter-utils/src/server-utils.ts
|
|
2
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
4
|
+
import { constants as fsConstants3, promises as fs3 } from "node:fs";
|
|
5
|
+
import os2 from "node:os";
|
|
6
|
+
import path3 from "node:path";
|
|
7
|
+
|
|
8
|
+
// ../../adapter-utils/src/remote-execution-env.ts
|
|
9
|
+
var REMOTE_EXECUTION_ENV_IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
10
|
+
"PATH",
|
|
11
|
+
"HOME",
|
|
12
|
+
"PWD",
|
|
13
|
+
"SHELL",
|
|
14
|
+
"USER",
|
|
15
|
+
"LOGNAME",
|
|
16
|
+
"NVM_DIR",
|
|
17
|
+
"TMPDIR",
|
|
18
|
+
"TMP",
|
|
19
|
+
"TEMP",
|
|
20
|
+
"XDG_CONFIG_HOME",
|
|
21
|
+
"XDG_CACHE_HOME",
|
|
22
|
+
"XDG_DATA_HOME",
|
|
23
|
+
"XDG_STATE_HOME",
|
|
24
|
+
"XDG_RUNTIME_DIR"
|
|
25
|
+
]);
|
|
26
|
+
function readEnvValueCaseInsensitive(env, key) {
|
|
27
|
+
const direct = env[key];
|
|
28
|
+
if (typeof direct === "string") return direct;
|
|
29
|
+
const upper = key.toUpperCase();
|
|
30
|
+
for (const [candidateKey, candidateValue] of Object.entries(env)) {
|
|
31
|
+
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
|
|
32
|
+
return candidateValue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
function sanitizeRemoteExecutionEnv(env, inheritedEnv = process.env) {
|
|
38
|
+
const sanitized = {};
|
|
39
|
+
for (const [key, value] of Object.entries(env)) {
|
|
40
|
+
const normalizedKey = key.toUpperCase();
|
|
41
|
+
if (!REMOTE_EXECUTION_ENV_IDENTITY_KEYS.has(normalizedKey)) {
|
|
42
|
+
sanitized[key] = value;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
|
|
46
|
+
if (typeof inheritedValue === "string" && inheritedValue === value) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
sanitized[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return sanitized;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ../../adapter-utils/src/ssh.ts
|
|
55
|
+
import { randomUUID } from "node:crypto";
|
|
56
|
+
import { execFile, spawn } from "node:child_process";
|
|
57
|
+
import { constants as fsConstants2, createReadStream as createReadStream2, createWriteStream, promises as fs2 } from "node:fs";
|
|
58
|
+
import os from "node:os";
|
|
59
|
+
import path2 from "node:path";
|
|
60
|
+
import { Transform } from "node:stream";
|
|
61
|
+
|
|
62
|
+
// ../../adapter-utils/src/workspace-restore-merge.ts
|
|
63
|
+
import { createHash } from "node:crypto";
|
|
64
|
+
import { createReadStream } from "node:fs";
|
|
65
|
+
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
66
|
+
import path from "node:path";
|
|
67
|
+
|
|
68
|
+
// ../../adapter-utils/src/exclude-patterns.ts
|
|
69
|
+
function isRelativePathOrDescendant(relative, candidate) {
|
|
70
|
+
return relative === candidate || relative.startsWith(`${candidate}/`);
|
|
71
|
+
}
|
|
72
|
+
function pathContainsSegmentOrDescendant(relative, segment) {
|
|
73
|
+
return relative === segment || relative.startsWith(`${segment}/`) || relative.endsWith(`/${segment}`) || relative.includes(`/${segment}/`);
|
|
74
|
+
}
|
|
75
|
+
function excludePatternMatches(relative, pattern) {
|
|
76
|
+
if (pattern.startsWith("*/") && pattern.endsWith("/*")) {
|
|
77
|
+
return pathContainsSegmentOrDescendant(relative, pattern.slice(2, -2));
|
|
78
|
+
}
|
|
79
|
+
if (pattern.startsWith("*/")) {
|
|
80
|
+
return pathContainsSegmentOrDescendant(relative, pattern.slice(2));
|
|
81
|
+
}
|
|
82
|
+
if (pattern.endsWith("/*")) {
|
|
83
|
+
const base = pattern.slice(0, -2);
|
|
84
|
+
return relative.startsWith(`${base}/`);
|
|
85
|
+
}
|
|
86
|
+
return isRelativePathOrDescendant(relative, pattern);
|
|
87
|
+
}
|
|
88
|
+
function shouldExcludePath(relative, exclude) {
|
|
89
|
+
return exclude.some((entry) => excludePatternMatches(relative, entry));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ../../adapter-utils/src/workspace-restore-merge.ts
|
|
93
|
+
async function hashFile(filePath) {
|
|
94
|
+
return await new Promise((resolve, reject) => {
|
|
95
|
+
const hash = createHash("sha256");
|
|
96
|
+
const stream = createReadStream(filePath);
|
|
97
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
98
|
+
stream.on("error", reject);
|
|
99
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function walkDirectory(root, exclude, relative = "", out = /* @__PURE__ */ new Map()) {
|
|
103
|
+
const current = relative ? path.join(root, relative) : root;
|
|
104
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
105
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
|
108
|
+
if (shouldExcludePath(nextRelative, exclude)) continue;
|
|
109
|
+
const fullPath = path.join(root, nextRelative);
|
|
110
|
+
const stats = await fs.lstat(fullPath);
|
|
111
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink() && !stats.isFile()) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (stats.isDirectory()) {
|
|
115
|
+
out.set(nextRelative, { kind: "dir" });
|
|
116
|
+
await walkDirectory(root, exclude, nextRelative, out);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (stats.isSymbolicLink()) {
|
|
120
|
+
out.set(nextRelative, {
|
|
121
|
+
kind: "symlink",
|
|
122
|
+
target: await fs.readlink(fullPath)
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
out.set(nextRelative, {
|
|
127
|
+
kind: "file",
|
|
128
|
+
mode: stats.mode,
|
|
129
|
+
hash: await hashFile(fullPath)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function entriesMatch(left, right) {
|
|
135
|
+
if (!left || !right) return false;
|
|
136
|
+
if (left.kind !== right.kind) return false;
|
|
137
|
+
if (left.kind === "dir") return true;
|
|
138
|
+
if (left.kind === "symlink" && right.kind === "symlink") {
|
|
139
|
+
return left.target === right.target;
|
|
140
|
+
}
|
|
141
|
+
if (left.kind === "file" && right.kind === "file") {
|
|
142
|
+
return left.mode === right.mode && left.hash === right.hash;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
async function isHolderAlive(lockDir) {
|
|
147
|
+
try {
|
|
148
|
+
const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8");
|
|
149
|
+
const owner = JSON.parse(raw);
|
|
150
|
+
const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null;
|
|
151
|
+
if (pid === null) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
process.kill(pid, 0);
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function acquireDirectoryMergeLock(lockDir) {
|
|
165
|
+
const deadline = Date.now() + 3e4;
|
|
166
|
+
while (true) {
|
|
167
|
+
try {
|
|
168
|
+
await fs.mkdir(lockDir);
|
|
169
|
+
await fs.writeFile(
|
|
170
|
+
path.join(lockDir, "owner.json"),
|
|
171
|
+
`${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
172
|
+
`,
|
|
173
|
+
"utf8"
|
|
174
|
+
);
|
|
175
|
+
return async () => {
|
|
176
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
177
|
+
};
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const code = error && typeof error === "object" ? error.code : null;
|
|
180
|
+
if (code !== "EEXIST") throw error;
|
|
181
|
+
if (!await isHolderAlive(lockDir)) {
|
|
182
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => void 0);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (Date.now() >= deadline) {
|
|
186
|
+
throw new Error(`Timed out waiting for workspace restore lock at ${lockDir}`);
|
|
187
|
+
}
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function withDirectoryMergeLock(targetDir, fn) {
|
|
193
|
+
const releaseLock = await acquireDirectoryMergeLock(`${targetDir}.paperclip-restore.lock`);
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} finally {
|
|
197
|
+
await releaseLock();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function copySnapshotEntry(sourceDir, targetDir, relative, entry) {
|
|
201
|
+
const sourcePath = path.join(sourceDir, relative);
|
|
202
|
+
const targetPath = path.join(targetDir, relative);
|
|
203
|
+
if (entry.kind === "dir") {
|
|
204
|
+
const existing = await fs.lstat(targetPath).catch(() => null);
|
|
205
|
+
if (existing?.isDirectory()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (existing) {
|
|
209
|
+
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => void 0);
|
|
210
|
+
}
|
|
211
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
215
|
+
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => void 0);
|
|
216
|
+
if (entry.kind === "symlink") {
|
|
217
|
+
await fs.symlink(entry.target, targetPath);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
|
|
221
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
222
|
+
});
|
|
223
|
+
await fs.chmod(targetPath, entry.mode);
|
|
224
|
+
}
|
|
225
|
+
async function captureDirectorySnapshot(rootDir, options = {}) {
|
|
226
|
+
const exclude = [...new Set(options.exclude ?? [])];
|
|
227
|
+
return {
|
|
228
|
+
exclude,
|
|
229
|
+
entries: await walkDirectory(rootDir, exclude)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function mergeDirectoryWithBaseline(input) {
|
|
233
|
+
const source = await captureDirectorySnapshot(input.sourceDir, { exclude: input.baseline.exclude });
|
|
234
|
+
await withDirectoryMergeLock(input.targetDir, async () => {
|
|
235
|
+
await input.beforeApply?.();
|
|
236
|
+
const current = await captureDirectorySnapshot(input.targetDir, { exclude: input.baseline.exclude });
|
|
237
|
+
const deletedLeafEntries = [...input.baseline.entries.entries()].filter(([relative, entry]) => entry.kind !== "dir" && !source.entries.has(relative)).sort(([left], [right]) => right.length - left.length);
|
|
238
|
+
for (const [relative, baselineEntry] of deletedLeafEntries) {
|
|
239
|
+
if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue;
|
|
240
|
+
await fs.rm(path.join(input.targetDir, relative), { recursive: true, force: true }).catch(() => void 0);
|
|
241
|
+
}
|
|
242
|
+
const deletedDirs = [...input.baseline.entries.entries()].filter(([relative, entry]) => entry.kind === "dir" && !source.entries.has(relative)).sort(([left], [right]) => right.length - left.length);
|
|
243
|
+
for (const [relative] of deletedDirs) {
|
|
244
|
+
await fs.rmdir(path.join(input.targetDir, relative)).catch(() => void 0);
|
|
245
|
+
}
|
|
246
|
+
const changedSourceEntries = [...source.entries.entries()].filter(([relative, entry]) => !entriesMatch(input.baseline.entries.get(relative), entry)).sort(([left], [right]) => left.localeCompare(right));
|
|
247
|
+
for (const [relative, entry] of changedSourceEntries) {
|
|
248
|
+
await copySnapshotEntry(input.sourceDir, input.targetDir, relative, entry);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ../../adapter-utils/src/runtime-progress.ts
|
|
254
|
+
var BYTES_PER_MB = 1024 * 1024;
|
|
255
|
+
function formatMb(bytes) {
|
|
256
|
+
return (Math.max(0, bytes) / BYTES_PER_MB).toFixed(1);
|
|
257
|
+
}
|
|
258
|
+
function clampPercent(value) {
|
|
259
|
+
if (!Number.isFinite(value)) return 0;
|
|
260
|
+
return Math.min(100, Math.max(0, Math.round(value)));
|
|
261
|
+
}
|
|
262
|
+
function createRuntimeProgressReporter(options) {
|
|
263
|
+
const stepPercent = options.stepPercent && options.stepPercent > 0 ? options.stepPercent : 10;
|
|
264
|
+
const minIntervalMs = options.minIntervalMs && options.minIntervalMs > 0 ? options.minIntervalMs : 2e3;
|
|
265
|
+
const now = options.now ?? Date.now;
|
|
266
|
+
const prefix = `[paperclip] ${options.phase}${options.label ? ` ${options.label}` : ""} ${options.direction} ${options.target}`;
|
|
267
|
+
let lastEmitAt = null;
|
|
268
|
+
let lastStep = -1;
|
|
269
|
+
let lastDoneBytes = 0;
|
|
270
|
+
let lastTotalBytes = null;
|
|
271
|
+
let completed = false;
|
|
272
|
+
function buildLine(doneBytes, totalBytes) {
|
|
273
|
+
if (totalBytes != null && totalBytes > 0) {
|
|
274
|
+
const pct = clampPercent(doneBytes / totalBytes * 100);
|
|
275
|
+
return `${prefix}: ${pct}% (${formatMb(doneBytes)}/${formatMb(totalBytes)} MB)
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
return `${prefix}: ${formatMb(doneBytes)} MB
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
function buildFailLine(doneBytes, totalBytes) {
|
|
282
|
+
if (totalBytes != null && totalBytes > 0) {
|
|
283
|
+
const pct = clampPercent(doneBytes / totalBytes * 100);
|
|
284
|
+
return `${prefix}: failed at ${pct}% (${formatMb(doneBytes)}/${formatMb(totalBytes)} MB)
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
return `${prefix}: failed after ${formatMb(doneBytes)} MB
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
async function emit(doneBytes, totalBytes) {
|
|
291
|
+
lastEmitAt = now();
|
|
292
|
+
if (totalBytes != null && totalBytes > 0) {
|
|
293
|
+
lastStep = Math.floor(doneBytes / totalBytes * 100 / stepPercent);
|
|
294
|
+
}
|
|
295
|
+
await options.sink(buildLine(doneBytes, totalBytes));
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
async report(doneBytes, totalBytes) {
|
|
299
|
+
lastDoneBytes = doneBytes;
|
|
300
|
+
lastTotalBytes = totalBytes;
|
|
301
|
+
if (completed) return;
|
|
302
|
+
const elapsedOk = lastEmitAt == null || now() - lastEmitAt >= minIntervalMs;
|
|
303
|
+
if (totalBytes != null && totalBytes > 0) {
|
|
304
|
+
const terminal = doneBytes >= totalBytes;
|
|
305
|
+
const step = Math.floor(doneBytes / totalBytes * 100 / stepPercent);
|
|
306
|
+
const stepOk = step > lastStep;
|
|
307
|
+
if (terminal || stepOk || elapsedOk) {
|
|
308
|
+
await emit(doneBytes, totalBytes);
|
|
309
|
+
}
|
|
310
|
+
if (terminal) completed = true;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (elapsedOk) {
|
|
314
|
+
await emit(doneBytes, totalBytes);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async complete(doneBytes, totalBytes) {
|
|
318
|
+
if (completed) return;
|
|
319
|
+
completed = true;
|
|
320
|
+
const total = totalBytes !== void 0 ? totalBytes : lastTotalBytes;
|
|
321
|
+
const done = doneBytes !== void 0 ? doneBytes : total != null && total > 0 ? total : lastDoneBytes;
|
|
322
|
+
await options.sink(buildLine(done, total));
|
|
323
|
+
},
|
|
324
|
+
async fail(doneBytes, totalBytes) {
|
|
325
|
+
if (completed) return;
|
|
326
|
+
completed = true;
|
|
327
|
+
const total = totalBytes !== void 0 ? totalBytes : lastTotalBytes;
|
|
328
|
+
const done = doneBytes !== void 0 ? doneBytes : lastDoneBytes;
|
|
329
|
+
await options.sink(buildFailLine(done, total));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ../../adapter-utils/src/ssh.ts
|
|
335
|
+
function createSshCommandManagedRuntimeRunner(input) {
|
|
336
|
+
const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
|
|
337
|
+
const maxBufferBytes = typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0 ? Math.trunc(input.maxBufferBytes) : 1024 * 1024;
|
|
338
|
+
return {
|
|
339
|
+
execute: async (commandInput) => {
|
|
340
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
341
|
+
const command = commandInput.command.trim();
|
|
342
|
+
const args = commandInput.args ?? [];
|
|
343
|
+
const cwd = commandInput.cwd?.trim() || defaultCwd;
|
|
344
|
+
const envEntries = Object.entries(commandInput.env ?? {}).filter((entry) => typeof entry[1] === "string");
|
|
345
|
+
const envPrefix = envEntries.length > 0 ? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` : "";
|
|
346
|
+
const exportPrefix = envEntries.length > 0 ? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " " : "";
|
|
347
|
+
const commandScript = command === "sh" || command === "bash" ? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string" ? `${exportPrefix}${args[1]}` : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}` : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
|
|
348
|
+
const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`;
|
|
349
|
+
try {
|
|
350
|
+
const result = await runSshCommand(input.spec, remoteCommand, {
|
|
351
|
+
stdin: commandInput.stdin,
|
|
352
|
+
timeoutMs: commandInput.timeoutMs,
|
|
353
|
+
maxBuffer: maxBufferBytes
|
|
354
|
+
});
|
|
355
|
+
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
|
|
356
|
+
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
|
|
357
|
+
return {
|
|
358
|
+
exitCode: 0,
|
|
359
|
+
signal: null,
|
|
360
|
+
timedOut: false,
|
|
361
|
+
stdout: result.stdout,
|
|
362
|
+
stderr: result.stderr,
|
|
363
|
+
pid: null,
|
|
364
|
+
startedAt
|
|
365
|
+
};
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const failure = error;
|
|
368
|
+
const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
|
|
369
|
+
const stderr = typeof failure.stderr === "string" ? failure.stderr : error instanceof Error ? error.message : String(error);
|
|
370
|
+
if (stdout) await commandInput.onLog?.("stdout", stdout);
|
|
371
|
+
if (stderr) await commandInput.onLog?.("stderr", stderr);
|
|
372
|
+
return {
|
|
373
|
+
exitCode: typeof failure.code === "number" ? failure.code : null,
|
|
374
|
+
signal: typeof failure.signal === "string" ? failure.signal : null,
|
|
375
|
+
timedOut: failure.killed === true,
|
|
376
|
+
stdout,
|
|
377
|
+
stderr,
|
|
378
|
+
pid: null,
|
|
379
|
+
startedAt
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function shellQuote(value) {
|
|
386
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
387
|
+
}
|
|
388
|
+
function isValidShellEnvKey(value) {
|
|
389
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
390
|
+
}
|
|
391
|
+
function parseSshRemoteExecutionSpec(value) {
|
|
392
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const parsed = value;
|
|
396
|
+
const host = typeof parsed.host === "string" ? parsed.host.trim() : "";
|
|
397
|
+
const username = typeof parsed.username === "string" ? parsed.username.trim() : "";
|
|
398
|
+
const remoteCwd = typeof parsed.remoteCwd === "string" ? parsed.remoteCwd.trim() : "";
|
|
399
|
+
const portValue = typeof parsed.port === "number" ? parsed.port : Number(parsed.port);
|
|
400
|
+
if (!host || !username || !remoteCwd || !Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
host,
|
|
405
|
+
port: portValue,
|
|
406
|
+
username,
|
|
407
|
+
remoteCwd,
|
|
408
|
+
remoteWorkspacePath: typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0 ? parsed.remoteWorkspacePath.trim() : remoteCwd,
|
|
409
|
+
privateKey: typeof parsed.privateKey === "string" && parsed.privateKey.length > 0 ? parsed.privateKey : null,
|
|
410
|
+
knownHosts: typeof parsed.knownHosts === "string" && parsed.knownHosts.length > 0 ? parsed.knownHosts : null,
|
|
411
|
+
strictHostKeyChecking: typeof parsed.strictHostKeyChecking === "boolean" ? parsed.strictHostKeyChecking : true
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
async function execFileText(file, args, options = {}) {
|
|
415
|
+
return await new Promise((resolve, reject) => {
|
|
416
|
+
execFile(
|
|
417
|
+
file,
|
|
418
|
+
args,
|
|
419
|
+
{
|
|
420
|
+
timeout: options.timeout ?? 15e3,
|
|
421
|
+
maxBuffer: options.maxBuffer ?? 1024 * 128
|
|
422
|
+
},
|
|
423
|
+
(error, stdout, stderr) => {
|
|
424
|
+
if (error) {
|
|
425
|
+
reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
resolve({
|
|
429
|
+
stdout: stdout ?? "",
|
|
430
|
+
stderr: stderr ?? ""
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async function spawnText(file, args, options = {}) {
|
|
437
|
+
return await new Promise((resolve, reject) => {
|
|
438
|
+
const child = spawn(file, args, {
|
|
439
|
+
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"]
|
|
440
|
+
});
|
|
441
|
+
const maxBuffer = options.maxBuffer ?? 1024 * 128;
|
|
442
|
+
let stdout = "";
|
|
443
|
+
let stderr = "";
|
|
444
|
+
let settled = false;
|
|
445
|
+
let timedOut = false;
|
|
446
|
+
const finishReject = (error) => {
|
|
447
|
+
if (settled) return;
|
|
448
|
+
settled = true;
|
|
449
|
+
error.stdout = stdout;
|
|
450
|
+
error.stderr = stderr;
|
|
451
|
+
error.killed = timedOut;
|
|
452
|
+
reject(error);
|
|
453
|
+
};
|
|
454
|
+
const append = (streamName, chunk) => {
|
|
455
|
+
const text = String(chunk);
|
|
456
|
+
if (streamName === "stdout") {
|
|
457
|
+
stdout += text;
|
|
458
|
+
} else {
|
|
459
|
+
stderr += text;
|
|
460
|
+
}
|
|
461
|
+
if (Buffer.byteLength(stdout, "utf8") > maxBuffer || Buffer.byteLength(stderr, "utf8") > maxBuffer) {
|
|
462
|
+
child.kill("SIGTERM");
|
|
463
|
+
finishReject(Object.assign(new Error(`Process output exceeded maxBuffer of ${maxBuffer} bytes.`), {
|
|
464
|
+
code: null
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
let killEscalation = null;
|
|
469
|
+
const timeout = options.timeout && options.timeout > 0 ? setTimeout(() => {
|
|
470
|
+
timedOut = true;
|
|
471
|
+
child.kill("SIGTERM");
|
|
472
|
+
killEscalation = setTimeout(() => {
|
|
473
|
+
try {
|
|
474
|
+
child.kill("SIGKILL");
|
|
475
|
+
} catch {
|
|
476
|
+
}
|
|
477
|
+
}, 5e3);
|
|
478
|
+
killEscalation.unref?.();
|
|
479
|
+
}, options.timeout) : null;
|
|
480
|
+
const clearTimers = () => {
|
|
481
|
+
if (timeout) clearTimeout(timeout);
|
|
482
|
+
if (killEscalation) clearTimeout(killEscalation);
|
|
483
|
+
};
|
|
484
|
+
child.stdout?.on("data", (chunk) => {
|
|
485
|
+
append("stdout", chunk);
|
|
486
|
+
});
|
|
487
|
+
child.stderr?.on("data", (chunk) => {
|
|
488
|
+
append("stderr", chunk);
|
|
489
|
+
});
|
|
490
|
+
child.on("error", (error) => {
|
|
491
|
+
clearTimers();
|
|
492
|
+
finishReject(Object.assign(error, { code: null }));
|
|
493
|
+
});
|
|
494
|
+
child.on("close", (code, signal) => {
|
|
495
|
+
clearTimers();
|
|
496
|
+
if (settled) return;
|
|
497
|
+
settled = true;
|
|
498
|
+
if (code === 0) {
|
|
499
|
+
resolve({ stdout, stderr });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
|
|
503
|
+
stdout,
|
|
504
|
+
stderr,
|
|
505
|
+
code,
|
|
506
|
+
signal,
|
|
507
|
+
killed: timedOut
|
|
508
|
+
}));
|
|
509
|
+
});
|
|
510
|
+
if (options.stdin != null && child.stdin) {
|
|
511
|
+
child.stdin.end(options.stdin);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
async function runLocalGit(localDir, args, options = {}) {
|
|
516
|
+
return await execFileText("git", ["-C", localDir, ...args], options);
|
|
517
|
+
}
|
|
518
|
+
async function withTempFile(prefix, contents, mode) {
|
|
519
|
+
const dir = await fs2.mkdtemp(path2.join(os.tmpdir(), prefix));
|
|
520
|
+
const filePath = path2.join(dir, "payload");
|
|
521
|
+
const normalizedContents = contents.endsWith("\n") ? contents : `${contents}
|
|
522
|
+
`;
|
|
523
|
+
await fs2.writeFile(filePath, normalizedContents, { mode, encoding: "utf8" });
|
|
524
|
+
return {
|
|
525
|
+
path: filePath,
|
|
526
|
+
cleanup: async () => {
|
|
527
|
+
await fs2.rm(dir, { recursive: true, force: true }).catch(() => void 0);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async function createSshAuthArgs(config) {
|
|
532
|
+
const tempFiles = [];
|
|
533
|
+
const sshArgs = [
|
|
534
|
+
"-o",
|
|
535
|
+
"BatchMode=yes",
|
|
536
|
+
"-o",
|
|
537
|
+
"ConnectTimeout=10",
|
|
538
|
+
"-o",
|
|
539
|
+
`StrictHostKeyChecking=${config.strictHostKeyChecking ? "yes" : "no"}`
|
|
540
|
+
];
|
|
541
|
+
if (config.strictHostKeyChecking) {
|
|
542
|
+
if (config.knownHosts) {
|
|
543
|
+
const knownHosts = await withTempFile("paperclip-ssh-known-hosts-", config.knownHosts, 384);
|
|
544
|
+
tempFiles.push(knownHosts.cleanup);
|
|
545
|
+
sshArgs.push("-o", `UserKnownHostsFile=${knownHosts.path}`);
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
sshArgs.push("-o", "UserKnownHostsFile=/dev/null");
|
|
549
|
+
}
|
|
550
|
+
if (config.privateKey) {
|
|
551
|
+
const privateKey = await withTempFile("paperclip-ssh-key-", config.privateKey, 384);
|
|
552
|
+
tempFiles.push(privateKey.cleanup);
|
|
553
|
+
sshArgs.push("-i", privateKey.path);
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
args: sshArgs,
|
|
557
|
+
cleanup: async () => {
|
|
558
|
+
await Promise.all(tempFiles.map((cleanup) => cleanup()));
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function tarExcludeArgs(exclude) {
|
|
563
|
+
const combined = ["._*", ...exclude ?? []];
|
|
564
|
+
return combined.flatMap((entry) => ["--exclude", entry]);
|
|
565
|
+
}
|
|
566
|
+
function tarSpawnEnv() {
|
|
567
|
+
return {
|
|
568
|
+
...process.env,
|
|
569
|
+
// Prevent macOS bsdtar from emitting AppleDouble metadata files like ._README.md.
|
|
570
|
+
COPYFILE_DISABLE: "1"
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function tarPatternToRegExp(pattern) {
|
|
574
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
575
|
+
return new RegExp(`^${escaped}$`);
|
|
576
|
+
}
|
|
577
|
+
async function estimateLocalDirSize(input) {
|
|
578
|
+
const regexes = ["._*", ...input.exclude ?? []].map(tarPatternToRegExp);
|
|
579
|
+
const isExcluded = (relPath, base) => regexes.some((regex) => regex.test(relPath) || regex.test(base));
|
|
580
|
+
let total = 0;
|
|
581
|
+
const walk = async (dir, relative) => {
|
|
582
|
+
const entries = await fs2.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
583
|
+
for (const entry of entries) {
|
|
584
|
+
const entryRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
585
|
+
if (isExcluded(entryRelative, entry.name)) continue;
|
|
586
|
+
const full = path2.join(dir, entry.name);
|
|
587
|
+
const stats = await (input.followSymlinks ? fs2.stat(full) : fs2.lstat(full)).catch(() => null);
|
|
588
|
+
if (!stats) continue;
|
|
589
|
+
if (stats.isDirectory()) {
|
|
590
|
+
await walk(full, entryRelative);
|
|
591
|
+
} else if (stats.isFile()) {
|
|
592
|
+
total += stats.size;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
await walk(input.localDir, "");
|
|
597
|
+
return total;
|
|
598
|
+
}
|
|
599
|
+
async function probeRemoteDirSize(input) {
|
|
600
|
+
try {
|
|
601
|
+
const result = await runSshScript(
|
|
602
|
+
input.spec,
|
|
603
|
+
`du -sk ${shellQuote(input.remoteDir)} 2>/dev/null | cut -f1`,
|
|
604
|
+
{ timeoutMs: 15e3, maxBuffer: 16 * 1024 }
|
|
605
|
+
);
|
|
606
|
+
const kilobytes = Number.parseInt(result.stdout.trim(), 10);
|
|
607
|
+
return Number.isFinite(kilobytes) && kilobytes > 0 ? kilobytes * 1024 : null;
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function createTransferProgress(input) {
|
|
613
|
+
const reporter = createRuntimeProgressReporter({
|
|
614
|
+
sink: input.onProgress,
|
|
615
|
+
phase: input.phase,
|
|
616
|
+
direction: input.direction,
|
|
617
|
+
label: input.label,
|
|
618
|
+
target: "ssh"
|
|
619
|
+
});
|
|
620
|
+
let total = null;
|
|
621
|
+
let cap = null;
|
|
622
|
+
const applyTotal = (value) => {
|
|
623
|
+
total = value != null && value > 0 ? value : null;
|
|
624
|
+
cap = total != null && input.estimated ? Math.floor(total * 0.99) : null;
|
|
625
|
+
};
|
|
626
|
+
const totalReady = input.totalBytes != null && typeof input.totalBytes.then === "function" ? input.totalBytes.then(applyTotal, () => applyTotal(null)) : (applyTotal(input.totalBytes), Promise.resolve());
|
|
627
|
+
let transferred = 0;
|
|
628
|
+
let chain = Promise.resolve();
|
|
629
|
+
const enqueue = (work) => {
|
|
630
|
+
chain = chain.then(work).catch(() => void 0);
|
|
631
|
+
};
|
|
632
|
+
const counter = new Transform({
|
|
633
|
+
transform(chunk, _encoding, callback) {
|
|
634
|
+
transferred += chunk.length;
|
|
635
|
+
const reported = cap != null ? Math.min(transferred, cap) : transferred;
|
|
636
|
+
const totalSnapshot = total;
|
|
637
|
+
enqueue(() => reporter.report(reported, totalSnapshot));
|
|
638
|
+
callback(null, chunk);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
counter,
|
|
643
|
+
transferred: () => transferred,
|
|
644
|
+
finish: async () => {
|
|
645
|
+
await chain.catch(() => void 0);
|
|
646
|
+
await totalReady.catch(() => void 0);
|
|
647
|
+
await reporter.complete(total != null ? total : transferred, total).catch(() => void 0);
|
|
648
|
+
},
|
|
649
|
+
fail: async () => {
|
|
650
|
+
await chain.catch(() => void 0);
|
|
651
|
+
await reporter.fail(transferred, total).catch(() => void 0);
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
async function runSshScript(config, script, options = {}) {
|
|
656
|
+
return await runSshCommand(
|
|
657
|
+
config,
|
|
658
|
+
script,
|
|
659
|
+
options
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
async function clearLocalDirectory(localDir, preserveEntries = []) {
|
|
663
|
+
await fs2.mkdir(localDir, { recursive: true });
|
|
664
|
+
const preserve = new Set(preserveEntries);
|
|
665
|
+
const entries = await fs2.readdir(localDir);
|
|
666
|
+
await Promise.all(
|
|
667
|
+
entries.filter((entry) => !preserve.has(entry)).map((entry) => fs2.rm(path2.join(localDir, entry), { recursive: true, force: true }))
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
async function copyDirectoryContents(sourceDir, targetDir) {
|
|
671
|
+
await fs2.mkdir(targetDir, { recursive: true });
|
|
672
|
+
const entries = await fs2.readdir(sourceDir);
|
|
673
|
+
await Promise.all(entries.map(async (entry) => {
|
|
674
|
+
await fs2.cp(path2.join(sourceDir, entry), path2.join(targetDir, entry), {
|
|
675
|
+
recursive: true,
|
|
676
|
+
force: true,
|
|
677
|
+
preserveTimestamps: true
|
|
678
|
+
});
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
async function readLocalGitWorkspaceSnapshot(localDir) {
|
|
682
|
+
try {
|
|
683
|
+
const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
|
|
684
|
+
timeout: 1e4,
|
|
685
|
+
maxBuffer: 16 * 1024
|
|
686
|
+
});
|
|
687
|
+
if (insideWorkTree.stdout.trim() !== "true") {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const [headCommitResult, branchResult, deletedResult] = await Promise.all([
|
|
691
|
+
runLocalGit(localDir, ["rev-parse", "HEAD"], {
|
|
692
|
+
timeout: 1e4,
|
|
693
|
+
maxBuffer: 16 * 1024
|
|
694
|
+
}),
|
|
695
|
+
runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
696
|
+
timeout: 1e4,
|
|
697
|
+
maxBuffer: 16 * 1024
|
|
698
|
+
}),
|
|
699
|
+
runLocalGit(localDir, ["ls-files", "--deleted", "-z"], {
|
|
700
|
+
timeout: 1e4,
|
|
701
|
+
maxBuffer: 256 * 1024
|
|
702
|
+
})
|
|
703
|
+
]);
|
|
704
|
+
const branchName = branchResult.stdout.trim();
|
|
705
|
+
return {
|
|
706
|
+
headCommit: headCommitResult.stdout.trim(),
|
|
707
|
+
branchName: branchName && branchName !== "HEAD" ? branchName : null,
|
|
708
|
+
deletedPaths: deletedResult.stdout.split("\0").map((entry) => entry.trim()).filter(Boolean)
|
|
709
|
+
};
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async function streamLocalFileToSsh(input) {
|
|
715
|
+
const auth = await createSshAuthArgs(input.spec);
|
|
716
|
+
const sshArgs = [
|
|
717
|
+
...auth.args,
|
|
718
|
+
"-p",
|
|
719
|
+
String(input.spec.port),
|
|
720
|
+
`${input.spec.username}@${input.spec.host}`,
|
|
721
|
+
`sh -c ${shellQuote(input.remoteScript)}`
|
|
722
|
+
];
|
|
723
|
+
await new Promise((resolve, reject) => {
|
|
724
|
+
const source = createReadStream2(input.localFile);
|
|
725
|
+
const ssh = spawn("ssh", sshArgs, {
|
|
726
|
+
stdio: ["pipe", "ignore", "pipe"]
|
|
727
|
+
});
|
|
728
|
+
let sshStderr = "";
|
|
729
|
+
let settled = false;
|
|
730
|
+
const fail = (error) => {
|
|
731
|
+
if (settled) return;
|
|
732
|
+
settled = true;
|
|
733
|
+
source.destroy();
|
|
734
|
+
ssh.kill("SIGTERM");
|
|
735
|
+
reject(error);
|
|
736
|
+
};
|
|
737
|
+
ssh.stderr?.on("data", (chunk) => {
|
|
738
|
+
sshStderr += String(chunk);
|
|
739
|
+
});
|
|
740
|
+
source.on("error", fail);
|
|
741
|
+
ssh.on("error", fail);
|
|
742
|
+
if (input.progress) {
|
|
743
|
+
input.progress.counter.on("error", fail);
|
|
744
|
+
source.pipe(input.progress.counter).pipe(ssh.stdin ?? null);
|
|
745
|
+
} else {
|
|
746
|
+
source.pipe(ssh.stdin ?? null);
|
|
747
|
+
}
|
|
748
|
+
ssh.on("close", (code) => {
|
|
749
|
+
if (settled) return;
|
|
750
|
+
settled = true;
|
|
751
|
+
if ((code ?? 0) !== 0) {
|
|
752
|
+
reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
resolve();
|
|
756
|
+
});
|
|
757
|
+
}).finally(auth.cleanup);
|
|
758
|
+
}
|
|
759
|
+
async function streamSshToLocalFile(input) {
|
|
760
|
+
const auth = await createSshAuthArgs(input.spec);
|
|
761
|
+
const sshArgs = [
|
|
762
|
+
...auth.args,
|
|
763
|
+
"-p",
|
|
764
|
+
String(input.spec.port),
|
|
765
|
+
`${input.spec.username}@${input.spec.host}`,
|
|
766
|
+
`sh -c ${shellQuote(input.remoteScript)}`
|
|
767
|
+
];
|
|
768
|
+
await new Promise((resolve, reject) => {
|
|
769
|
+
const ssh = spawn("ssh", sshArgs, {
|
|
770
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
771
|
+
});
|
|
772
|
+
const sink = createWriteStream(input.localFile, { mode: 384 });
|
|
773
|
+
let sshStderr = "";
|
|
774
|
+
let settled = false;
|
|
775
|
+
const fail = (error) => {
|
|
776
|
+
if (settled) return;
|
|
777
|
+
settled = true;
|
|
778
|
+
ssh.kill("SIGTERM");
|
|
779
|
+
sink.destroy();
|
|
780
|
+
reject(error);
|
|
781
|
+
};
|
|
782
|
+
if (input.progress) {
|
|
783
|
+
input.progress.counter.on("error", fail);
|
|
784
|
+
ssh.stdout?.pipe(input.progress.counter).pipe(sink);
|
|
785
|
+
} else {
|
|
786
|
+
ssh.stdout?.pipe(sink);
|
|
787
|
+
}
|
|
788
|
+
ssh.stderr?.on("data", (chunk) => {
|
|
789
|
+
sshStderr += String(chunk);
|
|
790
|
+
});
|
|
791
|
+
ssh.on("error", fail);
|
|
792
|
+
sink.on("error", fail);
|
|
793
|
+
ssh.on("close", (code) => {
|
|
794
|
+
sink.end(() => {
|
|
795
|
+
if (settled) return;
|
|
796
|
+
settled = true;
|
|
797
|
+
if ((code ?? 0) !== 0) {
|
|
798
|
+
reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
resolve();
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
}).finally(auth.cleanup);
|
|
805
|
+
}
|
|
806
|
+
async function importGitWorkspaceToSsh(input) {
|
|
807
|
+
const bundleDir = await fs2.mkdtemp(path2.join(os.tmpdir(), "paperclip-ssh-bundle-"));
|
|
808
|
+
const bundlePath = path2.join(bundleDir, "workspace.bundle");
|
|
809
|
+
const tempRef = `refs/paperclip/ssh-sync/import/${randomUUID()}`;
|
|
810
|
+
try {
|
|
811
|
+
await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
|
|
812
|
+
timeout: 1e4,
|
|
813
|
+
maxBuffer: 16 * 1024
|
|
814
|
+
});
|
|
815
|
+
await runLocalGit(input.localDir, ["bundle", "create", bundlePath, tempRef], {
|
|
816
|
+
timeout: 6e4,
|
|
817
|
+
maxBuffer: 1024 * 1024
|
|
818
|
+
});
|
|
819
|
+
const remoteSetupScript = [
|
|
820
|
+
"set -e",
|
|
821
|
+
`mkdir -p ${shellQuote(path2.posix.join(input.remoteDir, ".paperclip-runtime"))}`,
|
|
822
|
+
`tmp_bundle=$(mktemp ${shellQuote(path2.posix.join(input.remoteDir, ".paperclip-runtime", "import-XXXXXX.bundle"))})`,
|
|
823
|
+
`trap 'rm -f "$tmp_bundle"' EXIT`,
|
|
824
|
+
'cat > "$tmp_bundle"',
|
|
825
|
+
`if [ ! -d ${shellQuote(path2.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`,
|
|
826
|
+
`git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`,
|
|
827
|
+
input.snapshot.branchName ? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null` : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
|
|
828
|
+
`git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
|
|
829
|
+
`git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`,
|
|
830
|
+
// Drop the per-import ref on the remote side too so it can't accumulate.
|
|
831
|
+
`git -C ${shellQuote(input.remoteDir)} update-ref -d ${shellQuote(tempRef)} >/dev/null 2>&1 || true`
|
|
832
|
+
].join("\n");
|
|
833
|
+
const progress = input.onProgress ? createTransferProgress({
|
|
834
|
+
onProgress: input.onProgress,
|
|
835
|
+
phase: "Importing git history",
|
|
836
|
+
direction: "to",
|
|
837
|
+
totalBytes: (await fs2.stat(bundlePath)).size,
|
|
838
|
+
estimated: false
|
|
839
|
+
}) : null;
|
|
840
|
+
try {
|
|
841
|
+
await streamLocalFileToSsh({
|
|
842
|
+
spec: input.spec,
|
|
843
|
+
localFile: bundlePath,
|
|
844
|
+
remoteScript: remoteSetupScript,
|
|
845
|
+
progress: progress ?? void 0
|
|
846
|
+
});
|
|
847
|
+
await progress?.finish();
|
|
848
|
+
} catch (error) {
|
|
849
|
+
await progress?.fail();
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
} finally {
|
|
853
|
+
await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], {
|
|
854
|
+
timeout: 1e4,
|
|
855
|
+
maxBuffer: 16 * 1024
|
|
856
|
+
}).catch(() => void 0);
|
|
857
|
+
await fs2.rm(bundleDir, { recursive: true, force: true }).catch(() => void 0);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
async function exportGitWorkspaceFromSsh(input) {
|
|
861
|
+
const bundleDir = await fs2.mkdtemp(path2.join(os.tmpdir(), "paperclip-ssh-bundle-"));
|
|
862
|
+
const bundlePath = path2.join(bundleDir, "workspace.bundle");
|
|
863
|
+
const importedRef = input.importedRef ?? `refs/paperclip/ssh-sync/imported/${randomUUID()}`;
|
|
864
|
+
try {
|
|
865
|
+
const exportScript = [
|
|
866
|
+
"set -e",
|
|
867
|
+
`git -C ${shellQuote(input.remoteDir)} update-ref refs/paperclip/ssh-sync/export HEAD`,
|
|
868
|
+
`mkdir -p ${shellQuote(path2.posix.join(input.remoteDir, ".paperclip-runtime"))}`,
|
|
869
|
+
`tmp_bundle=$(mktemp ${shellQuote(path2.posix.join(input.remoteDir, ".paperclip-runtime", "export-XXXXXX.bundle"))})`,
|
|
870
|
+
'cleanup() { rm -f "$tmp_bundle"; git -C ' + shellQuote(input.remoteDir) + " update-ref -d refs/paperclip/ssh-sync/export >/dev/null 2>&1 || true; }",
|
|
871
|
+
"trap cleanup EXIT",
|
|
872
|
+
`git -C ${shellQuote(input.remoteDir)} bundle create "$tmp_bundle" refs/paperclip/ssh-sync/export >/dev/null`,
|
|
873
|
+
'cat "$tmp_bundle"'
|
|
874
|
+
].join("\n");
|
|
875
|
+
const progress = input.onProgress ? createTransferProgress({
|
|
876
|
+
onProgress: input.onProgress,
|
|
877
|
+
phase: "Exporting git history",
|
|
878
|
+
direction: "from",
|
|
879
|
+
totalBytes: null,
|
|
880
|
+
estimated: false
|
|
881
|
+
}) : null;
|
|
882
|
+
try {
|
|
883
|
+
await streamSshToLocalFile({
|
|
884
|
+
spec: input.spec,
|
|
885
|
+
remoteScript: exportScript,
|
|
886
|
+
localFile: bundlePath,
|
|
887
|
+
progress: progress ?? void 0
|
|
888
|
+
});
|
|
889
|
+
await progress?.finish();
|
|
890
|
+
} catch (error) {
|
|
891
|
+
await progress?.fail();
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
await runLocalGit(input.localDir, ["fetch", "--force", bundlePath, `refs/paperclip/ssh-sync/export:${importedRef}`], {
|
|
895
|
+
timeout: 6e4,
|
|
896
|
+
maxBuffer: 1024 * 1024
|
|
897
|
+
});
|
|
898
|
+
if (input.resetLocalWorkspace !== false) {
|
|
899
|
+
await runLocalGit(input.localDir, ["reset", "--hard", importedRef], {
|
|
900
|
+
timeout: 6e4,
|
|
901
|
+
maxBuffer: 1024 * 1024
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const importedHead = await runLocalGit(input.localDir, ["rev-parse", importedRef], {
|
|
905
|
+
timeout: 1e4,
|
|
906
|
+
maxBuffer: 16 * 1024
|
|
907
|
+
});
|
|
908
|
+
return importedHead.stdout.trim();
|
|
909
|
+
} finally {
|
|
910
|
+
if (input.resetLocalWorkspace !== false) {
|
|
911
|
+
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
|
|
912
|
+
timeout: 1e4,
|
|
913
|
+
maxBuffer: 16 * 1024
|
|
914
|
+
}).catch(() => void 0);
|
|
915
|
+
}
|
|
916
|
+
await fs2.rm(bundleDir, { recursive: true, force: true }).catch(() => void 0);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
async function integrateImportedGitHead(input) {
|
|
920
|
+
const isConcurrentRefUpdateError = (error) => {
|
|
921
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
922
|
+
return message.includes("cannot lock ref") && message.includes("expected");
|
|
923
|
+
};
|
|
924
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
925
|
+
const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
|
|
926
|
+
if (!snapshot) return;
|
|
927
|
+
const currentHead = snapshot.headCommit;
|
|
928
|
+
if (!currentHead || currentHead === input.importedHead) return;
|
|
929
|
+
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
|
|
930
|
+
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
|
|
931
|
+
timeout: 1e4,
|
|
932
|
+
maxBuffer: 16 * 1024
|
|
933
|
+
}).catch(() => null);
|
|
934
|
+
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
|
|
935
|
+
if (mergeBaseHead === input.importedHead) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (mergeBaseHead === currentHead) {
|
|
939
|
+
try {
|
|
940
|
+
await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
|
|
941
|
+
timeout: 1e4,
|
|
942
|
+
maxBuffer: 16 * 1024
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
947
|
+
throw error;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
let mergedTree;
|
|
951
|
+
try {
|
|
952
|
+
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
|
|
953
|
+
timeout: 6e4,
|
|
954
|
+
maxBuffer: 256 * 1024
|
|
955
|
+
});
|
|
956
|
+
} catch (error) {
|
|
957
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
958
|
+
throw new Error(
|
|
959
|
+
`Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
963
|
+
if (!mergedTreeId) {
|
|
964
|
+
throw new Error("Failed to compute a merged git tree for SSH workspace restore.");
|
|
965
|
+
}
|
|
966
|
+
const mergeCommit = await runLocalGit(
|
|
967
|
+
input.localDir,
|
|
968
|
+
[
|
|
969
|
+
"commit-tree",
|
|
970
|
+
mergedTreeId,
|
|
971
|
+
"-p",
|
|
972
|
+
currentHead,
|
|
973
|
+
"-p",
|
|
974
|
+
input.importedHead,
|
|
975
|
+
"-m",
|
|
976
|
+
`Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`
|
|
977
|
+
],
|
|
978
|
+
{
|
|
979
|
+
timeout: 6e4,
|
|
980
|
+
maxBuffer: 64 * 1024
|
|
981
|
+
}
|
|
982
|
+
);
|
|
983
|
+
try {
|
|
984
|
+
await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
|
|
985
|
+
timeout: 1e4,
|
|
986
|
+
maxBuffer: 16 * 1024
|
|
987
|
+
});
|
|
988
|
+
return;
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
throw new Error(`Failed to integrate concurrent SSH git history for ${input.importedHead.slice(0, 12)} after multiple retries.`);
|
|
995
|
+
}
|
|
996
|
+
async function clearRemoteDirectory(input) {
|
|
997
|
+
const preservePatterns = (input.preserveEntries ?? []).map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
|
998
|
+
const script = [
|
|
999
|
+
"set -e",
|
|
1000
|
+
`mkdir -p ${shellQuote(input.remoteDir)}`,
|
|
1001
|
+
`find ${shellQuote(input.remoteDir)} -mindepth 1 -maxdepth 1 ${preservePatterns} -exec rm -rf -- {} +`
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
await runSshScript(input.spec, script, {
|
|
1004
|
+
timeoutMs: 3e4,
|
|
1005
|
+
maxBuffer: 256 * 1024
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
async function removeDeletedPathsOnSsh(input) {
|
|
1009
|
+
if (input.deletedPaths.length === 0) return;
|
|
1010
|
+
const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" ");
|
|
1011
|
+
const script = `cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`;
|
|
1012
|
+
await runSshScript(input.spec, script, {
|
|
1013
|
+
timeoutMs: 3e4,
|
|
1014
|
+
maxBuffer: 256 * 1024
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
async function runSshCommand(config, remoteCommand, options = {}) {
|
|
1018
|
+
let cleanup = () => Promise.resolve();
|
|
1019
|
+
try {
|
|
1020
|
+
const auth = await createSshAuthArgs(config);
|
|
1021
|
+
cleanup = auth.cleanup;
|
|
1022
|
+
const sshArgs = [...auth.args];
|
|
1023
|
+
const envEntries = Object.entries(options.env ?? {}).filter((entry) => typeof entry[1] === "string");
|
|
1024
|
+
for (const [key] of envEntries) {
|
|
1025
|
+
if (!isValidShellEnvKey(key)) {
|
|
1026
|
+
throw new Error(`Invalid SSH environment variable key: ${key}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
1030
|
+
const remoteScript = [
|
|
1031
|
+
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
|
1032
|
+
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
|
|
1033
|
+
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
|
1034
|
+
envArgs.length > 0 ? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}` : `exec sh -c ${shellQuote(remoteCommand)}`
|
|
1035
|
+
].join(" && ");
|
|
1036
|
+
sshArgs.push(
|
|
1037
|
+
"-p",
|
|
1038
|
+
String(config.port),
|
|
1039
|
+
`${config.username}@${config.host}`,
|
|
1040
|
+
`sh -c ${shellQuote(remoteScript)}`
|
|
1041
|
+
);
|
|
1042
|
+
return options.stdin != null ? await spawnText("ssh", sshArgs, {
|
|
1043
|
+
stdin: options.stdin,
|
|
1044
|
+
timeout: options.timeoutMs ?? 15e3,
|
|
1045
|
+
maxBuffer: options.maxBuffer ?? 1024 * 128
|
|
1046
|
+
}) : await execFileText("ssh", sshArgs, {
|
|
1047
|
+
timeout: options.timeoutMs ?? 15e3,
|
|
1048
|
+
maxBuffer: options.maxBuffer ?? 1024 * 128
|
|
1049
|
+
});
|
|
1050
|
+
} finally {
|
|
1051
|
+
await cleanup();
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async function buildSshSpawnTarget(input) {
|
|
1055
|
+
for (const key of Object.keys(input.env)) {
|
|
1056
|
+
if (!isValidShellEnvKey(key)) {
|
|
1057
|
+
throw new Error(`Invalid SSH environment variable key: ${key}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const auth = await createSshAuthArgs(input.spec);
|
|
1061
|
+
const sshArgs = [...auth.args];
|
|
1062
|
+
const envArgs = Object.entries(input.env).filter((entry) => typeof entry[1] === "string").map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
1063
|
+
const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" ");
|
|
1064
|
+
const remoteScript = [
|
|
1065
|
+
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
|
|
1066
|
+
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
|
|
1067
|
+
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
|
|
1068
|
+
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
|
|
1069
|
+
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
|
|
1070
|
+
`cd ${shellQuote(input.spec.remoteCwd)}`,
|
|
1071
|
+
envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${remoteCommandParts}` : `exec ${remoteCommandParts}`
|
|
1072
|
+
].join(" && ");
|
|
1073
|
+
sshArgs.push(
|
|
1074
|
+
"-p",
|
|
1075
|
+
String(input.spec.port),
|
|
1076
|
+
`${input.spec.username}@${input.spec.host}`,
|
|
1077
|
+
`sh -c ${shellQuote(remoteScript)}`
|
|
1078
|
+
);
|
|
1079
|
+
return {
|
|
1080
|
+
command: "ssh",
|
|
1081
|
+
args: sshArgs,
|
|
1082
|
+
cleanup: auth.cleanup
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
async function syncDirectoryToSsh(input) {
|
|
1086
|
+
const auth = await createSshAuthArgs(input.spec);
|
|
1087
|
+
const sshArgs = [
|
|
1088
|
+
...auth.args,
|
|
1089
|
+
"-p",
|
|
1090
|
+
String(input.spec.port),
|
|
1091
|
+
`${input.spec.username}@${input.spec.host}`,
|
|
1092
|
+
`sh -c ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`
|
|
1093
|
+
];
|
|
1094
|
+
const progress = input.onProgress ? createTransferProgress({
|
|
1095
|
+
onProgress: input.onProgress,
|
|
1096
|
+
phase: "Syncing",
|
|
1097
|
+
direction: "to",
|
|
1098
|
+
label: input.progressLabel,
|
|
1099
|
+
totalBytes: estimateLocalDirSize({
|
|
1100
|
+
localDir: input.localDir,
|
|
1101
|
+
exclude: input.exclude,
|
|
1102
|
+
followSymlinks: input.followSymlinks
|
|
1103
|
+
}),
|
|
1104
|
+
estimated: true
|
|
1105
|
+
}) : null;
|
|
1106
|
+
try {
|
|
1107
|
+
await new Promise((resolve, reject) => {
|
|
1108
|
+
const tarArgs = [
|
|
1109
|
+
...input.followSymlinks ? ["-h"] : [],
|
|
1110
|
+
"-C",
|
|
1111
|
+
input.localDir,
|
|
1112
|
+
...tarExcludeArgs(input.exclude),
|
|
1113
|
+
"-cf",
|
|
1114
|
+
"-",
|
|
1115
|
+
"."
|
|
1116
|
+
];
|
|
1117
|
+
const tar = spawn("tar", tarArgs, {
|
|
1118
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1119
|
+
env: tarSpawnEnv()
|
|
1120
|
+
});
|
|
1121
|
+
const ssh = spawn("ssh", sshArgs, {
|
|
1122
|
+
stdio: ["pipe", "ignore", "pipe"]
|
|
1123
|
+
});
|
|
1124
|
+
let tarStderr = "";
|
|
1125
|
+
let sshStderr = "";
|
|
1126
|
+
let settled = false;
|
|
1127
|
+
let tarExited = false;
|
|
1128
|
+
let sshExited = false;
|
|
1129
|
+
let tarExitCode = null;
|
|
1130
|
+
let sshExitCode = null;
|
|
1131
|
+
const maybeFinish = () => {
|
|
1132
|
+
if (settled || !tarExited || !sshExited) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
settled = true;
|
|
1136
|
+
if ((tarExitCode ?? 0) !== 0) {
|
|
1137
|
+
reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if ((sshExitCode ?? 0) !== 0) {
|
|
1141
|
+
reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
resolve();
|
|
1145
|
+
};
|
|
1146
|
+
const fail = (error) => {
|
|
1147
|
+
if (settled) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
settled = true;
|
|
1151
|
+
tar.kill("SIGTERM");
|
|
1152
|
+
ssh.kill("SIGTERM");
|
|
1153
|
+
reject(error);
|
|
1154
|
+
};
|
|
1155
|
+
if (progress) {
|
|
1156
|
+
progress.counter.on("error", fail);
|
|
1157
|
+
tar.stdout?.pipe(progress.counter).pipe(ssh.stdin ?? null);
|
|
1158
|
+
} else {
|
|
1159
|
+
tar.stdout?.pipe(ssh.stdin ?? null);
|
|
1160
|
+
}
|
|
1161
|
+
tar.stderr?.on("data", (chunk) => {
|
|
1162
|
+
tarStderr += String(chunk);
|
|
1163
|
+
});
|
|
1164
|
+
ssh.stderr?.on("data", (chunk) => {
|
|
1165
|
+
sshStderr += String(chunk);
|
|
1166
|
+
});
|
|
1167
|
+
tar.on("error", fail);
|
|
1168
|
+
ssh.on("error", fail);
|
|
1169
|
+
tar.on("close", (code) => {
|
|
1170
|
+
tarExited = true;
|
|
1171
|
+
tarExitCode = code;
|
|
1172
|
+
maybeFinish();
|
|
1173
|
+
});
|
|
1174
|
+
ssh.on("close", (code) => {
|
|
1175
|
+
sshExited = true;
|
|
1176
|
+
sshExitCode = code;
|
|
1177
|
+
maybeFinish();
|
|
1178
|
+
});
|
|
1179
|
+
}).finally(auth.cleanup);
|
|
1180
|
+
await progress?.finish();
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
await progress?.fail();
|
|
1183
|
+
throw error;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async function syncDirectoryFromSsh(input) {
|
|
1187
|
+
const auth = await createSshAuthArgs(input.spec);
|
|
1188
|
+
const stagingDir = await fs2.mkdtemp(path2.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
|
|
1189
|
+
const remoteTarScript = [
|
|
1190
|
+
`cd ${shellQuote(input.remoteDir)}`,
|
|
1191
|
+
`tar ${[...tarExcludeArgs(input.exclude).map(shellQuote), "-cf", "-", "."].join(" ")}`
|
|
1192
|
+
].join(" && ");
|
|
1193
|
+
const sshArgs = [
|
|
1194
|
+
...auth.args,
|
|
1195
|
+
"-p",
|
|
1196
|
+
String(input.spec.port),
|
|
1197
|
+
`${input.spec.username}@${input.spec.host}`,
|
|
1198
|
+
`sh -c ${shellQuote(remoteTarScript)}`
|
|
1199
|
+
];
|
|
1200
|
+
const progress = input.onProgress ? createTransferProgress({
|
|
1201
|
+
onProgress: input.onProgress,
|
|
1202
|
+
phase: "Restoring",
|
|
1203
|
+
direction: "from",
|
|
1204
|
+
label: input.progressLabel,
|
|
1205
|
+
totalBytes: probeRemoteDirSize({ spec: input.spec, remoteDir: input.remoteDir }),
|
|
1206
|
+
estimated: true
|
|
1207
|
+
}) : null;
|
|
1208
|
+
try {
|
|
1209
|
+
await new Promise((resolve, reject) => {
|
|
1210
|
+
const ssh = spawn("ssh", sshArgs, {
|
|
1211
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1212
|
+
});
|
|
1213
|
+
const tar = spawn("tar", ["-xf", "-", "-C", stagingDir], {
|
|
1214
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
1215
|
+
env: tarSpawnEnv()
|
|
1216
|
+
});
|
|
1217
|
+
let sshStderr = "";
|
|
1218
|
+
let tarStderr = "";
|
|
1219
|
+
let settled = false;
|
|
1220
|
+
let sshExited = false;
|
|
1221
|
+
let tarExited = false;
|
|
1222
|
+
let sshExitCode = null;
|
|
1223
|
+
let tarExitCode = null;
|
|
1224
|
+
const maybeFinish = () => {
|
|
1225
|
+
if (settled || !sshExited || !tarExited) return;
|
|
1226
|
+
settled = true;
|
|
1227
|
+
if ((sshExitCode ?? 0) !== 0) {
|
|
1228
|
+
reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if ((tarExitCode ?? 0) !== 0) {
|
|
1232
|
+
reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
resolve();
|
|
1236
|
+
};
|
|
1237
|
+
const fail = (error) => {
|
|
1238
|
+
if (settled) return;
|
|
1239
|
+
settled = true;
|
|
1240
|
+
ssh.kill("SIGTERM");
|
|
1241
|
+
tar.kill("SIGTERM");
|
|
1242
|
+
reject(error);
|
|
1243
|
+
};
|
|
1244
|
+
if (progress) {
|
|
1245
|
+
progress.counter.on("error", fail);
|
|
1246
|
+
ssh.stdout?.pipe(progress.counter).pipe(tar.stdin ?? null);
|
|
1247
|
+
} else {
|
|
1248
|
+
ssh.stdout?.pipe(tar.stdin ?? null);
|
|
1249
|
+
}
|
|
1250
|
+
ssh.stderr?.on("data", (chunk) => {
|
|
1251
|
+
sshStderr += String(chunk);
|
|
1252
|
+
});
|
|
1253
|
+
tar.stderr?.on("data", (chunk) => {
|
|
1254
|
+
tarStderr += String(chunk);
|
|
1255
|
+
});
|
|
1256
|
+
ssh.on("error", fail);
|
|
1257
|
+
tar.on("error", fail);
|
|
1258
|
+
ssh.on("close", (code) => {
|
|
1259
|
+
sshExited = true;
|
|
1260
|
+
sshExitCode = code;
|
|
1261
|
+
maybeFinish();
|
|
1262
|
+
});
|
|
1263
|
+
tar.on("close", (code) => {
|
|
1264
|
+
tarExited = true;
|
|
1265
|
+
tarExitCode = code;
|
|
1266
|
+
maybeFinish();
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
await progress?.finish();
|
|
1270
|
+
await clearLocalDirectory(input.localDir, input.preserveLocalEntries);
|
|
1271
|
+
await copyDirectoryContents(stagingDir, input.localDir);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
await progress?.fail();
|
|
1274
|
+
throw error;
|
|
1275
|
+
} finally {
|
|
1276
|
+
await fs2.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1277
|
+
await auth.cleanup();
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async function prepareWorkspaceForSshExecution(input) {
|
|
1281
|
+
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
|
|
1282
|
+
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
|
|
1283
|
+
if (gitSnapshot) {
|
|
1284
|
+
await importGitWorkspaceToSsh({
|
|
1285
|
+
spec: input.spec,
|
|
1286
|
+
localDir: input.localDir,
|
|
1287
|
+
remoteDir,
|
|
1288
|
+
snapshot: gitSnapshot,
|
|
1289
|
+
onProgress: input.onProgress
|
|
1290
|
+
});
|
|
1291
|
+
await syncDirectoryToSsh({
|
|
1292
|
+
spec: input.spec,
|
|
1293
|
+
localDir: input.localDir,
|
|
1294
|
+
remoteDir,
|
|
1295
|
+
exclude: [".git", ".paperclip-runtime"],
|
|
1296
|
+
onProgress: input.onProgress,
|
|
1297
|
+
progressLabel: "workspace"
|
|
1298
|
+
});
|
|
1299
|
+
await removeDeletedPathsOnSsh({
|
|
1300
|
+
spec: input.spec,
|
|
1301
|
+
remoteDir,
|
|
1302
|
+
deletedPaths: gitSnapshot.deletedPaths
|
|
1303
|
+
});
|
|
1304
|
+
return { gitBacked: true };
|
|
1305
|
+
}
|
|
1306
|
+
await clearRemoteDirectory({
|
|
1307
|
+
spec: input.spec,
|
|
1308
|
+
remoteDir,
|
|
1309
|
+
preserveEntries: [".paperclip-runtime"]
|
|
1310
|
+
});
|
|
1311
|
+
await syncDirectoryToSsh({
|
|
1312
|
+
spec: input.spec,
|
|
1313
|
+
localDir: input.localDir,
|
|
1314
|
+
remoteDir,
|
|
1315
|
+
exclude: [".paperclip-runtime"],
|
|
1316
|
+
onProgress: input.onProgress,
|
|
1317
|
+
progressLabel: "workspace"
|
|
1318
|
+
});
|
|
1319
|
+
return { gitBacked: false };
|
|
1320
|
+
}
|
|
1321
|
+
async function restoreWorkspaceFromSshExecution(input) {
|
|
1322
|
+
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
|
|
1323
|
+
if (input.baselineSnapshot) {
|
|
1324
|
+
const stagingDir = await fs2.mkdtemp(path2.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
|
|
1325
|
+
const importedRef = input.restoreGitHistory ? `refs/paperclip/ssh-sync/imported/${randomUUID()}` : null;
|
|
1326
|
+
try {
|
|
1327
|
+
const importedHead = input.restoreGitHistory ? await exportGitWorkspaceFromSsh({
|
|
1328
|
+
spec: input.spec,
|
|
1329
|
+
remoteDir,
|
|
1330
|
+
localDir: input.localDir,
|
|
1331
|
+
importedRef: importedRef ?? void 0,
|
|
1332
|
+
resetLocalWorkspace: false,
|
|
1333
|
+
onProgress: input.onProgress
|
|
1334
|
+
}) : null;
|
|
1335
|
+
await syncDirectoryFromSsh({
|
|
1336
|
+
spec: input.spec,
|
|
1337
|
+
remoteDir,
|
|
1338
|
+
localDir: stagingDir,
|
|
1339
|
+
exclude: input.baselineSnapshot.exclude,
|
|
1340
|
+
onProgress: input.onProgress,
|
|
1341
|
+
progressLabel: "workspace"
|
|
1342
|
+
});
|
|
1343
|
+
await mergeDirectoryWithBaseline({
|
|
1344
|
+
baseline: input.baselineSnapshot,
|
|
1345
|
+
sourceDir: stagingDir,
|
|
1346
|
+
targetDir: input.localDir,
|
|
1347
|
+
// Git history advances via integrateImportedGitHead; the working tree
|
|
1348
|
+
// still comes from the remote file snapshot so dirty remote edits win.
|
|
1349
|
+
beforeApply: importedHead ? async () => {
|
|
1350
|
+
await integrateImportedGitHead({
|
|
1351
|
+
localDir: input.localDir,
|
|
1352
|
+
importedHead
|
|
1353
|
+
});
|
|
1354
|
+
} : void 0
|
|
1355
|
+
});
|
|
1356
|
+
} finally {
|
|
1357
|
+
if (importedRef) {
|
|
1358
|
+
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
|
|
1359
|
+
timeout: 1e4,
|
|
1360
|
+
maxBuffer: 16 * 1024
|
|
1361
|
+
}).catch(() => void 0);
|
|
1362
|
+
}
|
|
1363
|
+
await fs2.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1364
|
+
}
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
|
|
1368
|
+
if (gitSnapshot) {
|
|
1369
|
+
await exportGitWorkspaceFromSsh({
|
|
1370
|
+
spec: input.spec,
|
|
1371
|
+
remoteDir,
|
|
1372
|
+
localDir: input.localDir,
|
|
1373
|
+
onProgress: input.onProgress
|
|
1374
|
+
});
|
|
1375
|
+
await syncDirectoryFromSsh({
|
|
1376
|
+
spec: input.spec,
|
|
1377
|
+
remoteDir,
|
|
1378
|
+
localDir: input.localDir,
|
|
1379
|
+
exclude: [".git", ".paperclip-runtime"],
|
|
1380
|
+
preserveLocalEntries: [".git"],
|
|
1381
|
+
onProgress: input.onProgress,
|
|
1382
|
+
progressLabel: "workspace"
|
|
1383
|
+
});
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
await syncDirectoryFromSsh({
|
|
1387
|
+
spec: input.spec,
|
|
1388
|
+
remoteDir,
|
|
1389
|
+
localDir: input.localDir,
|
|
1390
|
+
exclude: [".paperclip-runtime"],
|
|
1391
|
+
onProgress: input.onProgress,
|
|
1392
|
+
progressLabel: "workspace"
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ../../adapter-utils/src/command-redaction.ts
|
|
1397
|
+
var REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
|
|
1398
|
+
var SECRET_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
|
|
1399
|
+
var COMMAND_CLI_SECRET_OPTION_RE = new RegExp(
|
|
1400
|
+
String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`,
|
|
1401
|
+
"gi"
|
|
1402
|
+
);
|
|
1403
|
+
var COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
|
|
1404
|
+
String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`,
|
|
1405
|
+
"gi"
|
|
1406
|
+
);
|
|
1407
|
+
var COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
|
|
1408
|
+
var COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
|
|
1409
|
+
var COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
|
|
1410
|
+
var COMMAND_JWT_RE = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
|
|
1411
|
+
var COMMAND_SECRET_HINTS = [
|
|
1412
|
+
"api",
|
|
1413
|
+
"key",
|
|
1414
|
+
"token",
|
|
1415
|
+
"auth",
|
|
1416
|
+
"bearer",
|
|
1417
|
+
"secret",
|
|
1418
|
+
"pass",
|
|
1419
|
+
"credential",
|
|
1420
|
+
"jwt",
|
|
1421
|
+
"private",
|
|
1422
|
+
"cookie",
|
|
1423
|
+
"connectionstring",
|
|
1424
|
+
"sk-",
|
|
1425
|
+
"ghp_",
|
|
1426
|
+
"gho_",
|
|
1427
|
+
"ghu_",
|
|
1428
|
+
"ghs_",
|
|
1429
|
+
"ghr_"
|
|
1430
|
+
];
|
|
1431
|
+
function maybeContainsSecretText(command) {
|
|
1432
|
+
const lower = command.toLowerCase();
|
|
1433
|
+
return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes(".");
|
|
1434
|
+
}
|
|
1435
|
+
function redactCommandText(command, redactedValue = REDACTED_COMMAND_TEXT_VALUE) {
|
|
1436
|
+
if (!maybeContainsSecretText(command)) return command;
|
|
1437
|
+
return command.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`).replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`).replace(
|
|
1438
|
+
COMMAND_ENV_SECRET_ASSIGNMENT_RE,
|
|
1439
|
+
(_match, prefix, quote) => quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`
|
|
1440
|
+
).replace(COMMAND_OPENAI_KEY_RE, redactedValue).replace(COMMAND_GITHUB_TOKEN_RE, redactedValue).replace(COMMAND_JWT_RE, redactedValue);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// ../../adapter-utils/src/server-utils.ts
|
|
1444
|
+
function resolveProcessGroupId(child) {
|
|
1445
|
+
if (process.platform === "win32") return null;
|
|
1446
|
+
return typeof child.pid === "number" && child.pid > 0 ? child.pid : null;
|
|
1447
|
+
}
|
|
1448
|
+
function signalRunningProcess(running, signal) {
|
|
1449
|
+
if (process.platform !== "win32" && running.processGroupId && running.processGroupId > 0) {
|
|
1450
|
+
try {
|
|
1451
|
+
process.kill(-running.processGroupId, signal);
|
|
1452
|
+
return;
|
|
1453
|
+
} catch {
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
if (!running.child.killed) {
|
|
1457
|
+
running.child.kill(signal);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
var runningProcesses = /* @__PURE__ */ new Map();
|
|
1461
|
+
var MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
1462
|
+
var MAX_EXCERPT_BYTES = 32 * 1024;
|
|
1463
|
+
var TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
|
|
1464
|
+
var DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
|
1465
|
+
var PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
|
1466
|
+
var SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
|
1467
|
+
var REDACTED_LOG_VALUE = "***REDACTED***";
|
|
1468
|
+
var PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
|
1469
|
+
"../../skills",
|
|
1470
|
+
"../../../../../skills"
|
|
1471
|
+
];
|
|
1472
|
+
var MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json";
|
|
1473
|
+
var MATERIALIZED_SKILL_LOCK_OWNER = "owner.json";
|
|
1474
|
+
var MATERIALIZED_SKILL_LOCK_STALE_MS = 3e4;
|
|
1475
|
+
function expandHomePrefix(value) {
|
|
1476
|
+
if (value === "~") return os2.homedir();
|
|
1477
|
+
if (value.startsWith("~/")) return path3.resolve(os2.homedir(), value.slice(2));
|
|
1478
|
+
return value;
|
|
1479
|
+
}
|
|
1480
|
+
function resolvePaperclipInstanceRootForAdapter(input = {}) {
|
|
1481
|
+
const env = input.env ?? process.env;
|
|
1482
|
+
const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim();
|
|
1483
|
+
const homeDir = path3.resolve(homeRaw ? expandHomePrefix(homeRaw) : path3.resolve(os2.homedir(), ".paperclip"));
|
|
1484
|
+
const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
|
|
1485
|
+
if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`);
|
|
1486
|
+
return path3.resolve(homeDir, "instances", instanceId);
|
|
1487
|
+
}
|
|
1488
|
+
var DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|
1489
|
+
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
|
1490
|
+
"",
|
|
1491
|
+
"Execution contract:",
|
|
1492
|
+
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
|
|
1493
|
+
"- Leave durable progress in comments, documents, or work products, then update the issue to a clear final disposition before ending the heartbeat.",
|
|
1494
|
+
"- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
|
1495
|
+
"- Final disposition checklist: mark `done` when complete; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.",
|
|
1496
|
+
"- Prefer the smallest verification that proves the change; do not default to full workspace typecheck/build/test on every heartbeat unless the task scope warrants it.",
|
|
1497
|
+
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
|
|
1498
|
+
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
|
|
1499
|
+
"- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.",
|
|
1500
|
+
"- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response; for request_confirmation this resumes only after acceptance.",
|
|
1501
|
+
"- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.",
|
|
1502
|
+
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
|
|
1503
|
+
"- If blocked, mark the issue blocked and name the unblock owner and action.",
|
|
1504
|
+
"- Respect budget, pause/cancel, approval gates, and company boundaries."
|
|
1505
|
+
].join("\n");
|
|
1506
|
+
var WATCHDOG_DEFAULT_MANDATE = [
|
|
1507
|
+
"You are running as a task watchdog, not as the original deliverable worker.",
|
|
1508
|
+
"Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.",
|
|
1509
|
+
"",
|
|
1510
|
+
"Mandate:",
|
|
1511
|
+
"- Treat every terminal, cancelled, blocked, in-review, or otherwise stopped leaf in the watched subtree as a claim that must be verified against comments, documents, work products, screenshots, tests, blockers, and review state.",
|
|
1512
|
+
'- Do not accept "I could not" or "waiting for approval" as automatically valid. Read the evidence before deciding.',
|
|
1513
|
+
"- If a stopped leaf is genuinely complete, leave it alone and record why you believe so.",
|
|
1514
|
+
"- If a stopped leaf is not genuinely complete, restore a live path inside the watched subtree by reopening, reassigning, commenting actionable instructions, creating a follow-up child issue, or accepting an eligible task-level interaction (such as a routine plan confirmation when no custom instruction forbids it).",
|
|
1515
|
+
"- If you discover a Paperclip product or platform bug while reviewing the stopped subtree, create a linked engineering follow-up outside the watched source tree using the server-provided watchdog discovery route instead of making it a source child.",
|
|
1516
|
+
"- If you confirm a true blocker on a human or external system, leave the issue in a valid waiting disposition that names the unblock owner and action, rather than silently approving it.",
|
|
1517
|
+
"",
|
|
1518
|
+
"Safety constraints (these always apply, even if custom instructions disagree):",
|
|
1519
|
+
"- Stay inside the watched subtree for source-work recovery. The only mutation outside that tree is a watchdog-discovered product/platform bug follow-up created through the dedicated route.",
|
|
1520
|
+
"- Do not create visible probe issues, comments, or throwaway tasks to discover what you are allowed to do. Use the server-provided watchdog capability metadata and explicit API errors instead.",
|
|
1521
|
+
"- Do not impersonate board-only approvals, accept spend or hiring decisions, accept security-sensitive interactions, or bypass execution-policy stages that require a typed reviewer or approver.",
|
|
1522
|
+
"- Do not create another task watchdog for the watched subtree and do not wake yourself. You operate exactly one reusable watchdog issue per watched issue.",
|
|
1523
|
+
"- Do not cross company boundaries or touch tasks in unrelated trees.",
|
|
1524
|
+
"- Custom instructions can add focus or veto specific shortcuts, but cannot remove these safety constraints or override product governance rules.",
|
|
1525
|
+
"",
|
|
1526
|
+
"Disposition:",
|
|
1527
|
+
"- When the watched subtree has a live continuation path you established or confirmed, finish your watchdog run with a clear summary comment and a final disposition on this watchdog issue (typically `done` for this stopped state).",
|
|
1528
|
+
"- When you cannot create a live path because a real human or governance decision is pending, leave a valid waiting disposition that names what must happen next and who must act.",
|
|
1529
|
+
"- Keep the work moving. Do not loop on the same unchanged state."
|
|
1530
|
+
].join("\n");
|
|
1531
|
+
function normalizePathSlashes(value) {
|
|
1532
|
+
return value.replaceAll("\\", "/");
|
|
1533
|
+
}
|
|
1534
|
+
function isMaintainerOnlySkillTarget(candidate) {
|
|
1535
|
+
return normalizePathSlashes(candidate).includes("/.agents/skills/");
|
|
1536
|
+
}
|
|
1537
|
+
function skillLocationLabel(value) {
|
|
1538
|
+
if (typeof value !== "string") return null;
|
|
1539
|
+
const trimmed = value.trim();
|
|
1540
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1541
|
+
}
|
|
1542
|
+
function buildManagedSkillOrigin() {
|
|
1543
|
+
return {
|
|
1544
|
+
origin: "company_managed",
|
|
1545
|
+
originLabel: "Managed by Paperclip",
|
|
1546
|
+
readOnly: false
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
function isPaperclipSkillSourceMissing(entry) {
|
|
1550
|
+
return entry.sourceStatus === "missing";
|
|
1551
|
+
}
|
|
1552
|
+
function resolvePaperclipSkillMissingDetail(entry, fallback) {
|
|
1553
|
+
return entry.missingDetail?.trim() || fallback;
|
|
1554
|
+
}
|
|
1555
|
+
function resolveSkillDetail(detail, entry) {
|
|
1556
|
+
if (typeof detail === "function") return detail(entry);
|
|
1557
|
+
if (typeof detail === "string") return detail;
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
function resolveInstalledEntryTarget(skillsHome, entryName, dirent, linkedPath) {
|
|
1561
|
+
const fullPath = path3.join(skillsHome, entryName);
|
|
1562
|
+
if (dirent.isSymbolicLink()) {
|
|
1563
|
+
return {
|
|
1564
|
+
targetPath: linkedPath ? path3.resolve(path3.dirname(fullPath), linkedPath) : null,
|
|
1565
|
+
kind: "symlink"
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
if (dirent.isDirectory()) {
|
|
1569
|
+
return { targetPath: fullPath, kind: "directory" };
|
|
1570
|
+
}
|
|
1571
|
+
return { targetPath: fullPath, kind: "file" };
|
|
1572
|
+
}
|
|
1573
|
+
function parseObject(value) {
|
|
1574
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1575
|
+
return {};
|
|
1576
|
+
}
|
|
1577
|
+
return value;
|
|
1578
|
+
}
|
|
1579
|
+
function asString(value, fallback) {
|
|
1580
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
1581
|
+
}
|
|
1582
|
+
function asNumber(value, fallback) {
|
|
1583
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1584
|
+
}
|
|
1585
|
+
function asBoolean(value, fallback) {
|
|
1586
|
+
return typeof value === "boolean" ? value : fallback;
|
|
1587
|
+
}
|
|
1588
|
+
function asStringArray(value) {
|
|
1589
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
1590
|
+
}
|
|
1591
|
+
function parseJson(value) {
|
|
1592
|
+
try {
|
|
1593
|
+
return JSON.parse(value);
|
|
1594
|
+
} catch {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function appendWithCap(prev, chunk, cap = MAX_CAPTURE_BYTES) {
|
|
1599
|
+
const combined = prev + chunk;
|
|
1600
|
+
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
|
1601
|
+
}
|
|
1602
|
+
function appendWithByteCap(prev, chunk, cap = MAX_CAPTURE_BYTES) {
|
|
1603
|
+
const combined = prev + chunk;
|
|
1604
|
+
const bytes = Buffer.byteLength(combined, "utf8");
|
|
1605
|
+
if (bytes <= cap) return combined;
|
|
1606
|
+
const buffer = Buffer.from(combined, "utf8");
|
|
1607
|
+
let start = Math.max(0, bytes - cap);
|
|
1608
|
+
while (start < buffer.length && (buffer[start] & 192) === 128) start += 1;
|
|
1609
|
+
return buffer.subarray(start).toString("utf8");
|
|
1610
|
+
}
|
|
1611
|
+
function resumeReadable(readable) {
|
|
1612
|
+
if (!readable || readable.destroyed) return;
|
|
1613
|
+
readable.resume();
|
|
1614
|
+
}
|
|
1615
|
+
function resolvePathValue(obj, dottedPath) {
|
|
1616
|
+
const parts = dottedPath.split(".");
|
|
1617
|
+
let cursor = obj;
|
|
1618
|
+
for (const part of parts) {
|
|
1619
|
+
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
|
|
1620
|
+
return "";
|
|
1621
|
+
}
|
|
1622
|
+
cursor = cursor[part];
|
|
1623
|
+
}
|
|
1624
|
+
if (cursor === null || cursor === void 0) return "";
|
|
1625
|
+
if (typeof cursor === "string") return cursor;
|
|
1626
|
+
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
|
|
1627
|
+
try {
|
|
1628
|
+
return JSON.stringify(cursor);
|
|
1629
|
+
} catch {
|
|
1630
|
+
return "";
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function renderTemplate(template, data) {
|
|
1634
|
+
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path4) => resolvePathValue(data, path4));
|
|
1635
|
+
}
|
|
1636
|
+
function joinPromptSections(sections, separator = "\n\n") {
|
|
1637
|
+
return sections.map((value) => typeof value === "string" ? value.trim() : "").filter(Boolean).join(separator);
|
|
1638
|
+
}
|
|
1639
|
+
function normalizePaperclipWakeIssue(value) {
|
|
1640
|
+
const issue = parseObject(value);
|
|
1641
|
+
const id = asString(issue.id, "").trim() || null;
|
|
1642
|
+
const identifier = asString(issue.identifier, "").trim() || null;
|
|
1643
|
+
const title = asString(issue.title, "").trim() || null;
|
|
1644
|
+
const status = asString(issue.status, "").trim() || null;
|
|
1645
|
+
const workMode = asString(issue.workMode, "").trim() || null;
|
|
1646
|
+
const priority = asString(issue.priority, "").trim() || null;
|
|
1647
|
+
if (!id && !identifier && !title) return null;
|
|
1648
|
+
return {
|
|
1649
|
+
id,
|
|
1650
|
+
identifier,
|
|
1651
|
+
title,
|
|
1652
|
+
status,
|
|
1653
|
+
workMode,
|
|
1654
|
+
priority
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
function normalizePaperclipWakeComment(value) {
|
|
1658
|
+
const comment = parseObject(value);
|
|
1659
|
+
const author = parseObject(comment.author);
|
|
1660
|
+
const body = asString(comment.body, "");
|
|
1661
|
+
if (!body.trim()) return null;
|
|
1662
|
+
return {
|
|
1663
|
+
id: asString(comment.id, "").trim() || null,
|
|
1664
|
+
issueId: asString(comment.issueId, "").trim() || null,
|
|
1665
|
+
body,
|
|
1666
|
+
bodyTruncated: asBoolean(comment.bodyTruncated, false),
|
|
1667
|
+
createdAt: asString(comment.createdAt, "").trim() || null,
|
|
1668
|
+
authorType: asString(author.type, "").trim() || null,
|
|
1669
|
+
authorId: asString(author.id, "").trim() || null
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
function normalizePaperclipWakeContinuationSummary(value) {
|
|
1673
|
+
const summary = parseObject(value);
|
|
1674
|
+
const body = asString(summary.body, "").trim();
|
|
1675
|
+
if (!body) return null;
|
|
1676
|
+
return {
|
|
1677
|
+
key: asString(summary.key, "").trim() || null,
|
|
1678
|
+
title: asString(summary.title, "").trim() || null,
|
|
1679
|
+
body,
|
|
1680
|
+
bodyTruncated: asBoolean(summary.bodyTruncated, false),
|
|
1681
|
+
updatedAt: asString(summary.updatedAt, "").trim() || null
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function normalizePaperclipWakeLivenessContinuation(value) {
|
|
1685
|
+
const continuation = parseObject(value);
|
|
1686
|
+
const attempt = asNumber(continuation.attempt, 0);
|
|
1687
|
+
const maxAttempts = asNumber(continuation.maxAttempts, 0);
|
|
1688
|
+
const sourceRunId = asString(continuation.sourceRunId, "").trim() || null;
|
|
1689
|
+
const state = asString(continuation.state, "").trim() || null;
|
|
1690
|
+
const reason = asString(continuation.reason, "").trim() || null;
|
|
1691
|
+
const instruction = asString(continuation.instruction, "").trim() || null;
|
|
1692
|
+
if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null;
|
|
1693
|
+
return {
|
|
1694
|
+
attempt: attempt > 0 ? attempt : null,
|
|
1695
|
+
maxAttempts: maxAttempts > 0 ? maxAttempts : null,
|
|
1696
|
+
sourceRunId,
|
|
1697
|
+
state,
|
|
1698
|
+
reason,
|
|
1699
|
+
instruction
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
function normalizePaperclipWakeChildIssueSummary(value) {
|
|
1703
|
+
const child = parseObject(value);
|
|
1704
|
+
const id = asString(child.id, "").trim() || null;
|
|
1705
|
+
const identifier = asString(child.identifier, "").trim() || null;
|
|
1706
|
+
const title = asString(child.title, "").trim() || null;
|
|
1707
|
+
const status = asString(child.status, "").trim() || null;
|
|
1708
|
+
const priority = asString(child.priority, "").trim() || null;
|
|
1709
|
+
const summary = asString(child.summary, "").trim() || null;
|
|
1710
|
+
if (!id && !identifier && !title && !status && !summary) return null;
|
|
1711
|
+
return { id, identifier, title, status, priority, summary };
|
|
1712
|
+
}
|
|
1713
|
+
function normalizePaperclipWakeBlockerSummary(value) {
|
|
1714
|
+
const blocker = parseObject(value);
|
|
1715
|
+
const id = asString(blocker.id, "").trim() || null;
|
|
1716
|
+
const identifier = asString(blocker.identifier, "").trim() || null;
|
|
1717
|
+
const title = asString(blocker.title, "").trim() || null;
|
|
1718
|
+
const status = asString(blocker.status, "").trim() || null;
|
|
1719
|
+
const priority = asString(blocker.priority, "").trim() || null;
|
|
1720
|
+
if (!id && !identifier && !title && !status) return null;
|
|
1721
|
+
return { id, identifier, title, status, priority };
|
|
1722
|
+
}
|
|
1723
|
+
function normalizePaperclipWakeTreeHoldSummary(value) {
|
|
1724
|
+
const hold = parseObject(value);
|
|
1725
|
+
const holdId = asString(hold.holdId, "").trim() || null;
|
|
1726
|
+
const rootIssueId = asString(hold.rootIssueId, "").trim() || null;
|
|
1727
|
+
const mode = asString(hold.mode, "").trim() || null;
|
|
1728
|
+
const reason = asString(hold.reason, "").trim() || null;
|
|
1729
|
+
if (!holdId && !rootIssueId && !mode && !reason) return null;
|
|
1730
|
+
return { holdId, rootIssueId, mode, reason };
|
|
1731
|
+
}
|
|
1732
|
+
function normalizePaperclipWakeExecutionPrincipal(value) {
|
|
1733
|
+
const principal = parseObject(value);
|
|
1734
|
+
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
|
1735
|
+
if (typeRaw !== "agent" && typeRaw !== "user") return null;
|
|
1736
|
+
return {
|
|
1737
|
+
type: typeRaw,
|
|
1738
|
+
agentId: asString(principal.agentId, "").trim() || null,
|
|
1739
|
+
userId: asString(principal.userId, "").trim() || null
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
var MAX_WATCHDOG_INSTRUCTIONS_CHARS = 4e3;
|
|
1743
|
+
var MAX_WATCHDOG_LEAF_SUMMARIES = 25;
|
|
1744
|
+
var MAX_WATCHDOG_CAPABILITY_ITEMS = 50;
|
|
1745
|
+
function normalizePaperclipWakeTaskWatchdogLeaf(value) {
|
|
1746
|
+
const leaf = parseObject(value);
|
|
1747
|
+
const id = asString(leaf.id, "").trim() || null;
|
|
1748
|
+
const identifier = asString(leaf.identifier, "").trim() || null;
|
|
1749
|
+
const title = asString(leaf.title, "").trim() || null;
|
|
1750
|
+
const status = asString(leaf.status, "").trim() || null;
|
|
1751
|
+
const priority = asString(leaf.priority, "").trim() || null;
|
|
1752
|
+
const role = asString(leaf.role, "").trim() || null;
|
|
1753
|
+
const summary = asString(leaf.summary, "").trim() || null;
|
|
1754
|
+
if (!id && !identifier && !title && !status && !summary) return null;
|
|
1755
|
+
return { id, identifier, title, status, priority, role, summary };
|
|
1756
|
+
}
|
|
1757
|
+
function normalizeStringList(value, maxItems) {
|
|
1758
|
+
if (!Array.isArray(value)) return [];
|
|
1759
|
+
return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()).slice(0, maxItems);
|
|
1760
|
+
}
|
|
1761
|
+
function normalizePaperclipWakeTaskWatchdogCapabilities(value) {
|
|
1762
|
+
const capabilities = parseObject(value);
|
|
1763
|
+
const operations = normalizeStringList(capabilities.operations, MAX_WATCHDOG_CAPABILITY_ITEMS);
|
|
1764
|
+
const deniedOperations = normalizeStringList(capabilities.deniedOperations, MAX_WATCHDOG_CAPABILITY_ITEMS);
|
|
1765
|
+
const targetScopeRaw = parseObject(capabilities.targetScope);
|
|
1766
|
+
const targetScope = {
|
|
1767
|
+
watchedIssueId: asString(targetScopeRaw.watchedIssueId, "").trim() || null,
|
|
1768
|
+
watchedIssueIdentifier: asString(targetScopeRaw.watchedIssueIdentifier, "").trim() || null,
|
|
1769
|
+
watchdogIssueId: asString(targetScopeRaw.watchdogIssueId, "").trim() || null,
|
|
1770
|
+
includeNonWatchdogDescendants: asBoolean(targetScopeRaw.includeNonWatchdogDescendants, false),
|
|
1771
|
+
excludedOriginKinds: normalizeStringList(targetScopeRaw.excludedOriginKinds, MAX_WATCHDOG_CAPABILITY_ITEMS)
|
|
1772
|
+
};
|
|
1773
|
+
const hasTargetScope = Boolean(
|
|
1774
|
+
targetScope.watchedIssueId || targetScope.watchedIssueIdentifier || targetScope.watchdogIssueId || targetScope.includeNonWatchdogDescendants || targetScope.excludedOriginKinds.length > 0
|
|
1775
|
+
);
|
|
1776
|
+
if (operations.length === 0 && deniedOperations.length === 0 && !hasTargetScope) return null;
|
|
1777
|
+
return {
|
|
1778
|
+
operations,
|
|
1779
|
+
deniedOperations,
|
|
1780
|
+
targetScope: hasTargetScope ? targetScope : null
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
function normalizePaperclipWakeTaskWatchdog(value) {
|
|
1784
|
+
const watchdog = parseObject(value);
|
|
1785
|
+
const watchedIssueId = asString(watchdog.watchedIssueId, "").trim() || null;
|
|
1786
|
+
const watchedIssueIdentifier = asString(watchdog.watchedIssueIdentifier, "").trim() || null;
|
|
1787
|
+
const watchedIssueTitle = asString(watchdog.watchedIssueTitle, "").trim() || null;
|
|
1788
|
+
const stopFingerprint = asString(watchdog.stopFingerprint, "").trim() || null;
|
|
1789
|
+
const customInstructionsRaw = asString(watchdog.customInstructions, "");
|
|
1790
|
+
const customInstructionsTrimmed = customInstructionsRaw.trim();
|
|
1791
|
+
const customInstructions = customInstructionsTrimmed ? customInstructionsTrimmed.length > MAX_WATCHDOG_INSTRUCTIONS_CHARS ? customInstructionsTrimmed.slice(0, MAX_WATCHDOG_INSTRUCTIONS_CHARS) : customInstructionsTrimmed : null;
|
|
1792
|
+
const terminalLeafSummaries = Array.isArray(watchdog.terminalLeafSummaries) ? watchdog.terminalLeafSummaries.slice(0, MAX_WATCHDOG_LEAF_SUMMARIES).map((entry) => normalizePaperclipWakeTaskWatchdogLeaf(entry)).filter((entry) => Boolean(entry)) : [];
|
|
1793
|
+
const capabilities = normalizePaperclipWakeTaskWatchdogCapabilities(watchdog.capabilities);
|
|
1794
|
+
if (!watchedIssueId && !watchedIssueIdentifier && !watchedIssueTitle && !stopFingerprint && !customInstructions && terminalLeafSummaries.length === 0 && !capabilities) {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
watchedIssueId,
|
|
1799
|
+
watchedIssueIdentifier,
|
|
1800
|
+
watchedIssueTitle,
|
|
1801
|
+
stopFingerprint,
|
|
1802
|
+
terminalLeafSummaries,
|
|
1803
|
+
customInstructions,
|
|
1804
|
+
capabilities
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
function normalizePaperclipWakeExecutionStage(value) {
|
|
1808
|
+
const stage = parseObject(value);
|
|
1809
|
+
const wakeRoleRaw = asString(stage.wakeRole, "").trim().toLowerCase();
|
|
1810
|
+
const wakeRole = wakeRoleRaw === "reviewer" || wakeRoleRaw === "approver" || wakeRoleRaw === "executor" ? wakeRoleRaw : null;
|
|
1811
|
+
const allowedActions = Array.isArray(stage.allowedActions) ? stage.allowedActions.filter((entry) => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) : [];
|
|
1812
|
+
const currentParticipant = normalizePaperclipWakeExecutionPrincipal(stage.currentParticipant);
|
|
1813
|
+
const returnAssignee = normalizePaperclipWakeExecutionPrincipal(stage.returnAssignee);
|
|
1814
|
+
const reviewRequestRaw = parseObject(stage.reviewRequest);
|
|
1815
|
+
const reviewInstructions = asString(reviewRequestRaw.instructions, "").trim();
|
|
1816
|
+
const reviewRequest = reviewInstructions ? { instructions: reviewInstructions } : null;
|
|
1817
|
+
const stageId = asString(stage.stageId, "").trim() || null;
|
|
1818
|
+
const stageType = asString(stage.stageType, "").trim() || null;
|
|
1819
|
+
const lastDecisionOutcome = asString(stage.lastDecisionOutcome, "").trim() || null;
|
|
1820
|
+
if (!wakeRole && !stageId && !stageType && !currentParticipant && !returnAssignee && !reviewRequest && !lastDecisionOutcome && allowedActions.length === 0) {
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
return {
|
|
1824
|
+
wakeRole,
|
|
1825
|
+
stageId,
|
|
1826
|
+
stageType,
|
|
1827
|
+
currentParticipant,
|
|
1828
|
+
returnAssignee,
|
|
1829
|
+
reviewRequest,
|
|
1830
|
+
lastDecisionOutcome,
|
|
1831
|
+
allowedActions
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
function normalizePaperclipWakePayload(value) {
|
|
1835
|
+
const payload = parseObject(value);
|
|
1836
|
+
const comments = Array.isArray(payload.comments) ? payload.comments.map((entry) => normalizePaperclipWakeComment(entry)).filter((entry) => Boolean(entry)) : [];
|
|
1837
|
+
const commentWindow = parseObject(payload.commentWindow);
|
|
1838
|
+
const commentIds = Array.isArray(payload.commentIds) ? payload.commentIds.filter((entry) => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()) : [];
|
|
1839
|
+
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
|
|
1840
|
+
const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary);
|
|
1841
|
+
const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation);
|
|
1842
|
+
const taskWatchdog = normalizePaperclipWakeTaskWatchdog(payload.taskWatchdog);
|
|
1843
|
+
const childIssueSummaries = Array.isArray(payload.childIssueSummaries) ? payload.childIssueSummaries.map((entry) => normalizePaperclipWakeChildIssueSummary(entry)).filter((entry) => Boolean(entry)) : [];
|
|
1844
|
+
const unresolvedBlockerIssueIds = Array.isArray(payload.unresolvedBlockerIssueIds) ? payload.unresolvedBlockerIssueIds.map((entry) => asString(entry, "").trim()).filter(Boolean) : [];
|
|
1845
|
+
const unresolvedBlockerSummaries = Array.isArray(payload.unresolvedBlockerSummaries) ? payload.unresolvedBlockerSummaries.map((entry) => normalizePaperclipWakeBlockerSummary(entry)).filter((entry) => Boolean(entry)) : [];
|
|
1846
|
+
const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold);
|
|
1847
|
+
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
return {
|
|
1851
|
+
reason: asString(payload.reason, "").trim() || null,
|
|
1852
|
+
issue: normalizePaperclipWakeIssue(payload.issue),
|
|
1853
|
+
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
|
1854
|
+
dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false),
|
|
1855
|
+
treeHoldInteraction: asBoolean(payload.treeHoldInteraction, false),
|
|
1856
|
+
activeTreeHold,
|
|
1857
|
+
unresolvedBlockerIssueIds,
|
|
1858
|
+
unresolvedBlockerSummaries,
|
|
1859
|
+
executionStage,
|
|
1860
|
+
continuationSummary,
|
|
1861
|
+
livenessContinuation,
|
|
1862
|
+
taskWatchdog,
|
|
1863
|
+
interactionKind: asString(payload.interactionKind, "").trim() || null,
|
|
1864
|
+
interactionStatus: asString(payload.interactionStatus, "").trim() || null,
|
|
1865
|
+
childIssueSummaries,
|
|
1866
|
+
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
|
|
1867
|
+
commentIds,
|
|
1868
|
+
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
|
1869
|
+
comments,
|
|
1870
|
+
requestedCount: asNumber(commentWindow.requestedCount, comments.length || commentIds.length),
|
|
1871
|
+
includedCount: asNumber(commentWindow.includedCount, comments.length),
|
|
1872
|
+
missingCount: asNumber(commentWindow.missingCount, 0),
|
|
1873
|
+
truncated: asBoolean(payload.truncated, false),
|
|
1874
|
+
fallbackFetchNeeded: asBoolean(payload.fallbackFetchNeeded, false)
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
function stringifyPaperclipWakePayload(value) {
|
|
1878
|
+
const normalized = normalizePaperclipWakePayload(value);
|
|
1879
|
+
if (!normalized) return null;
|
|
1880
|
+
return JSON.stringify(normalized);
|
|
1881
|
+
}
|
|
1882
|
+
function readPaperclipIssueWorkModeFromContext(value) {
|
|
1883
|
+
const context = parseObject(value);
|
|
1884
|
+
const issue = parseObject(context.paperclipIssue);
|
|
1885
|
+
const direct = asString(issue.workMode, "").trim();
|
|
1886
|
+
if (direct) return direct;
|
|
1887
|
+
const wake = normalizePaperclipWakePayload(context.paperclipWake);
|
|
1888
|
+
return wake?.issue?.workMode ?? null;
|
|
1889
|
+
}
|
|
1890
|
+
function renderPaperclipWakePrompt(value, options = {}) {
|
|
1891
|
+
const normalized = normalizePaperclipWakePayload(value);
|
|
1892
|
+
if (!normalized) return "";
|
|
1893
|
+
const resumedSession = options.resumedSession === true;
|
|
1894
|
+
const executionStage = normalized.executionStage;
|
|
1895
|
+
const principalLabel = (principal) => {
|
|
1896
|
+
if (!principal || !principal.type) return "unknown";
|
|
1897
|
+
if (principal.type === "agent") return principal.agentId ? `agent ${principal.agentId}` : "agent";
|
|
1898
|
+
return principal.userId ? `user ${principal.userId}` : "user";
|
|
1899
|
+
};
|
|
1900
|
+
const lines = resumedSession ? [
|
|
1901
|
+
"## Paperclip Resume Delta",
|
|
1902
|
+
"",
|
|
1903
|
+
"You are resuming an existing Paperclip session.",
|
|
1904
|
+
"This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.",
|
|
1905
|
+
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
|
|
1906
|
+
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
|
1907
|
+
"",
|
|
1908
|
+
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
|
1909
|
+
"",
|
|
1910
|
+
`- reason: ${normalized.reason ?? "unknown"}`,
|
|
1911
|
+
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
|
1912
|
+
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
|
1913
|
+
`- latest comment id: ${normalized.latestCommentId ?? "unknown"}`,
|
|
1914
|
+
`- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`
|
|
1915
|
+
] : [
|
|
1916
|
+
"## Paperclip Wake Payload",
|
|
1917
|
+
"",
|
|
1918
|
+
"Treat this wake payload as the highest-priority change for the current heartbeat.",
|
|
1919
|
+
"This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.",
|
|
1920
|
+
"Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.",
|
|
1921
|
+
"Use this inline wake data first before refetching the issue thread.",
|
|
1922
|
+
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
|
1923
|
+
"",
|
|
1924
|
+
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
|
1925
|
+
"",
|
|
1926
|
+
`- reason: ${normalized.reason ?? "unknown"}`,
|
|
1927
|
+
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
|
1928
|
+
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
|
1929
|
+
`- latest comment id: ${normalized.latestCommentId ?? "unknown"}`,
|
|
1930
|
+
`- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`
|
|
1931
|
+
];
|
|
1932
|
+
if (normalized.issue?.status) {
|
|
1933
|
+
lines.push(`- issue status: ${normalized.issue.status}`);
|
|
1934
|
+
}
|
|
1935
|
+
if (normalized.issue?.workMode) {
|
|
1936
|
+
lines.push(`- issue work mode: ${normalized.issue.workMode}`);
|
|
1937
|
+
}
|
|
1938
|
+
if (normalized.issue?.priority) {
|
|
1939
|
+
lines.push(`- issue priority: ${normalized.issue.priority}`);
|
|
1940
|
+
}
|
|
1941
|
+
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
|
|
1942
|
+
const hasWakeComments = normalized.comments.length > 0;
|
|
1943
|
+
const acceptedPlanContinuation = !hasWakeComments && normalized.interactionKind === "request_confirmation" && normalized.interactionStatus === "accepted";
|
|
1944
|
+
let directive = "Make the plan only. Do not write code or perform implementation work.";
|
|
1945
|
+
if (hasWakeComments) {
|
|
1946
|
+
directive = "Update the plan only. Do not write code or perform implementation work.";
|
|
1947
|
+
}
|
|
1948
|
+
if (acceptedPlanContinuation) {
|
|
1949
|
+
directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue.";
|
|
1950
|
+
}
|
|
1951
|
+
lines.push(`- planning directive: ${directive}`);
|
|
1952
|
+
if (acceptedPlanContinuation) {
|
|
1953
|
+
lines.push(
|
|
1954
|
+
"- accepted-plan continuation: you may create child implementation issues from the approved plan, but must not start implementation work on the planning issue itself"
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (normalized.checkedOutByHarness) {
|
|
1959
|
+
lines.push("- checkout: already claimed by the harness for this run");
|
|
1960
|
+
}
|
|
1961
|
+
if (normalized.dependencyBlockedInteraction) {
|
|
1962
|
+
lines.push("- dependency-blocked interaction: yes");
|
|
1963
|
+
lines.push("- execution scope: respond or triage the human comment; do not treat blocker-dependent deliverable work as unblocked");
|
|
1964
|
+
if (normalized.unresolvedBlockerSummaries.length > 0) {
|
|
1965
|
+
const blockers = normalized.unresolvedBlockerSummaries.map((blocker) => `${blocker.identifier ?? blocker.id ?? "unknown"}${blocker.title ? ` ${blocker.title}` : ""}${blocker.status ? ` (${blocker.status})` : ""}`).join("; ");
|
|
1966
|
+
lines.push(`- unresolved blockers: ${blockers}`);
|
|
1967
|
+
} else if (normalized.unresolvedBlockerIssueIds.length > 0) {
|
|
1968
|
+
lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
if (normalized.treeHoldInteraction) {
|
|
1972
|
+
lines.push("- tree-hold interaction: yes");
|
|
1973
|
+
lines.push("- execution scope: respond or triage the human comment; the subtree remains paused until an explicit resume action");
|
|
1974
|
+
if (normalized.activeTreeHold) {
|
|
1975
|
+
const hold = normalized.activeTreeHold;
|
|
1976
|
+
lines.push(`- active tree hold: ${hold.holdId ?? "unknown"}${hold.rootIssueId ? ` rooted at ${hold.rootIssueId}` : ""}${hold.mode ? ` (${hold.mode})` : ""}`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
if (normalized.missingCount > 0) {
|
|
1980
|
+
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
|
1981
|
+
}
|
|
1982
|
+
if (executionStage) {
|
|
1983
|
+
lines.push(
|
|
1984
|
+
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,
|
|
1985
|
+
`- execution stage: ${executionStage.stageType ?? "unknown"}`,
|
|
1986
|
+
`- execution participant: ${principalLabel(executionStage.currentParticipant)}`,
|
|
1987
|
+
`- execution return assignee: ${principalLabel(executionStage.returnAssignee)}`,
|
|
1988
|
+
`- last decision outcome: ${executionStage.lastDecisionOutcome ?? "none"}`
|
|
1989
|
+
);
|
|
1990
|
+
if (executionStage.allowedActions.length > 0) {
|
|
1991
|
+
lines.push(`- allowed actions: ${executionStage.allowedActions.join(", ")}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (executionStage.reviewRequest) {
|
|
1994
|
+
lines.push(
|
|
1995
|
+
"",
|
|
1996
|
+
"Review request instructions:",
|
|
1997
|
+
executionStage.reviewRequest.instructions
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
lines.push("");
|
|
2001
|
+
if (executionStage.wakeRole === "reviewer" || executionStage.wakeRole === "approver") {
|
|
2002
|
+
lines.push(
|
|
2003
|
+
`You are waking as the active ${executionStage.wakeRole} for this issue.`,
|
|
2004
|
+
"Do not execute the task itself or continue executor work.",
|
|
2005
|
+
"Review the issue and choose one of the allowed actions above.",
|
|
2006
|
+
"If you request changes, the workflow routes back to the stored return assignee.",
|
|
2007
|
+
""
|
|
2008
|
+
);
|
|
2009
|
+
} else if (executionStage.wakeRole === "executor") {
|
|
2010
|
+
lines.push(
|
|
2011
|
+
"You are waking because changes were requested in the execution workflow.",
|
|
2012
|
+
"Address the requested changes on this issue and resubmit when the work is ready.",
|
|
2013
|
+
""
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
if (normalized.taskWatchdog) {
|
|
2018
|
+
const watchdog = normalized.taskWatchdog;
|
|
2019
|
+
const watchedLabel = watchdog.watchedIssueIdentifier ?? watchdog.watchedIssueId ?? "unknown";
|
|
2020
|
+
lines.push(
|
|
2021
|
+
"",
|
|
2022
|
+
"## Task Watchdog Mandate",
|
|
2023
|
+
"",
|
|
2024
|
+
`Watched issue: ${watchedLabel}${watchdog.watchedIssueTitle ? ` ${watchdog.watchedIssueTitle}` : ""}`
|
|
2025
|
+
);
|
|
2026
|
+
if (watchdog.stopFingerprint) {
|
|
2027
|
+
lines.push(`Stop fingerprint: ${watchdog.stopFingerprint}`);
|
|
2028
|
+
}
|
|
2029
|
+
lines.push("", WATCHDOG_DEFAULT_MANDATE);
|
|
2030
|
+
if (watchdog.capabilities) {
|
|
2031
|
+
lines.push("", "Server-derived watchdog capability metadata:");
|
|
2032
|
+
if (watchdog.capabilities.targetScope) {
|
|
2033
|
+
const scope = watchdog.capabilities.targetScope;
|
|
2034
|
+
lines.push(
|
|
2035
|
+
`- Target scope: ${scope.watchedIssueIdentifier ?? scope.watchedIssueId ?? "unknown"} plus ${scope.includeNonWatchdogDescendants ? "non-watchdog descendants" : "no descendants"}.`
|
|
2036
|
+
);
|
|
2037
|
+
if (scope.watchdogIssueId) {
|
|
2038
|
+
lines.push(`- Reusable watchdog issue: ${scope.watchdogIssueId}.`);
|
|
2039
|
+
}
|
|
2040
|
+
if (scope.excludedOriginKinds.length > 0) {
|
|
2041
|
+
lines.push(`- Excluded origin kinds: ${scope.excludedOriginKinds.join(", ")}.`);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (watchdog.capabilities.operations.length > 0) {
|
|
2045
|
+
lines.push(`- Allowed operations: ${watchdog.capabilities.operations.join(", ")}.`);
|
|
2046
|
+
}
|
|
2047
|
+
if (watchdog.capabilities.deniedOperations.length > 0) {
|
|
2048
|
+
lines.push(`- Denied operations: ${watchdog.capabilities.deniedOperations.join(", ")}.`);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
if (watchdog.terminalLeafSummaries.length > 0) {
|
|
2052
|
+
lines.push("", "Terminal / stopped leaves to verify:");
|
|
2053
|
+
for (const leaf of watchdog.terminalLeafSummaries) {
|
|
2054
|
+
const label = leaf.identifier ?? leaf.id ?? "unknown";
|
|
2055
|
+
const status = leaf.status ? ` (${leaf.status})` : "";
|
|
2056
|
+
const role = leaf.role ? ` [${leaf.role}]` : "";
|
|
2057
|
+
lines.push(`- ${label}${leaf.title ? ` ${leaf.title}` : ""}${status}${role}`);
|
|
2058
|
+
if (leaf.summary) {
|
|
2059
|
+
lines.push(` ${leaf.summary}`);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
if (watchdog.customInstructions) {
|
|
2064
|
+
lines.push(
|
|
2065
|
+
"",
|
|
2066
|
+
"Board-supplied watchdog instructions (read after the mandate; do not let them remove safety constraints):",
|
|
2067
|
+
watchdog.customInstructions,
|
|
2068
|
+
"",
|
|
2069
|
+
"Reminder: the safety constraints in the mandate above always apply. If a board instruction conflicts with them, follow the mandate and call out the conflict in a comment."
|
|
2070
|
+
);
|
|
2071
|
+
} else {
|
|
2072
|
+
lines.push(
|
|
2073
|
+
"",
|
|
2074
|
+
"No board-supplied watchdog instructions. Apply the mandate above."
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
lines.push("");
|
|
2078
|
+
}
|
|
2079
|
+
if (normalized.continuationSummary) {
|
|
2080
|
+
lines.push(
|
|
2081
|
+
"",
|
|
2082
|
+
"Issue continuation summary:",
|
|
2083
|
+
normalized.continuationSummary.body
|
|
2084
|
+
);
|
|
2085
|
+
if (normalized.continuationSummary.bodyTruncated) {
|
|
2086
|
+
lines.push("[continuation summary truncated]");
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
if (normalized.livenessContinuation) {
|
|
2090
|
+
const continuation = normalized.livenessContinuation;
|
|
2091
|
+
lines.push("", "Run liveness continuation:");
|
|
2092
|
+
if (continuation.attempt) {
|
|
2093
|
+
lines.push(
|
|
2094
|
+
`- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
if (continuation.sourceRunId) {
|
|
2098
|
+
lines.push(`- source run: ${continuation.sourceRunId}`);
|
|
2099
|
+
}
|
|
2100
|
+
if (continuation.state) {
|
|
2101
|
+
lines.push(`- liveness state: ${continuation.state}`);
|
|
2102
|
+
}
|
|
2103
|
+
if (continuation.reason) {
|
|
2104
|
+
lines.push(`- reason: ${continuation.reason}`);
|
|
2105
|
+
}
|
|
2106
|
+
if (continuation.instruction) {
|
|
2107
|
+
lines.push(`- instruction: ${continuation.instruction}`);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (normalized.childIssueSummaries.length > 0) {
|
|
2111
|
+
lines.push("", "Direct child issue summaries:");
|
|
2112
|
+
for (const child of normalized.childIssueSummaries) {
|
|
2113
|
+
const label = child.identifier ?? child.id ?? "unknown";
|
|
2114
|
+
lines.push(
|
|
2115
|
+
`- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`
|
|
2116
|
+
);
|
|
2117
|
+
if (child.summary) {
|
|
2118
|
+
lines.push(` ${child.summary}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (normalized.childIssueSummaryTruncated) {
|
|
2122
|
+
lines.push("[child issue summaries truncated]");
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
if (normalized.checkedOutByHarness) {
|
|
2126
|
+
lines.push(
|
|
2127
|
+
"",
|
|
2128
|
+
"The harness already checked out this issue for the current run.",
|
|
2129
|
+
"Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.",
|
|
2130
|
+
""
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
if (normalized.comments.length > 0) {
|
|
2134
|
+
lines.push("New comments in order:");
|
|
2135
|
+
}
|
|
2136
|
+
for (const [index, comment] of normalized.comments.entries()) {
|
|
2137
|
+
const authorLabel = comment.authorId ? `${comment.authorType ?? "unknown"} ${comment.authorId}` : comment.authorType ?? "unknown";
|
|
2138
|
+
lines.push(
|
|
2139
|
+
`${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`,
|
|
2140
|
+
comment.body
|
|
2141
|
+
);
|
|
2142
|
+
if (comment.bodyTruncated) {
|
|
2143
|
+
lines.push("[comment body truncated]");
|
|
2144
|
+
}
|
|
2145
|
+
lines.push("");
|
|
2146
|
+
}
|
|
2147
|
+
return lines.join("\n").trim();
|
|
2148
|
+
}
|
|
2149
|
+
function redactEnvForLogs(env) {
|
|
2150
|
+
const redacted = {};
|
|
2151
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2152
|
+
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value;
|
|
2153
|
+
}
|
|
2154
|
+
return redacted;
|
|
2155
|
+
}
|
|
2156
|
+
function redactCommandTextForLogs(command) {
|
|
2157
|
+
return redactCommandText(command, REDACTED_LOG_VALUE);
|
|
2158
|
+
}
|
|
2159
|
+
function buildInvocationEnvForLogs(env, options = {}) {
|
|
2160
|
+
const merged = { ...env };
|
|
2161
|
+
const runtimeEnv = options.runtimeEnv ?? {};
|
|
2162
|
+
for (const key of options.includeRuntimeKeys ?? []) {
|
|
2163
|
+
if (key in merged) continue;
|
|
2164
|
+
const value = runtimeEnv[key];
|
|
2165
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
2166
|
+
merged[key] = value;
|
|
2167
|
+
}
|
|
2168
|
+
const resolvedCommand = options.resolvedCommand?.trim();
|
|
2169
|
+
if (resolvedCommand) {
|
|
2170
|
+
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = redactCommandTextForLogs(resolvedCommand);
|
|
2171
|
+
}
|
|
2172
|
+
return redactEnvForLogs(merged);
|
|
2173
|
+
}
|
|
2174
|
+
function buildPaperclipEnv(agent) {
|
|
2175
|
+
const resolveHostForUrl = (rawHost) => {
|
|
2176
|
+
const host = rawHost.trim();
|
|
2177
|
+
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
|
2178
|
+
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
|
2179
|
+
return host;
|
|
2180
|
+
};
|
|
2181
|
+
const vars = {
|
|
2182
|
+
PAPERCLIP_AGENT_ID: agent.id,
|
|
2183
|
+
PAPERCLIP_COMPANY_ID: agent.companyId
|
|
2184
|
+
};
|
|
2185
|
+
const runtimeHost = resolveHostForUrl(
|
|
2186
|
+
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost"
|
|
2187
|
+
);
|
|
2188
|
+
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
|
2189
|
+
const apiUrl = process.env.PAPERCLIP_RUNTIME_API_URL ?? process.env.PAPERCLIP_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
|
|
2190
|
+
vars.PAPERCLIP_API_URL = apiUrl;
|
|
2191
|
+
return vars;
|
|
2192
|
+
}
|
|
2193
|
+
function applyPaperclipWorkspaceEnv(env, input) {
|
|
2194
|
+
const mappings = [
|
|
2195
|
+
["PAPERCLIP_WORKSPACE_CWD", input.workspaceCwd],
|
|
2196
|
+
["PAPERCLIP_WORKSPACE_SOURCE", input.workspaceSource],
|
|
2197
|
+
["PAPERCLIP_WORKSPACE_STRATEGY", input.workspaceStrategy],
|
|
2198
|
+
["PAPERCLIP_WORKSPACE_ID", input.workspaceId],
|
|
2199
|
+
["PAPERCLIP_WORKSPACE_REPO_URL", input.workspaceRepoUrl],
|
|
2200
|
+
["PAPERCLIP_WORKSPACE_REPO_REF", input.workspaceRepoRef],
|
|
2201
|
+
["PAPERCLIP_WORKSPACE_BRANCH", input.workspaceBranch],
|
|
2202
|
+
["PAPERCLIP_WORKSPACE_WORKTREE_PATH", input.workspaceWorktreePath],
|
|
2203
|
+
["AGENT_HOME", input.agentHome]
|
|
2204
|
+
];
|
|
2205
|
+
for (const [key, value] of mappings) {
|
|
2206
|
+
if (typeof value === "string" && value.length > 0) {
|
|
2207
|
+
env[key] = value;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
return env;
|
|
2211
|
+
}
|
|
2212
|
+
function shapePaperclipWorkspaceEnvForExecution(input) {
|
|
2213
|
+
const workspaceCwd = typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0 ? input.workspaceCwd.trim() : null;
|
|
2214
|
+
const workspaceWorktreePath = typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0 ? input.workspaceWorktreePath.trim() : null;
|
|
2215
|
+
const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : [];
|
|
2216
|
+
if (!input.executionTargetIsRemote) {
|
|
2217
|
+
return {
|
|
2218
|
+
workspaceCwd,
|
|
2219
|
+
workspaceWorktreePath,
|
|
2220
|
+
workspaceHints
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
const executionCwd = typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0 ? input.executionCwd.trim() : null;
|
|
2224
|
+
if (executionCwd === null) {
|
|
2225
|
+
console.warn(
|
|
2226
|
+
"[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; stripping workspaceCwd to avoid leaking local paths into the remote environment."
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
const realizedWorkspaceCwd = executionCwd;
|
|
2230
|
+
const localWorkspaceCwd = workspaceCwd ? path3.resolve(workspaceCwd) : null;
|
|
2231
|
+
const shapedWorkspaceHints = workspaceHints.map((hint) => {
|
|
2232
|
+
const nextHint = { ...hint };
|
|
2233
|
+
const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : "";
|
|
2234
|
+
if (!hintCwd) return nextHint;
|
|
2235
|
+
if (localWorkspaceCwd && path3.resolve(hintCwd) === localWorkspaceCwd) {
|
|
2236
|
+
if (realizedWorkspaceCwd) {
|
|
2237
|
+
nextHint.cwd = realizedWorkspaceCwd;
|
|
2238
|
+
} else {
|
|
2239
|
+
delete nextHint.cwd;
|
|
2240
|
+
}
|
|
2241
|
+
return nextHint;
|
|
2242
|
+
}
|
|
2243
|
+
delete nextHint.cwd;
|
|
2244
|
+
return nextHint;
|
|
2245
|
+
});
|
|
2246
|
+
return {
|
|
2247
|
+
workspaceCwd: realizedWorkspaceCwd,
|
|
2248
|
+
workspaceWorktreePath: null,
|
|
2249
|
+
workspaceHints: shapedWorkspaceHints
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
function rewriteWorkspaceCwdEnvVarsForExecution(input) {
|
|
2253
|
+
const nextEnv = Object.fromEntries(
|
|
2254
|
+
Object.entries(input.env).filter((entry) => typeof entry[1] === "string")
|
|
2255
|
+
);
|
|
2256
|
+
const localWorkspaceCwd = typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0 ? path3.resolve(input.workspaceCwd) : null;
|
|
2257
|
+
const remoteWorkspaceCwd = typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0 ? input.executionCwd.trim() : null;
|
|
2258
|
+
if (!input.executionTargetIsRemote || !localWorkspaceCwd || !remoteWorkspaceCwd) {
|
|
2259
|
+
return nextEnv;
|
|
2260
|
+
}
|
|
2261
|
+
for (const [key, value] of Object.entries(nextEnv)) {
|
|
2262
|
+
if (!key.endsWith("_WORKSPACE_CWD")) continue;
|
|
2263
|
+
const trimmed = value.trim();
|
|
2264
|
+
if (!trimmed) continue;
|
|
2265
|
+
if (path3.resolve(trimmed) !== localWorkspaceCwd) continue;
|
|
2266
|
+
nextEnv[key] = remoteWorkspaceCwd;
|
|
2267
|
+
}
|
|
2268
|
+
return nextEnv;
|
|
2269
|
+
}
|
|
2270
|
+
function refreshPaperclipWorkspaceEnvForExecution(input) {
|
|
2271
|
+
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
|
2272
|
+
workspaceCwd: input.workspaceCwd,
|
|
2273
|
+
workspaceWorktreePath: input.workspaceWorktreePath,
|
|
2274
|
+
workspaceHints: input.workspaceHints,
|
|
2275
|
+
executionTargetIsRemote: input.executionTargetIsRemote,
|
|
2276
|
+
executionCwd: input.executionCwd
|
|
2277
|
+
});
|
|
2278
|
+
delete input.env.PAPERCLIP_WORKSPACE_CWD;
|
|
2279
|
+
delete input.env.PAPERCLIP_WORKSPACE_WORKTREE_PATH;
|
|
2280
|
+
delete input.env.PAPERCLIP_WORKSPACES_JSON;
|
|
2281
|
+
applyPaperclipWorkspaceEnv(input.env, {
|
|
2282
|
+
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
|
2283
|
+
workspaceSource: input.workspaceSource,
|
|
2284
|
+
workspaceStrategy: input.workspaceStrategy,
|
|
2285
|
+
workspaceId: input.workspaceId,
|
|
2286
|
+
workspaceRepoUrl: input.workspaceRepoUrl,
|
|
2287
|
+
workspaceRepoRef: input.workspaceRepoRef,
|
|
2288
|
+
workspaceBranch: input.workspaceBranch,
|
|
2289
|
+
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
|
2290
|
+
agentHome: input.agentHome
|
|
2291
|
+
});
|
|
2292
|
+
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
|
2293
|
+
input.env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
|
2294
|
+
}
|
|
2295
|
+
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
|
|
2296
|
+
env: input.envConfig ?? {},
|
|
2297
|
+
workspaceCwd: input.workspaceCwd,
|
|
2298
|
+
executionCwd: shapedWorkspaceEnv.workspaceCwd,
|
|
2299
|
+
executionTargetIsRemote: input.executionTargetIsRemote
|
|
2300
|
+
});
|
|
2301
|
+
for (const [key, value] of Object.entries(shapedEnvConfig)) {
|
|
2302
|
+
input.env[key] = value;
|
|
2303
|
+
}
|
|
2304
|
+
return shapedWorkspaceEnv;
|
|
2305
|
+
}
|
|
2306
|
+
function sanitizeInheritedPaperclipEnv(baseEnv) {
|
|
2307
|
+
const env = { ...baseEnv };
|
|
2308
|
+
for (const key of Object.keys(env)) {
|
|
2309
|
+
if (!key.startsWith("PAPERCLIP_")) continue;
|
|
2310
|
+
if (key === "PAPERCLIP_RUNTIME_API_URL") continue;
|
|
2311
|
+
if (key === "PAPERCLIP_LISTEN_HOST") continue;
|
|
2312
|
+
if (key === "PAPERCLIP_LISTEN_PORT") continue;
|
|
2313
|
+
delete env[key];
|
|
2314
|
+
}
|
|
2315
|
+
return env;
|
|
2316
|
+
}
|
|
2317
|
+
function defaultPathForPlatform() {
|
|
2318
|
+
if (process.platform === "win32") {
|
|
2319
|
+
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
|
2320
|
+
}
|
|
2321
|
+
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
2322
|
+
}
|
|
2323
|
+
function windowsPathExts(env) {
|
|
2324
|
+
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
|
2325
|
+
}
|
|
2326
|
+
async function pathExists(candidate) {
|
|
2327
|
+
try {
|
|
2328
|
+
await fs3.access(candidate, process.platform === "win32" ? fsConstants3.F_OK : fsConstants3.X_OK);
|
|
2329
|
+
return true;
|
|
2330
|
+
} catch {
|
|
2331
|
+
return false;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
async function resolveCommandPath(command, cwd, env) {
|
|
2335
|
+
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
|
2336
|
+
if (hasPathSeparator) {
|
|
2337
|
+
const absolute = path3.isAbsolute(command) ? command : path3.resolve(cwd, command);
|
|
2338
|
+
return await pathExists(absolute) ? absolute : null;
|
|
2339
|
+
}
|
|
2340
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
2341
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
2342
|
+
const dirs = pathValue.split(delimiter).filter(Boolean);
|
|
2343
|
+
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
|
2344
|
+
const hasExtension = process.platform === "win32" && path3.extname(command).length > 0;
|
|
2345
|
+
for (const dir of dirs) {
|
|
2346
|
+
const candidates = process.platform === "win32" ? hasExtension ? [path3.join(dir, command)] : exts.map((ext) => path3.join(dir, `${command}${ext}`)) : [path3.join(dir, command)];
|
|
2347
|
+
for (const candidate of candidates) {
|
|
2348
|
+
if (await pathExists(candidate)) return candidate;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
async function resolveCommandForLogs(command, cwd, env, options = {}) {
|
|
2354
|
+
const remote = options.remoteExecution ?? null;
|
|
2355
|
+
if (remote) {
|
|
2356
|
+
return `ssh://${remote.username}@${remote.host}:${remote.port}/${remote.remoteCwd} :: ${command}`;
|
|
2357
|
+
}
|
|
2358
|
+
return await resolveCommandPath(command, cwd, env) ?? command;
|
|
2359
|
+
}
|
|
2360
|
+
function quoteForCmd(arg) {
|
|
2361
|
+
if (!arg.length) return '""';
|
|
2362
|
+
const escaped = arg.replace(/"/g, '""');
|
|
2363
|
+
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
|
2364
|
+
}
|
|
2365
|
+
function sanitizeSshRemoteEnv(env, inheritedEnv = process.env) {
|
|
2366
|
+
return sanitizeRemoteExecutionEnv(env, inheritedEnv);
|
|
2367
|
+
}
|
|
2368
|
+
function resolveWindowsCmdShell(env) {
|
|
2369
|
+
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
|
|
2370
|
+
return path3.join(fallbackRoot, "System32", "cmd.exe");
|
|
2371
|
+
}
|
|
2372
|
+
async function resolveSpawnTarget(command, args, cwd, env, options = {}) {
|
|
2373
|
+
const remote = options.remoteExecution ?? null;
|
|
2374
|
+
if (remote) {
|
|
2375
|
+
const sshResolved = await resolveCommandPath("ssh", process.cwd(), env);
|
|
2376
|
+
if (!sshResolved) {
|
|
2377
|
+
throw new Error('Command not found in PATH: "ssh"');
|
|
2378
|
+
}
|
|
2379
|
+
const spawnTarget = await buildSshSpawnTarget({
|
|
2380
|
+
spec: remote,
|
|
2381
|
+
command,
|
|
2382
|
+
args,
|
|
2383
|
+
env: Object.fromEntries(
|
|
2384
|
+
Object.entries(options.remoteEnv ?? {}).filter((entry) => typeof entry[1] === "string")
|
|
2385
|
+
)
|
|
2386
|
+
});
|
|
2387
|
+
return {
|
|
2388
|
+
command: sshResolved,
|
|
2389
|
+
args: spawnTarget.args,
|
|
2390
|
+
cwd: process.cwd(),
|
|
2391
|
+
cleanup: spawnTarget.cleanup
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
const resolved = await resolveCommandPath(command, cwd, env);
|
|
2395
|
+
const executable = resolved ?? command;
|
|
2396
|
+
if (process.platform !== "win32") {
|
|
2397
|
+
return { command: executable, args };
|
|
2398
|
+
}
|
|
2399
|
+
if (/\.(cmd|bat)$/i.test(executable)) {
|
|
2400
|
+
const shell = resolveWindowsCmdShell(env);
|
|
2401
|
+
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
|
2402
|
+
return {
|
|
2403
|
+
command: shell,
|
|
2404
|
+
args: ["/d", "/s", "/c", commandLine]
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
return { command: executable, args };
|
|
2408
|
+
}
|
|
2409
|
+
function ensurePathInEnv(env) {
|
|
2410
|
+
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
|
2411
|
+
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
|
2412
|
+
return { ...env, PATH: defaultPathForPlatform() };
|
|
2413
|
+
}
|
|
2414
|
+
async function ensureAbsoluteDirectory(cwd, opts = {}) {
|
|
2415
|
+
if (!path3.isAbsolute(cwd)) {
|
|
2416
|
+
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
|
2417
|
+
}
|
|
2418
|
+
const assertDirectory = async () => {
|
|
2419
|
+
const stats = await fs3.stat(cwd);
|
|
2420
|
+
if (!stats.isDirectory()) {
|
|
2421
|
+
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
try {
|
|
2425
|
+
await assertDirectory();
|
|
2426
|
+
return;
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
const code = err.code;
|
|
2429
|
+
if (!opts.createIfMissing || code !== "ENOENT") {
|
|
2430
|
+
if (code === "ENOENT") {
|
|
2431
|
+
throw new Error(`Working directory does not exist: "${cwd}"`);
|
|
2432
|
+
}
|
|
2433
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
try {
|
|
2437
|
+
await fs3.mkdir(cwd, { recursive: true });
|
|
2438
|
+
await assertDirectory();
|
|
2439
|
+
} catch (err) {
|
|
2440
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
2441
|
+
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
async function resolvePaperclipSkillsDir(moduleDir, additionalCandidates = []) {
|
|
2445
|
+
const candidates = [
|
|
2446
|
+
...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path3.resolve(moduleDir, relativePath)),
|
|
2447
|
+
...additionalCandidates.map((candidate) => path3.resolve(candidate))
|
|
2448
|
+
];
|
|
2449
|
+
const seenRoots = /* @__PURE__ */ new Set();
|
|
2450
|
+
for (const root of candidates) {
|
|
2451
|
+
if (seenRoots.has(root)) continue;
|
|
2452
|
+
seenRoots.add(root);
|
|
2453
|
+
const isDirectory = await fs3.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
|
|
2454
|
+
if (isDirectory) return root;
|
|
2455
|
+
}
|
|
2456
|
+
return null;
|
|
2457
|
+
}
|
|
2458
|
+
async function listPaperclipSkillEntries(moduleDir, additionalCandidates = []) {
|
|
2459
|
+
const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates);
|
|
2460
|
+
if (!root) return [];
|
|
2461
|
+
try {
|
|
2462
|
+
const entries = await fs3.readdir(root, { withFileTypes: true });
|
|
2463
|
+
const dirs = entries.filter((entry) => entry.isDirectory());
|
|
2464
|
+
return dirs.map((entry) => ({
|
|
2465
|
+
key: `paperclipai/paperclip/${entry.name}`,
|
|
2466
|
+
runtimeName: entry.name,
|
|
2467
|
+
source: path3.join(root, entry.name)
|
|
2468
|
+
}));
|
|
2469
|
+
} catch {
|
|
2470
|
+
return [];
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
async function readInstalledSkillTargets(skillsHome) {
|
|
2474
|
+
const entries = await fs3.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
|
2475
|
+
const out = /* @__PURE__ */ new Map();
|
|
2476
|
+
for (const entry of entries) {
|
|
2477
|
+
const fullPath = path3.join(skillsHome, entry.name);
|
|
2478
|
+
const linkedPath = entry.isSymbolicLink() ? await fs3.readlink(fullPath).catch(() => null) : null;
|
|
2479
|
+
out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
|
|
2480
|
+
}
|
|
2481
|
+
return out;
|
|
2482
|
+
}
|
|
2483
|
+
function buildRuntimeMountedSkillSnapshot(options) {
|
|
2484
|
+
const {
|
|
2485
|
+
adapterType,
|
|
2486
|
+
availableEntries,
|
|
2487
|
+
desiredSkills,
|
|
2488
|
+
configuredDetail,
|
|
2489
|
+
missingDetail = "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
2490
|
+
mode = "ephemeral",
|
|
2491
|
+
externalInstalled,
|
|
2492
|
+
externalLocationLabel,
|
|
2493
|
+
externalDetail = "Installed outside Paperclip management.",
|
|
2494
|
+
skillsHome
|
|
2495
|
+
} = options;
|
|
2496
|
+
const supported = options.supported ?? mode !== "unsupported";
|
|
2497
|
+
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
2498
|
+
const desiredSet = new Set(desiredSkills);
|
|
2499
|
+
const entries = [];
|
|
2500
|
+
const warnings = [...options.warnings ?? []];
|
|
2501
|
+
for (const available of availableEntries) {
|
|
2502
|
+
const desired = desiredSet.has(available.key);
|
|
2503
|
+
if (isPaperclipSkillSourceMissing(available)) {
|
|
2504
|
+
entries.push({
|
|
2505
|
+
key: available.key,
|
|
2506
|
+
runtimeName: available.runtimeName,
|
|
2507
|
+
versionId: available.versionId ?? null,
|
|
2508
|
+
currentVersionId: available.currentVersionId ?? null,
|
|
2509
|
+
desired,
|
|
2510
|
+
managed: true,
|
|
2511
|
+
state: "missing",
|
|
2512
|
+
sourcePath: null,
|
|
2513
|
+
targetPath: null,
|
|
2514
|
+
detail: resolvePaperclipSkillMissingDetail(available, missingDetail),
|
|
2515
|
+
...buildManagedSkillOrigin()
|
|
2516
|
+
});
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
const configured = supported && mode === "ephemeral" && desired;
|
|
2520
|
+
entries.push({
|
|
2521
|
+
key: available.key,
|
|
2522
|
+
runtimeName: available.runtimeName,
|
|
2523
|
+
versionId: available.versionId ?? null,
|
|
2524
|
+
currentVersionId: available.currentVersionId ?? null,
|
|
2525
|
+
desired,
|
|
2526
|
+
managed: true,
|
|
2527
|
+
state: configured ? "configured" : "available",
|
|
2528
|
+
sourcePath: available.source,
|
|
2529
|
+
targetPath: null,
|
|
2530
|
+
detail: desired ? configured ? resolveSkillDetail(configuredDetail, available) : resolveSkillDetail(
|
|
2531
|
+
options.unsupportedDetail ?? "Desired state is stored in Paperclip only; this adapter cannot apply skills at runtime.",
|
|
2532
|
+
available
|
|
2533
|
+
) : null,
|
|
2534
|
+
...buildManagedSkillOrigin()
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
for (const desiredSkill of desiredSkills) {
|
|
2538
|
+
if (availableByKey.has(desiredSkill)) continue;
|
|
2539
|
+
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
2540
|
+
entries.push({
|
|
2541
|
+
key: desiredSkill,
|
|
2542
|
+
runtimeName: null,
|
|
2543
|
+
desired: true,
|
|
2544
|
+
managed: true,
|
|
2545
|
+
state: "missing",
|
|
2546
|
+
sourcePath: null,
|
|
2547
|
+
targetPath: null,
|
|
2548
|
+
detail: missingDetail,
|
|
2549
|
+
origin: "external_unknown",
|
|
2550
|
+
originLabel: "External or unavailable",
|
|
2551
|
+
readOnly: false
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
if (externalInstalled) {
|
|
2555
|
+
for (const [name, installedEntry] of externalInstalled.entries()) {
|
|
2556
|
+
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
2557
|
+
entries.push({
|
|
2558
|
+
key: name,
|
|
2559
|
+
runtimeName: name,
|
|
2560
|
+
desired: false,
|
|
2561
|
+
managed: false,
|
|
2562
|
+
state: "external",
|
|
2563
|
+
origin: "user_installed",
|
|
2564
|
+
originLabel: "User-installed",
|
|
2565
|
+
locationLabel: skillLocationLabel(externalLocationLabel),
|
|
2566
|
+
readOnly: true,
|
|
2567
|
+
sourcePath: null,
|
|
2568
|
+
targetPath: installedEntry.targetPath ?? (skillsHome ? path3.join(skillsHome, name) : null),
|
|
2569
|
+
detail: externalDetail
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
2574
|
+
return {
|
|
2575
|
+
adapterType,
|
|
2576
|
+
supported,
|
|
2577
|
+
mode,
|
|
2578
|
+
desiredSkills,
|
|
2579
|
+
desiredSkillEntries: desiredSkills.map((key) => ({
|
|
2580
|
+
key,
|
|
2581
|
+
versionId: availableByKey.get(key)?.versionId ?? null
|
|
2582
|
+
})),
|
|
2583
|
+
entries,
|
|
2584
|
+
warnings
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
function buildPersistentSkillSnapshot(options) {
|
|
2588
|
+
const {
|
|
2589
|
+
adapterType,
|
|
2590
|
+
availableEntries,
|
|
2591
|
+
desiredSkills,
|
|
2592
|
+
installed,
|
|
2593
|
+
skillsHome,
|
|
2594
|
+
locationLabel,
|
|
2595
|
+
installedDetail,
|
|
2596
|
+
missingDetail,
|
|
2597
|
+
externalConflictDetail,
|
|
2598
|
+
externalDetail
|
|
2599
|
+
} = options;
|
|
2600
|
+
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
|
2601
|
+
const desiredSet = new Set(desiredSkills);
|
|
2602
|
+
const entries = [];
|
|
2603
|
+
const warnings = [...options.warnings ?? []];
|
|
2604
|
+
for (const available of availableEntries) {
|
|
2605
|
+
const installedEntry = installed.get(available.runtimeName) ?? null;
|
|
2606
|
+
const desired = desiredSet.has(available.key);
|
|
2607
|
+
if (isPaperclipSkillSourceMissing(available)) {
|
|
2608
|
+
entries.push({
|
|
2609
|
+
key: available.key,
|
|
2610
|
+
runtimeName: available.runtimeName,
|
|
2611
|
+
versionId: available.versionId ?? null,
|
|
2612
|
+
currentVersionId: available.currentVersionId ?? null,
|
|
2613
|
+
desired,
|
|
2614
|
+
managed: true,
|
|
2615
|
+
state: "missing",
|
|
2616
|
+
sourcePath: null,
|
|
2617
|
+
targetPath: path3.join(skillsHome, available.runtimeName),
|
|
2618
|
+
detail: resolvePaperclipSkillMissingDetail(
|
|
2619
|
+
available,
|
|
2620
|
+
missingDetail
|
|
2621
|
+
),
|
|
2622
|
+
...buildManagedSkillOrigin()
|
|
2623
|
+
});
|
|
2624
|
+
continue;
|
|
2625
|
+
}
|
|
2626
|
+
let state = "available";
|
|
2627
|
+
let managed = false;
|
|
2628
|
+
let detail = null;
|
|
2629
|
+
if (installedEntry?.targetPath === available.source) {
|
|
2630
|
+
managed = true;
|
|
2631
|
+
state = desired ? "installed" : "stale";
|
|
2632
|
+
detail = installedDetail ?? null;
|
|
2633
|
+
} else if (installedEntry) {
|
|
2634
|
+
state = "external";
|
|
2635
|
+
detail = desired ? externalConflictDetail : externalDetail;
|
|
2636
|
+
} else if (desired) {
|
|
2637
|
+
state = "missing";
|
|
2638
|
+
detail = missingDetail;
|
|
2639
|
+
}
|
|
2640
|
+
entries.push({
|
|
2641
|
+
key: available.key,
|
|
2642
|
+
runtimeName: available.runtimeName,
|
|
2643
|
+
versionId: available.versionId ?? null,
|
|
2644
|
+
currentVersionId: available.currentVersionId ?? null,
|
|
2645
|
+
desired,
|
|
2646
|
+
managed,
|
|
2647
|
+
state,
|
|
2648
|
+
sourcePath: available.source,
|
|
2649
|
+
targetPath: path3.join(skillsHome, available.runtimeName),
|
|
2650
|
+
detail,
|
|
2651
|
+
...buildManagedSkillOrigin()
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
for (const desiredSkill of desiredSkills) {
|
|
2655
|
+
if (availableByKey.has(desiredSkill)) continue;
|
|
2656
|
+
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
|
2657
|
+
entries.push({
|
|
2658
|
+
key: desiredSkill,
|
|
2659
|
+
runtimeName: null,
|
|
2660
|
+
desired: true,
|
|
2661
|
+
managed: true,
|
|
2662
|
+
state: "missing",
|
|
2663
|
+
sourcePath: null,
|
|
2664
|
+
targetPath: null,
|
|
2665
|
+
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
|
2666
|
+
origin: "external_unknown",
|
|
2667
|
+
originLabel: "External or unavailable",
|
|
2668
|
+
readOnly: false
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
for (const [name, installedEntry] of installed.entries()) {
|
|
2672
|
+
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
|
2673
|
+
entries.push({
|
|
2674
|
+
key: name,
|
|
2675
|
+
runtimeName: name,
|
|
2676
|
+
desired: false,
|
|
2677
|
+
managed: false,
|
|
2678
|
+
state: "external",
|
|
2679
|
+
origin: "user_installed",
|
|
2680
|
+
originLabel: "User-installed",
|
|
2681
|
+
locationLabel: skillLocationLabel(locationLabel),
|
|
2682
|
+
readOnly: true,
|
|
2683
|
+
sourcePath: null,
|
|
2684
|
+
targetPath: installedEntry.targetPath ?? path3.join(skillsHome, name),
|
|
2685
|
+
detail: externalDetail
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
2689
|
+
return {
|
|
2690
|
+
adapterType,
|
|
2691
|
+
supported: true,
|
|
2692
|
+
mode: "persistent",
|
|
2693
|
+
desiredSkills,
|
|
2694
|
+
desiredSkillEntries: desiredSkills.map((key) => ({
|
|
2695
|
+
key,
|
|
2696
|
+
versionId: availableByKey.get(key)?.versionId ?? null
|
|
2697
|
+
})),
|
|
2698
|
+
entries,
|
|
2699
|
+
warnings
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
function normalizeConfiguredPaperclipRuntimeSkills(value) {
|
|
2703
|
+
if (!Array.isArray(value)) return [];
|
|
2704
|
+
const out = [];
|
|
2705
|
+
for (const rawEntry of value) {
|
|
2706
|
+
const entry = parseObject(rawEntry);
|
|
2707
|
+
const key = asString(entry.key, asString(entry.name, "")).trim();
|
|
2708
|
+
const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
|
|
2709
|
+
const source = asString(entry.source, "").trim();
|
|
2710
|
+
if (!key || !runtimeName || !source) continue;
|
|
2711
|
+
out.push({
|
|
2712
|
+
key,
|
|
2713
|
+
runtimeName,
|
|
2714
|
+
source,
|
|
2715
|
+
versionId: typeof entry.versionId === "string" && entry.versionId.trim().length > 0 ? entry.versionId.trim() : null,
|
|
2716
|
+
currentVersionId: typeof entry.currentVersionId === "string" && entry.currentVersionId.trim().length > 0 ? entry.currentVersionId.trim() : null,
|
|
2717
|
+
sourceStatus: entry.sourceStatus === "missing" ? "missing" : "available",
|
|
2718
|
+
missingDetail: typeof entry.missingDetail === "string" && entry.missingDetail.trim().length > 0 ? entry.missingDetail.trim() : null
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
return out;
|
|
2722
|
+
}
|
|
2723
|
+
async function readPaperclipRuntimeSkillEntries(config, moduleDir, additionalCandidates = []) {
|
|
2724
|
+
const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills);
|
|
2725
|
+
if (configuredEntries.length > 0) return configuredEntries;
|
|
2726
|
+
return listPaperclipSkillEntries(moduleDir, additionalCandidates);
|
|
2727
|
+
}
|
|
2728
|
+
async function readPaperclipSkillMarkdown(moduleDir, skillKey) {
|
|
2729
|
+
const normalized = skillKey.trim().toLowerCase();
|
|
2730
|
+
if (!normalized) return null;
|
|
2731
|
+
const entries = await listPaperclipSkillEntries(moduleDir);
|
|
2732
|
+
const match = entries.find((entry) => entry.key === normalized);
|
|
2733
|
+
if (!match) return null;
|
|
2734
|
+
try {
|
|
2735
|
+
return await fs3.readFile(path3.join(match.source, "SKILL.md"), "utf8");
|
|
2736
|
+
} catch {
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
function readPaperclipSkillSyncPreference(config) {
|
|
2741
|
+
const raw = config.paperclipSkillSync;
|
|
2742
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2743
|
+
return { explicit: false, desiredSkills: [], desiredSkillEntries: [] };
|
|
2744
|
+
}
|
|
2745
|
+
const syncConfig = raw;
|
|
2746
|
+
const desiredValues = syncConfig.desiredSkills;
|
|
2747
|
+
const desired = Array.isArray(desiredValues) ? desiredValues.flatMap((value) => {
|
|
2748
|
+
if (typeof value === "string") {
|
|
2749
|
+
const key = value.trim();
|
|
2750
|
+
return key ? [{ key, versionId: null }] : [];
|
|
2751
|
+
}
|
|
2752
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
2753
|
+
const record = value;
|
|
2754
|
+
const key = typeof record.key === "string" ? record.key.trim() : "";
|
|
2755
|
+
if (!key) return [];
|
|
2756
|
+
const versionId = typeof record.versionId === "string" && record.versionId.trim() ? record.versionId.trim() : null;
|
|
2757
|
+
return [{ key, versionId }];
|
|
2758
|
+
}
|
|
2759
|
+
return [];
|
|
2760
|
+
}) : [];
|
|
2761
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2762
|
+
for (const entry of desired) {
|
|
2763
|
+
if (!byKey.has(entry.key)) byKey.set(entry.key, entry);
|
|
2764
|
+
}
|
|
2765
|
+
const desiredSkillEntries = Array.from(byKey.values());
|
|
2766
|
+
return {
|
|
2767
|
+
explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
|
|
2768
|
+
desiredSkills: desiredSkillEntries.map((entry) => entry.key),
|
|
2769
|
+
desiredSkillEntries
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
function canonicalizeDesiredPaperclipSkillReference(reference, availableEntries) {
|
|
2773
|
+
const normalizedReference = reference.trim().toLowerCase();
|
|
2774
|
+
if (!normalizedReference) return "";
|
|
2775
|
+
const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference);
|
|
2776
|
+
if (exactKey) return exactKey.key;
|
|
2777
|
+
const byRuntimeName = availableEntries.filter(
|
|
2778
|
+
(entry) => typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference
|
|
2779
|
+
);
|
|
2780
|
+
if (byRuntimeName.length === 1) return byRuntimeName[0].key;
|
|
2781
|
+
const slugMatches = availableEntries.filter(
|
|
2782
|
+
(entry) => entry.key.trim().toLowerCase().split("/").pop() === normalizedReference
|
|
2783
|
+
);
|
|
2784
|
+
if (slugMatches.length === 1) return slugMatches[0].key;
|
|
2785
|
+
return normalizedReference;
|
|
2786
|
+
}
|
|
2787
|
+
function resolvePaperclipDesiredSkillNames(config, availableEntries) {
|
|
2788
|
+
const preference = readPaperclipSkillSyncPreference(config);
|
|
2789
|
+
if (!preference.explicit) return [];
|
|
2790
|
+
const desiredSkills = preference.desiredSkills.map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries)).filter(Boolean);
|
|
2791
|
+
return Array.from(new Set(desiredSkills));
|
|
2792
|
+
}
|
|
2793
|
+
function writePaperclipSkillSyncPreference(config, desiredSkills) {
|
|
2794
|
+
const next = { ...config };
|
|
2795
|
+
const raw = next.paperclipSkillSync;
|
|
2796
|
+
const current = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? { ...raw } : {};
|
|
2797
|
+
const entries = desiredSkills.flatMap((value) => {
|
|
2798
|
+
if (typeof value === "string") {
|
|
2799
|
+
const key2 = value.trim();
|
|
2800
|
+
return key2 ? [{ key: key2, versionId: null }] : [];
|
|
2801
|
+
}
|
|
2802
|
+
const key = value.key.trim();
|
|
2803
|
+
if (!key) return [];
|
|
2804
|
+
return [{ key, versionId: value.versionId ?? null }];
|
|
2805
|
+
});
|
|
2806
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2807
|
+
for (const entry of entries) {
|
|
2808
|
+
if (!byKey.has(entry.key)) byKey.set(entry.key, entry);
|
|
2809
|
+
}
|
|
2810
|
+
const normalized = Array.from(byKey.values());
|
|
2811
|
+
current.desiredSkills = normalized.some((entry) => entry.versionId) ? normalized : normalized.map((entry) => entry.key);
|
|
2812
|
+
next.paperclipSkillSync = current;
|
|
2813
|
+
return next;
|
|
2814
|
+
}
|
|
2815
|
+
async function ensurePaperclipSkillSymlink(source, target, linkSkill = (linkSource, linkTarget) => fs3.symlink(linkSource, linkTarget)) {
|
|
2816
|
+
const existing = await fs3.lstat(target).catch(() => null);
|
|
2817
|
+
if (!existing) {
|
|
2818
|
+
await linkSkill(source, target);
|
|
2819
|
+
return "created";
|
|
2820
|
+
}
|
|
2821
|
+
if (!existing.isSymbolicLink()) {
|
|
2822
|
+
return "skipped";
|
|
2823
|
+
}
|
|
2824
|
+
const linkedPath = await fs3.readlink(target).catch(() => null);
|
|
2825
|
+
if (!linkedPath) return "skipped";
|
|
2826
|
+
const resolvedLinkedPath = path3.resolve(path3.dirname(target), linkedPath);
|
|
2827
|
+
if (resolvedLinkedPath === source) {
|
|
2828
|
+
return "skipped";
|
|
2829
|
+
}
|
|
2830
|
+
const linkedPathExists = await fs3.stat(resolvedLinkedPath).then(() => true).catch(() => false);
|
|
2831
|
+
if (linkedPathExists) {
|
|
2832
|
+
return "skipped";
|
|
2833
|
+
}
|
|
2834
|
+
await fs3.unlink(target);
|
|
2835
|
+
await linkSkill(source, target);
|
|
2836
|
+
return "repaired";
|
|
2837
|
+
}
|
|
2838
|
+
async function hashSkillDirectory(root) {
|
|
2839
|
+
const hash = createHash2("sha256");
|
|
2840
|
+
async function visit(candidate, relativePath) {
|
|
2841
|
+
const stat = await fs3.lstat(candidate);
|
|
2842
|
+
if (stat.isSymbolicLink()) {
|
|
2843
|
+
hash.update(`symlink:${relativePath}
|
|
2844
|
+
`);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
if (stat.isDirectory()) {
|
|
2848
|
+
hash.update(`dir:${relativePath}
|
|
2849
|
+
`);
|
|
2850
|
+
const entries = await fs3.readdir(candidate, { withFileTypes: true });
|
|
2851
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
2852
|
+
for (const entry of entries) {
|
|
2853
|
+
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
2854
|
+
await visit(path3.join(candidate, entry.name), childRelativePath);
|
|
2855
|
+
}
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
if (stat.isFile()) {
|
|
2859
|
+
hash.update(`file:${relativePath}:${stat.mode}
|
|
2860
|
+
`);
|
|
2861
|
+
hash.update(await fs3.readFile(candidate));
|
|
2862
|
+
hash.update("\n");
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
hash.update(`other:${relativePath}:${stat.mode}
|
|
2866
|
+
`);
|
|
2867
|
+
}
|
|
2868
|
+
await visit(root, "");
|
|
2869
|
+
return hash.digest("hex");
|
|
2870
|
+
}
|
|
2871
|
+
async function materializedSkillFingerprintMatches(targetRoot, sourceFingerprint) {
|
|
2872
|
+
try {
|
|
2873
|
+
const raw = JSON.parse(await fs3.readFile(path3.join(targetRoot, MATERIALIZED_SKILL_SENTINEL), "utf8"));
|
|
2874
|
+
const parsed = parseObject(raw);
|
|
2875
|
+
return parsed.version === 1 && parsed.sourceFingerprint === sourceFingerprint;
|
|
2876
|
+
} catch {
|
|
2877
|
+
return false;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
async function acquireMaterializeLock(lockDir) {
|
|
2881
|
+
await fs3.mkdir(path3.dirname(lockDir), { recursive: true });
|
|
2882
|
+
const deadline = Date.now() + MATERIALIZED_SKILL_LOCK_STALE_MS;
|
|
2883
|
+
while (true) {
|
|
2884
|
+
try {
|
|
2885
|
+
await fs3.mkdir(lockDir);
|
|
2886
|
+
await fs3.writeFile(
|
|
2887
|
+
path3.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER),
|
|
2888
|
+
`${JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
2889
|
+
`,
|
|
2890
|
+
"utf8"
|
|
2891
|
+
);
|
|
2892
|
+
return async () => {
|
|
2893
|
+
await fs3.rm(lockDir, { recursive: true, force: true });
|
|
2894
|
+
};
|
|
2895
|
+
} catch (err) {
|
|
2896
|
+
const code = err && typeof err === "object" ? err.code : null;
|
|
2897
|
+
if (code !== "EEXIST") throw err;
|
|
2898
|
+
if (await removeStaleMaterializeLock(lockDir, MATERIALIZED_SKILL_LOCK_STALE_MS)) continue;
|
|
2899
|
+
if (Date.now() >= deadline) {
|
|
2900
|
+
throw new Error(`Timed out waiting for Paperclip skill materialization lock at ${lockDir}`);
|
|
2901
|
+
}
|
|
2902
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
function isPidAlive(pid) {
|
|
2907
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
2908
|
+
try {
|
|
2909
|
+
process.kill(pid, 0);
|
|
2910
|
+
return true;
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
const code = err && typeof err === "object" ? err.code : null;
|
|
2913
|
+
return code === "EPERM";
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
async function removeStaleMaterializeLock(lockDir, staleMs) {
|
|
2917
|
+
const ownerPath = path3.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER);
|
|
2918
|
+
let shouldRemove = false;
|
|
2919
|
+
try {
|
|
2920
|
+
const raw = JSON.parse(await fs3.readFile(ownerPath, "utf8"));
|
|
2921
|
+
const owner = parseObject(raw);
|
|
2922
|
+
const pid = typeof owner.pid === "number" ? owner.pid : 0;
|
|
2923
|
+
const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : Number.NaN;
|
|
2924
|
+
const ageMs = Number.isFinite(createdAt) ? Date.now() - createdAt : staleMs + 1;
|
|
2925
|
+
shouldRemove = !isPidAlive(pid) || ageMs > staleMs;
|
|
2926
|
+
} catch {
|
|
2927
|
+
const stat = await fs3.stat(lockDir).catch(() => null);
|
|
2928
|
+
shouldRemove = !stat || Date.now() - stat.mtimeMs > staleMs;
|
|
2929
|
+
}
|
|
2930
|
+
if (!shouldRemove) return false;
|
|
2931
|
+
await fs3.rm(lockDir, { recursive: true, force: true }).catch(() => {
|
|
2932
|
+
});
|
|
2933
|
+
return true;
|
|
2934
|
+
}
|
|
2935
|
+
async function materializePaperclipSkillCopy(source, target) {
|
|
2936
|
+
const sourceRoot = path3.resolve(source);
|
|
2937
|
+
const targetRoot = path3.resolve(target);
|
|
2938
|
+
const relativeTarget = path3.relative(sourceRoot, targetRoot);
|
|
2939
|
+
const relativeSource = path3.relative(targetRoot, sourceRoot);
|
|
2940
|
+
if (!relativeTarget || !relativeTarget.startsWith("..") && !path3.isAbsolute(relativeTarget) || !relativeSource || !relativeSource.startsWith("..") && !path3.isAbsolute(relativeSource)) {
|
|
2941
|
+
throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants.");
|
|
2942
|
+
}
|
|
2943
|
+
const rootStat = await fs3.lstat(sourceRoot);
|
|
2944
|
+
if (rootStat.isSymbolicLink()) {
|
|
2945
|
+
throw new Error("Refusing to materialize a skill root that is itself a symlink.");
|
|
2946
|
+
}
|
|
2947
|
+
if (!rootStat.isDirectory()) {
|
|
2948
|
+
throw new Error("Paperclip skills must be directories.");
|
|
2949
|
+
}
|
|
2950
|
+
const result = {
|
|
2951
|
+
copiedFiles: 0,
|
|
2952
|
+
skippedSymlinks: []
|
|
2953
|
+
};
|
|
2954
|
+
const lockDir = `${targetRoot}.lock`;
|
|
2955
|
+
const releaseLock = await acquireMaterializeLock(lockDir);
|
|
2956
|
+
const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID2()}`;
|
|
2957
|
+
async function copyEntry(sourcePath, targetPath, relativePath) {
|
|
2958
|
+
const stat = await fs3.lstat(sourcePath);
|
|
2959
|
+
if (stat.isSymbolicLink()) {
|
|
2960
|
+
result.skippedSymlinks.push(relativePath || ".");
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
if (stat.isDirectory()) {
|
|
2964
|
+
await fs3.mkdir(targetPath, { recursive: true });
|
|
2965
|
+
const entries = await fs3.readdir(sourcePath, { withFileTypes: true });
|
|
2966
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
2967
|
+
for (const entry of entries) {
|
|
2968
|
+
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
2969
|
+
await copyEntry(path3.join(sourcePath, entry.name), path3.join(targetPath, entry.name), childRelativePath);
|
|
2970
|
+
}
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (stat.isFile()) {
|
|
2974
|
+
await fs3.mkdir(path3.dirname(targetPath), { recursive: true });
|
|
2975
|
+
await fs3.copyFile(sourcePath, targetPath, fsConstants3.COPYFILE_FICLONE).catch(async () => {
|
|
2976
|
+
await fs3.copyFile(sourcePath, targetPath);
|
|
2977
|
+
});
|
|
2978
|
+
await fs3.chmod(targetPath, stat.mode).catch(() => {
|
|
2979
|
+
});
|
|
2980
|
+
result.copiedFiles += 1;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
try {
|
|
2984
|
+
const sourceFingerprint = await hashSkillDirectory(sourceRoot);
|
|
2985
|
+
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
|
|
2986
|
+
await copyEntry(sourceRoot, tempRoot, "");
|
|
2987
|
+
await fs3.writeFile(
|
|
2988
|
+
path3.join(tempRoot, MATERIALIZED_SKILL_SENTINEL),
|
|
2989
|
+
`${JSON.stringify({
|
|
2990
|
+
version: 1,
|
|
2991
|
+
sourceFingerprint,
|
|
2992
|
+
copiedFiles: result.copiedFiles,
|
|
2993
|
+
skippedSymlinks: result.skippedSymlinks
|
|
2994
|
+
}, null, 2)}
|
|
2995
|
+
`,
|
|
2996
|
+
"utf8"
|
|
2997
|
+
);
|
|
2998
|
+
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
|
|
2999
|
+
await fs3.rm(targetRoot, { recursive: true, force: true });
|
|
3000
|
+
await fs3.rename(tempRoot, targetRoot);
|
|
3001
|
+
return result;
|
|
3002
|
+
} finally {
|
|
3003
|
+
await fs3.rm(tempRoot, { recursive: true, force: true }).catch(() => {
|
|
3004
|
+
});
|
|
3005
|
+
await releaseLock();
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
async function removeMaintainerOnlySkillSymlinks(skillsHome, allowedSkillNames) {
|
|
3009
|
+
const allowed = new Set(Array.from(allowedSkillNames));
|
|
3010
|
+
try {
|
|
3011
|
+
const entries = await fs3.readdir(skillsHome, { withFileTypes: true });
|
|
3012
|
+
const removed = [];
|
|
3013
|
+
for (const entry of entries) {
|
|
3014
|
+
if (allowed.has(entry.name)) continue;
|
|
3015
|
+
const target = path3.join(skillsHome, entry.name);
|
|
3016
|
+
const existing = await fs3.lstat(target).catch(() => null);
|
|
3017
|
+
if (!existing?.isSymbolicLink()) continue;
|
|
3018
|
+
const linkedPath = await fs3.readlink(target).catch(() => null);
|
|
3019
|
+
if (!linkedPath) continue;
|
|
3020
|
+
const resolvedLinkedPath = path3.isAbsolute(linkedPath) ? linkedPath : path3.resolve(path3.dirname(target), linkedPath);
|
|
3021
|
+
if (!isMaintainerOnlySkillTarget(linkedPath) && !isMaintainerOnlySkillTarget(resolvedLinkedPath)) {
|
|
3022
|
+
continue;
|
|
3023
|
+
}
|
|
3024
|
+
await fs3.unlink(target);
|
|
3025
|
+
removed.push(entry.name);
|
|
3026
|
+
}
|
|
3027
|
+
return removed;
|
|
3028
|
+
} catch {
|
|
3029
|
+
return [];
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
async function ensureCommandResolvable(command, cwd, env, options = {}) {
|
|
3033
|
+
if (options.remoteExecution) {
|
|
3034
|
+
const resolvedSsh = await resolveCommandPath("ssh", process.cwd(), env);
|
|
3035
|
+
if (resolvedSsh) return;
|
|
3036
|
+
throw new Error('Command not found in PATH: "ssh"');
|
|
3037
|
+
}
|
|
3038
|
+
const resolved = await resolveCommandPath(command, cwd, env);
|
|
3039
|
+
if (resolved) return;
|
|
3040
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
3041
|
+
const absolute = path3.isAbsolute(command) ? command : path3.resolve(cwd, command);
|
|
3042
|
+
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
|
3043
|
+
}
|
|
3044
|
+
throw new Error(`Command not found in PATH: "${command}"`);
|
|
3045
|
+
}
|
|
3046
|
+
async function runChildProcess(runId, command, args, opts) {
|
|
3047
|
+
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
|
3048
|
+
return new Promise((resolve, reject) => {
|
|
3049
|
+
const rawMerged = {
|
|
3050
|
+
...sanitizeInheritedPaperclipEnv(process.env),
|
|
3051
|
+
...opts.env
|
|
3052
|
+
};
|
|
3053
|
+
const CLAUDE_CODE_NESTING_VARS = [
|
|
3054
|
+
"CLAUDECODE",
|
|
3055
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
3056
|
+
"CLAUDE_CODE_SESSION",
|
|
3057
|
+
"CLAUDE_CODE_PARENT_SESSION"
|
|
3058
|
+
];
|
|
3059
|
+
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
|
3060
|
+
delete rawMerged[key];
|
|
3061
|
+
}
|
|
3062
|
+
const mergedEnv = ensurePathInEnv(rawMerged);
|
|
3063
|
+
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv, {
|
|
3064
|
+
remoteExecution: opts.remoteExecution ?? null,
|
|
3065
|
+
remoteEnv: opts.remoteExecution ? opts.env : null
|
|
3066
|
+
}).then((target) => {
|
|
3067
|
+
const child = spawn2(target.command, target.args, {
|
|
3068
|
+
cwd: target.cwd ?? opts.cwd,
|
|
3069
|
+
env: mergedEnv,
|
|
3070
|
+
detached: process.platform !== "win32",
|
|
3071
|
+
shell: false,
|
|
3072
|
+
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"]
|
|
3073
|
+
});
|
|
3074
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3075
|
+
const processGroupId = resolveProcessGroupId(child);
|
|
3076
|
+
const spawnPersistPromise = typeof child.pid === "number" && child.pid > 0 && opts.onSpawn ? opts.onSpawn({ pid: child.pid, processGroupId, startedAt }).catch((err) => {
|
|
3077
|
+
onLogError(err, runId, "failed to record child process metadata");
|
|
3078
|
+
}) : Promise.resolve();
|
|
3079
|
+
runningProcesses.set(runId, { child, graceSec: opts.graceSec, processGroupId });
|
|
3080
|
+
let timedOut = false;
|
|
3081
|
+
let stdout = "";
|
|
3082
|
+
let stderr = "";
|
|
3083
|
+
let logChain = Promise.resolve();
|
|
3084
|
+
let terminalResultSeen = false;
|
|
3085
|
+
let terminalCleanupStarted = false;
|
|
3086
|
+
let terminalCleanupTimer = null;
|
|
3087
|
+
let terminalCleanupKillTimer = null;
|
|
3088
|
+
let terminalResultStdoutScanOffset = 0;
|
|
3089
|
+
let terminalResultStderrScanOffset = 0;
|
|
3090
|
+
const clearTerminalCleanupTimers = () => {
|
|
3091
|
+
if (terminalCleanupTimer) clearTimeout(terminalCleanupTimer);
|
|
3092
|
+
if (terminalCleanupKillTimer) clearTimeout(terminalCleanupKillTimer);
|
|
3093
|
+
terminalCleanupTimer = null;
|
|
3094
|
+
terminalCleanupKillTimer = null;
|
|
3095
|
+
};
|
|
3096
|
+
const maybeArmTerminalResultCleanup = () => {
|
|
3097
|
+
const terminalCleanup = opts.terminalResultCleanup;
|
|
3098
|
+
if (!terminalCleanup || terminalCleanupStarted || timedOut) return;
|
|
3099
|
+
if (!terminalResultSeen) {
|
|
3100
|
+
const stdoutStart = Math.max(0, terminalResultStdoutScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
|
|
3101
|
+
const stderrStart = Math.max(0, terminalResultStderrScanOffset - TERMINAL_RESULT_SCAN_OVERLAP_CHARS);
|
|
3102
|
+
const scanOutput = {
|
|
3103
|
+
stdout: stdout.slice(stdoutStart),
|
|
3104
|
+
stderr: stderr.slice(stderrStart)
|
|
3105
|
+
};
|
|
3106
|
+
terminalResultStdoutScanOffset = stdout.length;
|
|
3107
|
+
terminalResultStderrScanOffset = stderr.length;
|
|
3108
|
+
if (scanOutput.stdout.length === 0 && scanOutput.stderr.length === 0) return;
|
|
3109
|
+
try {
|
|
3110
|
+
terminalResultSeen = terminalCleanup.hasTerminalResult(scanOutput);
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
onLogError(err, runId, "failed to inspect terminal adapter output");
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
if (!terminalResultSeen) return;
|
|
3116
|
+
if (terminalCleanupTimer) return;
|
|
3117
|
+
const graceMs = Math.max(0, terminalCleanup.graceMs ?? 5e3);
|
|
3118
|
+
terminalCleanupTimer = setTimeout(() => {
|
|
3119
|
+
terminalCleanupTimer = null;
|
|
3120
|
+
if (terminalCleanupStarted || timedOut) return;
|
|
3121
|
+
terminalCleanupStarted = true;
|
|
3122
|
+
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
|
3123
|
+
terminalCleanupKillTimer = setTimeout(() => {
|
|
3124
|
+
terminalCleanupKillTimer = null;
|
|
3125
|
+
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
|
3126
|
+
}, Math.max(1, opts.graceSec) * 1e3);
|
|
3127
|
+
}, graceMs);
|
|
3128
|
+
};
|
|
3129
|
+
const timeout = opts.timeoutSec > 0 ? setTimeout(() => {
|
|
3130
|
+
timedOut = true;
|
|
3131
|
+
clearTerminalCleanupTimers();
|
|
3132
|
+
signalRunningProcess({ child, processGroupId }, "SIGTERM");
|
|
3133
|
+
setTimeout(() => {
|
|
3134
|
+
signalRunningProcess({ child, processGroupId }, "SIGKILL");
|
|
3135
|
+
}, Math.max(1, opts.graceSec) * 1e3);
|
|
3136
|
+
}, opts.timeoutSec * 1e3) : null;
|
|
3137
|
+
child.stdout?.on("data", (chunk) => {
|
|
3138
|
+
const readable = child.stdout;
|
|
3139
|
+
if (!readable) return;
|
|
3140
|
+
readable.pause();
|
|
3141
|
+
const text = String(chunk);
|
|
3142
|
+
stdout = appendWithCap(stdout, text);
|
|
3143
|
+
maybeArmTerminalResultCleanup();
|
|
3144
|
+
logChain = logChain.then(() => opts.onLog("stdout", text)).catch((err) => onLogError(err, runId, "failed to append stdout log chunk")).finally(() => {
|
|
3145
|
+
maybeArmTerminalResultCleanup();
|
|
3146
|
+
resumeReadable(readable);
|
|
3147
|
+
});
|
|
3148
|
+
});
|
|
3149
|
+
child.stderr?.on("data", (chunk) => {
|
|
3150
|
+
const readable = child.stderr;
|
|
3151
|
+
if (!readable) return;
|
|
3152
|
+
readable.pause();
|
|
3153
|
+
const text = String(chunk);
|
|
3154
|
+
stderr = appendWithCap(stderr, text);
|
|
3155
|
+
maybeArmTerminalResultCleanup();
|
|
3156
|
+
logChain = logChain.then(() => opts.onLog("stderr", text)).catch((err) => onLogError(err, runId, "failed to append stderr log chunk")).finally(() => {
|
|
3157
|
+
maybeArmTerminalResultCleanup();
|
|
3158
|
+
resumeReadable(readable);
|
|
3159
|
+
});
|
|
3160
|
+
});
|
|
3161
|
+
const stdin = child.stdin;
|
|
3162
|
+
if (opts.stdin != null && stdin) {
|
|
3163
|
+
void spawnPersistPromise.finally(() => {
|
|
3164
|
+
if (child.killed || stdin.destroyed) return;
|
|
3165
|
+
stdin.write(opts.stdin);
|
|
3166
|
+
stdin.end();
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
child.on("error", (err) => {
|
|
3170
|
+
if (timeout) clearTimeout(timeout);
|
|
3171
|
+
clearTerminalCleanupTimers();
|
|
3172
|
+
runningProcesses.delete(runId);
|
|
3173
|
+
void target.cleanup?.();
|
|
3174
|
+
const errno = err.code;
|
|
3175
|
+
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
|
3176
|
+
const msg = errno === "ENOENT" ? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).` : `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
|
3177
|
+
reject(new Error(msg));
|
|
3178
|
+
});
|
|
3179
|
+
child.on("exit", () => {
|
|
3180
|
+
maybeArmTerminalResultCleanup();
|
|
3181
|
+
});
|
|
3182
|
+
child.on("close", (code, signal) => {
|
|
3183
|
+
if (timeout) clearTimeout(timeout);
|
|
3184
|
+
clearTerminalCleanupTimers();
|
|
3185
|
+
runningProcesses.delete(runId);
|
|
3186
|
+
void logChain.finally(() => {
|
|
3187
|
+
void Promise.resolve().then(() => target.cleanup?.()).finally(() => {
|
|
3188
|
+
resolve({
|
|
3189
|
+
exitCode: code,
|
|
3190
|
+
signal,
|
|
3191
|
+
timedOut,
|
|
3192
|
+
stdout,
|
|
3193
|
+
stderr,
|
|
3194
|
+
pid: child.pid ?? null,
|
|
3195
|
+
startedAt
|
|
3196
|
+
});
|
|
3197
|
+
});
|
|
3198
|
+
});
|
|
3199
|
+
});
|
|
3200
|
+
}).catch(reject);
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
export {
|
|
3205
|
+
createRuntimeProgressReporter,
|
|
3206
|
+
shouldExcludePath,
|
|
3207
|
+
captureDirectorySnapshot,
|
|
3208
|
+
mergeDirectoryWithBaseline,
|
|
3209
|
+
createSshCommandManagedRuntimeRunner,
|
|
3210
|
+
shellQuote,
|
|
3211
|
+
parseSshRemoteExecutionSpec,
|
|
3212
|
+
runSshCommand,
|
|
3213
|
+
syncDirectoryToSsh,
|
|
3214
|
+
prepareWorkspaceForSshExecution,
|
|
3215
|
+
restoreWorkspaceFromSshExecution,
|
|
3216
|
+
sanitizeRemoteExecutionEnv,
|
|
3217
|
+
runningProcesses,
|
|
3218
|
+
MAX_CAPTURE_BYTES,
|
|
3219
|
+
MAX_EXCERPT_BYTES,
|
|
3220
|
+
resolvePaperclipInstanceRootForAdapter,
|
|
3221
|
+
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
3222
|
+
WATCHDOG_DEFAULT_MANDATE,
|
|
3223
|
+
parseObject,
|
|
3224
|
+
asString,
|
|
3225
|
+
asNumber,
|
|
3226
|
+
asBoolean,
|
|
3227
|
+
asStringArray,
|
|
3228
|
+
parseJson,
|
|
3229
|
+
appendWithCap,
|
|
3230
|
+
appendWithByteCap,
|
|
3231
|
+
resolvePathValue,
|
|
3232
|
+
renderTemplate,
|
|
3233
|
+
joinPromptSections,
|
|
3234
|
+
normalizePaperclipWakePayload,
|
|
3235
|
+
stringifyPaperclipWakePayload,
|
|
3236
|
+
readPaperclipIssueWorkModeFromContext,
|
|
3237
|
+
renderPaperclipWakePrompt,
|
|
3238
|
+
redactEnvForLogs,
|
|
3239
|
+
redactCommandTextForLogs,
|
|
3240
|
+
buildInvocationEnvForLogs,
|
|
3241
|
+
buildPaperclipEnv,
|
|
3242
|
+
applyPaperclipWorkspaceEnv,
|
|
3243
|
+
shapePaperclipWorkspaceEnvForExecution,
|
|
3244
|
+
rewriteWorkspaceCwdEnvVarsForExecution,
|
|
3245
|
+
refreshPaperclipWorkspaceEnvForExecution,
|
|
3246
|
+
sanitizeInheritedPaperclipEnv,
|
|
3247
|
+
defaultPathForPlatform,
|
|
3248
|
+
resolveCommandForLogs,
|
|
3249
|
+
sanitizeSshRemoteEnv,
|
|
3250
|
+
ensurePathInEnv,
|
|
3251
|
+
ensureAbsoluteDirectory,
|
|
3252
|
+
resolvePaperclipSkillsDir,
|
|
3253
|
+
listPaperclipSkillEntries,
|
|
3254
|
+
readInstalledSkillTargets,
|
|
3255
|
+
buildRuntimeMountedSkillSnapshot,
|
|
3256
|
+
buildPersistentSkillSnapshot,
|
|
3257
|
+
readPaperclipRuntimeSkillEntries,
|
|
3258
|
+
readPaperclipSkillMarkdown,
|
|
3259
|
+
readPaperclipSkillSyncPreference,
|
|
3260
|
+
resolvePaperclipDesiredSkillNames,
|
|
3261
|
+
writePaperclipSkillSyncPreference,
|
|
3262
|
+
ensurePaperclipSkillSymlink,
|
|
3263
|
+
materializePaperclipSkillCopy,
|
|
3264
|
+
removeMaintainerOnlySkillSymlinks,
|
|
3265
|
+
ensureCommandResolvable,
|
|
3266
|
+
runChildProcess
|
|
3267
|
+
};
|