@penclipai/adapter-utils 2026.704.0 → 2026.714.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/acpx-engine/cli.d.ts +2 -0
- package/dist/acpx-engine/cli.d.ts.map +1 -0
- package/dist/acpx-engine/cli.js +128 -0
- package/dist/acpx-engine/cli.js.map +1 -0
- package/dist/acpx-engine/constants.d.ts +16 -0
- package/dist/acpx-engine/constants.d.ts.map +1 -0
- package/dist/acpx-engine/constants.js +18 -0
- package/dist/acpx-engine/constants.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +57 -0
- package/dist/acpx-engine/execute.d.ts.map +1 -0
- package/dist/acpx-engine/execute.js +1849 -0
- package/dist/acpx-engine/execute.js.map +1 -0
- package/dist/acpx-engine/execute.test.d.ts +2 -0
- package/dist/acpx-engine/execute.test.d.ts.map +1 -0
- package/dist/acpx-engine/execute.test.js +1332 -0
- package/dist/acpx-engine/execute.test.js.map +1 -0
- package/dist/acpx-engine/index.d.ts +6 -0
- package/dist/acpx-engine/index.d.ts.map +1 -0
- package/dist/acpx-engine/index.js +6 -0
- package/dist/acpx-engine/index.js.map +1 -0
- package/dist/acpx-engine/session-codec.d.ts +3 -0
- package/dist/acpx-engine/session-codec.d.ts.map +1 -0
- package/dist/acpx-engine/session-codec.js +48 -0
- package/dist/acpx-engine/session-codec.js.map +1 -0
- package/dist/acpx-engine/ui.d.ts +3 -0
- package/dist/acpx-engine/ui.d.ts.map +1 -0
- package/dist/acpx-engine/ui.js +162 -0
- package/dist/acpx-engine/ui.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +416 -5
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target.d.ts +37 -1
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +488 -9
- package/dist/execution-target.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +9 -4
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +4 -0
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/server-utils.d.ts +18 -0
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +85 -19
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +256 -8
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +0 -6
- package/dist/session-compaction.js.map +1 -1
- package/dist/types.d.ts +35 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
package/dist/execution-target.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import os from "node:os";
|
|
1
4
|
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
2
6
|
import { prepareCommandManagedRuntime, } from "./command-managed-runtime.js";
|
|
3
7
|
import { buildRemoteExecutionSessionIdentity, prepareRemoteManagedRuntime, remoteExecutionSessionMatches, } from "./remote-managed-runtime.js";
|
|
4
8
|
import { createCommandManagedSandboxCallbackBridgeQueueClient, createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, sandboxCallbackBridgeDirectories, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js";
|
|
@@ -8,7 +12,14 @@ import { ensureCommandResolvable, resolveCommandForLogs, runChildProcess, } from
|
|
|
8
12
|
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
|
9
13
|
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
|
10
14
|
export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
|
11
|
-
|
|
15
|
+
// 4-hour wall-clock backstop for sandbox-backed adapter runs. This is a
|
|
16
|
+
// last-resort kill switch, not the primary hang detector: genuinely hung runs
|
|
17
|
+
// are caught much earlier by the adapters' output-inactivity monitors (e.g.
|
|
18
|
+
// codex-local's 7-minute monitor). The value intentionally matches the
|
|
19
|
+
// recovery watchdog's ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS (4h) in
|
|
20
|
+
// server/src/services/recovery/service.ts so healthy long runs are never
|
|
21
|
+
// killed by the adapter before the watchdog would even consider them stuck.
|
|
22
|
+
export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 14_400;
|
|
12
23
|
function parseObject(value) {
|
|
13
24
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
14
25
|
? value
|
|
@@ -102,21 +113,71 @@ export function describeAdapterExecutionTarget(target) {
|
|
|
102
113
|
}
|
|
103
114
|
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
|
104
115
|
}
|
|
105
|
-
export function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
export function resolveAdapterExecutionTargetTimeout(target, configuredTimeoutSec) {
|
|
117
|
+
if (typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec)) {
|
|
118
|
+
// Preserve fractional (sub-second) configured values instead of flooring:
|
|
119
|
+
// adapters historically honored e.g. timeoutSec=0.5, and flooring would
|
|
120
|
+
// silently turn it into "no timeout".
|
|
121
|
+
if (configuredTimeoutSec > 0) {
|
|
122
|
+
return { timeoutSec: configuredTimeoutSec, source: "configured" };
|
|
123
|
+
}
|
|
124
|
+
// A negative timeoutSec is the explicit "no adapter wall-clock timeout"
|
|
125
|
+
// opt-out, honored even on sandbox targets. Zero cannot carry that
|
|
126
|
+
// meaning: the adapter config UI persists the schema default of 0 for
|
|
127
|
+
// untouched fields, so timeoutSec=0 in stored config does not signal
|
|
128
|
+
// operator intent and falls through to target defaults below.
|
|
129
|
+
if (configuredTimeoutSec < 0) {
|
|
130
|
+
return { timeoutSec: 0, source: "configured" };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
111
133
|
// Local and SSH adapters preserve the historical "0 means no adapter
|
|
112
134
|
// timeout" behavior. Sandbox-backed runs execute through provider RPCs
|
|
113
135
|
// that usually apply their own shorter command defaults, so request an
|
|
114
136
|
// explicit longer timeout for full adapter runs when the adapter leaves
|
|
115
137
|
// timeoutSec unset.
|
|
116
138
|
if (target?.kind === "remote" && target.transport === "sandbox") {
|
|
117
|
-
return DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC;
|
|
139
|
+
return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" };
|
|
140
|
+
}
|
|
141
|
+
return { timeoutSec: 0, source: "unlimited" };
|
|
142
|
+
}
|
|
143
|
+
export function resolveAdapterExecutionTargetTimeoutSec(target, configuredTimeoutSec) {
|
|
144
|
+
return resolveAdapterExecutionTargetTimeout(target, configuredTimeoutSec).timeoutSec;
|
|
145
|
+
}
|
|
146
|
+
function describeAdapterExecutionTimeoutSource(source) {
|
|
147
|
+
switch (source) {
|
|
148
|
+
case "configured":
|
|
149
|
+
return "configured via adapterConfig.timeoutSec";
|
|
150
|
+
case "sandbox_default":
|
|
151
|
+
return "sandbox default";
|
|
152
|
+
case "unlimited":
|
|
153
|
+
return "no adapter wall-clock timeout";
|
|
118
154
|
}
|
|
119
|
-
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Self-describing error message for when the adapter wall-clock execution
|
|
158
|
+
* timeout kills a run. Names the timer that fired and the knob that controls
|
|
159
|
+
* it so run failures never surface as a bare "Timed out".
|
|
160
|
+
*/
|
|
161
|
+
export function formatAdapterExecutionTimeoutErrorMessage(resolution) {
|
|
162
|
+
return (`Run exceeded the adapter execution timeout ` +
|
|
163
|
+
`(timeoutSec=${resolution.timeoutSec}, ${describeAdapterExecutionTimeoutSource(resolution.source)}). ` +
|
|
164
|
+
`Set adapterConfig.timeoutSec to raise it.`);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* One-line start-of-run statement of the effective wall-clock timeout and its
|
|
168
|
+
* source. Callers prefix with `[paperclip] ` and append a newline.
|
|
169
|
+
*/
|
|
170
|
+
export function formatAdapterExecutionTimeoutStartLogLine(resolution) {
|
|
171
|
+
if (resolution.timeoutSec <= 0) {
|
|
172
|
+
if (resolution.source === "configured") {
|
|
173
|
+
return ("Adapter execution timeout: none " +
|
|
174
|
+
"(explicitly disabled via adapterConfig.timeoutSec; set it to a positive value to add one).");
|
|
175
|
+
}
|
|
176
|
+
return ("Adapter execution timeout: none " +
|
|
177
|
+
"(no adapter wall-clock timeout for this target; set adapterConfig.timeoutSec to add one).");
|
|
178
|
+
}
|
|
179
|
+
return (`Adapter execution timeout: timeoutSec=${resolution.timeoutSec} ` +
|
|
180
|
+
`(${describeAdapterExecutionTimeoutSource(resolution.source)}; set adapterConfig.timeoutSec to override).`);
|
|
120
181
|
}
|
|
121
182
|
function requireSandboxRunner(target) {
|
|
122
183
|
if (target.runner)
|
|
@@ -752,6 +813,424 @@ async function readBridgeForwardResponseBody(response, maxBodyBytes) {
|
|
|
752
813
|
}
|
|
753
814
|
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
|
754
815
|
}
|
|
816
|
+
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
|
|
817
|
+
const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs";
|
|
818
|
+
const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000;
|
|
819
|
+
function jsonLine(value) {
|
|
820
|
+
return `${JSON.stringify(value)}\n`;
|
|
821
|
+
}
|
|
822
|
+
function splitJsonLines(buffer) {
|
|
823
|
+
const parts = buffer.split(/\n/);
|
|
824
|
+
return { lines: parts.slice(0, -1), rest: parts.at(-1) ?? "" };
|
|
825
|
+
}
|
|
826
|
+
async function writeProcessSessionProxyScript(dir, port, token) {
|
|
827
|
+
await fs.mkdir(dir, { recursive: true });
|
|
828
|
+
const proxyPath = path.join(dir, PROCESS_SESSION_PROXY_SCRIPT);
|
|
829
|
+
await fs.writeFile(proxyPath, getProcessSessionProxySource({ port, token }), { mode: 0o700 });
|
|
830
|
+
if (process.platform === "win32") {
|
|
831
|
+
// ACPX splits override commands before spawning them. Use native Node
|
|
832
|
+
// directly so a .cmd shell layer cannot buffer the proxy's streaming stdin.
|
|
833
|
+
const nodePath = process.execPath.replace(/\\/g, "/");
|
|
834
|
+
const scriptPath = proxyPath.replace(/\\/g, "/");
|
|
835
|
+
return `${JSON.stringify(nodePath)} ${JSON.stringify(scriptPath)}`;
|
|
836
|
+
}
|
|
837
|
+
return proxyPath;
|
|
838
|
+
}
|
|
839
|
+
async function syncProcessSessionRemoteScript(input) {
|
|
840
|
+
await input.client.writeTextFile(input.remoteScriptPath, getProcessSessionRemoteSource());
|
|
841
|
+
}
|
|
842
|
+
async function readRemoteJsonFiles(input) {
|
|
843
|
+
const names = await input.client.listJsonFiles(input.dir);
|
|
844
|
+
const out = [];
|
|
845
|
+
for (const name of names) {
|
|
846
|
+
const filePath = path.posix.join(input.dir, name);
|
|
847
|
+
const body = await input.client.readTextFile(filePath);
|
|
848
|
+
await input.client.remove(filePath).catch(() => undefined);
|
|
849
|
+
out.push({ name, body });
|
|
850
|
+
}
|
|
851
|
+
return out;
|
|
852
|
+
}
|
|
853
|
+
async function waitForLocalServerListen(server) {
|
|
854
|
+
await new Promise((resolve, reject) => {
|
|
855
|
+
server.once("error", reject);
|
|
856
|
+
server.listen(0, "127.0.0.1", () => {
|
|
857
|
+
server.off("error", reject);
|
|
858
|
+
resolve();
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
const address = server.address();
|
|
862
|
+
if (!address || typeof address === "string") {
|
|
863
|
+
throw new Error("Process session bridge did not expose a TCP port.");
|
|
864
|
+
}
|
|
865
|
+
return address.port;
|
|
866
|
+
}
|
|
867
|
+
export async function startAdapterExecutionTargetProcessSessionBridge(input) {
|
|
868
|
+
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const target = input.target;
|
|
872
|
+
const onLog = input.onLog ?? (async () => { });
|
|
873
|
+
const runner = requireSandboxRunner(target);
|
|
874
|
+
const shellCommand = preferredSandboxShell(target);
|
|
875
|
+
const timeoutMs = typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0
|
|
876
|
+
? Math.trunc(input.timeoutSec * 1000)
|
|
877
|
+
: target.timeoutMs ?? undefined;
|
|
878
|
+
const bridgeRuntimeDir = path.posix.join(input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey), "process-sessions");
|
|
879
|
+
const sessionId = randomUUID();
|
|
880
|
+
const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId);
|
|
881
|
+
const stdinDir = path.posix.join(sessionDir, "stdin");
|
|
882
|
+
const eventsDir = path.posix.join(sessionDir, "events");
|
|
883
|
+
const commandFilePath = path.posix.join(sessionDir, "command.json");
|
|
884
|
+
const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT);
|
|
885
|
+
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
|
886
|
+
runner,
|
|
887
|
+
remoteCwd: target.remoteCwd,
|
|
888
|
+
timeoutMs,
|
|
889
|
+
shellCommand,
|
|
890
|
+
});
|
|
891
|
+
await client.makeDir(stdinDir);
|
|
892
|
+
await client.makeDir(eventsDir);
|
|
893
|
+
await syncProcessSessionRemoteScript({ client, remoteScriptPath });
|
|
894
|
+
const commandPayload = JSON.stringify({
|
|
895
|
+
command: input.command,
|
|
896
|
+
args: input.args,
|
|
897
|
+
cwd: input.cwd || target.remoteCwd,
|
|
898
|
+
env: sanitizeRemoteExecutionEnv(input.env),
|
|
899
|
+
});
|
|
900
|
+
await client.writeTextFile(commandFilePath, commandPayload);
|
|
901
|
+
await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
|
|
902
|
+
const startResult = await runner.execute({
|
|
903
|
+
command: shellCommand,
|
|
904
|
+
args: shellCommandArgs([
|
|
905
|
+
`mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
|
|
906
|
+
`chmod 600 ${shellQuote(commandFilePath)}`,
|
|
907
|
+
`PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
|
|
908
|
+
`nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
|
|
909
|
+
"printf '%s\\n' \"$!\"",
|
|
910
|
+
].join("\n")),
|
|
911
|
+
cwd: target.remoteCwd,
|
|
912
|
+
env: {
|
|
913
|
+
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
|
|
914
|
+
},
|
|
915
|
+
timeoutMs,
|
|
916
|
+
});
|
|
917
|
+
if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
|
|
918
|
+
throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
|
|
919
|
+
}
|
|
920
|
+
let socket = null;
|
|
921
|
+
let stopping = false;
|
|
922
|
+
let stdinSeq = 0;
|
|
923
|
+
let pollTimer = null;
|
|
924
|
+
let markReady = () => { };
|
|
925
|
+
const ready = new Promise((resolve) => {
|
|
926
|
+
markReady = resolve;
|
|
927
|
+
});
|
|
928
|
+
const pendingRemoteEvents = [];
|
|
929
|
+
const token = createSandboxCallbackBridgeToken(18);
|
|
930
|
+
const proxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-proxy-"));
|
|
931
|
+
const writeRemoteEventToSocket = (event) => {
|
|
932
|
+
if (!socket)
|
|
933
|
+
return false;
|
|
934
|
+
socket.write(jsonLine(event));
|
|
935
|
+
if (event.type === "exit") {
|
|
936
|
+
stopping = true;
|
|
937
|
+
socket.end();
|
|
938
|
+
}
|
|
939
|
+
else if (event.type === "error") {
|
|
940
|
+
stopping = true;
|
|
941
|
+
socket.destroy();
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
};
|
|
945
|
+
const deliverRemoteEvent = (event) => {
|
|
946
|
+
if (socket) {
|
|
947
|
+
writeRemoteEventToSocket(event);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
pendingRemoteEvents.push(event);
|
|
951
|
+
if (event.type === "exit" || event.type === "error") {
|
|
952
|
+
stopping = true;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
const flushPendingRemoteEvents = () => {
|
|
956
|
+
if (!socket)
|
|
957
|
+
return;
|
|
958
|
+
while (pendingRemoteEvents.length > 0 && socket) {
|
|
959
|
+
const event = pendingRemoteEvents.shift();
|
|
960
|
+
if (event)
|
|
961
|
+
writeRemoteEventToSocket(event);
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
const liveSockets = new Set();
|
|
965
|
+
let stdinWriteChain = Promise.resolve();
|
|
966
|
+
const server = net.createServer((nextSocket) => {
|
|
967
|
+
liveSockets.add(nextSocket);
|
|
968
|
+
nextSocket.setEncoding("utf8");
|
|
969
|
+
nextSocket.on("error", () => undefined);
|
|
970
|
+
let connectionBuffer = "";
|
|
971
|
+
let authenticated = false;
|
|
972
|
+
// Connections own the session (and receive buffered process output) only
|
|
973
|
+
// after presenting the bridge token; idle unauthenticated peers are dropped.
|
|
974
|
+
const authTimer = setTimeout(() => {
|
|
975
|
+
if (!authenticated)
|
|
976
|
+
nextSocket.destroy();
|
|
977
|
+
}, PROCESS_SESSION_AUTH_TIMEOUT_MS);
|
|
978
|
+
authTimer.unref?.();
|
|
979
|
+
nextSocket.on("close", () => {
|
|
980
|
+
clearTimeout(authTimer);
|
|
981
|
+
liveSockets.delete(nextSocket);
|
|
982
|
+
});
|
|
983
|
+
nextSocket.on("data", (chunk) => {
|
|
984
|
+
connectionBuffer += chunk;
|
|
985
|
+
const split = splitJsonLines(connectionBuffer);
|
|
986
|
+
connectionBuffer = split.rest;
|
|
987
|
+
for (const line of split.lines) {
|
|
988
|
+
if (!line.trim())
|
|
989
|
+
continue;
|
|
990
|
+
let message;
|
|
991
|
+
try {
|
|
992
|
+
message = JSON.parse(line);
|
|
993
|
+
}
|
|
994
|
+
catch {
|
|
995
|
+
nextSocket.destroy();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (message.token !== token) {
|
|
999
|
+
nextSocket.destroy();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (!authenticated) {
|
|
1003
|
+
if (socket) {
|
|
1004
|
+
nextSocket.destroy();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
authenticated = true;
|
|
1008
|
+
clearTimeout(authTimer);
|
|
1009
|
+
socket = nextSocket;
|
|
1010
|
+
markReady();
|
|
1011
|
+
flushPendingRemoteEvents();
|
|
1012
|
+
}
|
|
1013
|
+
stdinWriteChain = stdinWriteChain.then(async () => {
|
|
1014
|
+
if (message.type === "stdin" && typeof message.data === "string") {
|
|
1015
|
+
stdinSeq += 1;
|
|
1016
|
+
const name = `${String(stdinSeq).padStart(12, "0")}.json`;
|
|
1017
|
+
await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdin", data: message.data }));
|
|
1018
|
+
}
|
|
1019
|
+
else if (message.type === "stdinEnd") {
|
|
1020
|
+
stdinSeq += 1;
|
|
1021
|
+
const name = `${String(stdinSeq).padStart(12, "0")}.json`;
|
|
1022
|
+
await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdinEnd" }));
|
|
1023
|
+
}
|
|
1024
|
+
}).catch((error) => {
|
|
1025
|
+
if (!nextSocket.destroyed) {
|
|
1026
|
+
nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
|
1027
|
+
}
|
|
1028
|
+
nextSocket.destroy();
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
const poll = async () => {
|
|
1034
|
+
if (stopping)
|
|
1035
|
+
return;
|
|
1036
|
+
try {
|
|
1037
|
+
const events = await readRemoteJsonFiles({ client, dir: eventsDir });
|
|
1038
|
+
for (const event of events) {
|
|
1039
|
+
const parsed = JSON.parse(event.body);
|
|
1040
|
+
deliverRemoteEvent(parsed);
|
|
1041
|
+
if (parsed.type === "exit" || parsed.type === "error")
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch (error) {
|
|
1046
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1047
|
+
await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`);
|
|
1048
|
+
deliverRemoteEvent({ type: "error", message });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
finally {
|
|
1052
|
+
if (!stopping) {
|
|
1053
|
+
pollTimer = setTimeout(() => void poll(), 100);
|
|
1054
|
+
pollTimer.unref?.();
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
const port = await waitForLocalServerListen(server);
|
|
1059
|
+
const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token);
|
|
1060
|
+
pollTimer = setTimeout(() => void poll(), 100);
|
|
1061
|
+
pollTimer.unref?.();
|
|
1062
|
+
return {
|
|
1063
|
+
agentCommand,
|
|
1064
|
+
ready,
|
|
1065
|
+
stop: async () => {
|
|
1066
|
+
stopping = true;
|
|
1067
|
+
markReady();
|
|
1068
|
+
if (pollTimer)
|
|
1069
|
+
clearTimeout(pollTimer);
|
|
1070
|
+
for (const liveSocket of liveSockets)
|
|
1071
|
+
liveSocket.destroy();
|
|
1072
|
+
await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined);
|
|
1073
|
+
await stdinWriteChain.catch(() => undefined);
|
|
1074
|
+
await client.writeTextFile(path.posix.join(stdinDir, `${String(stdinSeq + 1).padStart(12, "0")}.json`), jsonLine({ type: "stdinEnd" })).catch(() => undefined);
|
|
1075
|
+
await client.remove(sessionDir).catch(() => undefined);
|
|
1076
|
+
await fs.rm(proxyDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1077
|
+
},
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function getProcessSessionProxySource(input) {
|
|
1081
|
+
return `#!/usr/bin/env node
|
|
1082
|
+
import net from "node:net";
|
|
1083
|
+
|
|
1084
|
+
const socket = net.createConnection({ host: "127.0.0.1", port: ${input.port} });
|
|
1085
|
+
const token = ${JSON.stringify(input.token)};
|
|
1086
|
+
let buffer = "";
|
|
1087
|
+
let exiting = false;
|
|
1088
|
+
let outputChain = Promise.resolve();
|
|
1089
|
+
let connected = false;
|
|
1090
|
+
let stdinEnded = false;
|
|
1091
|
+
const pendingStdin = [];
|
|
1092
|
+
|
|
1093
|
+
function send(message) {
|
|
1094
|
+
socket.write(JSON.stringify({ token, ...message }) + "\\n");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function writeOutput(stream, chunk) {
|
|
1098
|
+
const output = stream === "stderr" ? process.stderr : process.stdout;
|
|
1099
|
+
outputChain = outputChain.then(() => new Promise((resolve) => {
|
|
1100
|
+
if (output.write(chunk)) resolve();
|
|
1101
|
+
else output.once("drain", resolve);
|
|
1102
|
+
}));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function finish(code) {
|
|
1106
|
+
if (exiting) return;
|
|
1107
|
+
exiting = true;
|
|
1108
|
+
void outputChain.then(
|
|
1109
|
+
() => {
|
|
1110
|
+
process.exitCode = code;
|
|
1111
|
+
socket.end();
|
|
1112
|
+
},
|
|
1113
|
+
() => {
|
|
1114
|
+
process.exitCode = 1;
|
|
1115
|
+
socket.destroy();
|
|
1116
|
+
},
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
socket.on("connect", () => {
|
|
1121
|
+
connected = true;
|
|
1122
|
+
send({ type: "hello" });
|
|
1123
|
+
for (const data of pendingStdin.splice(0)) send({ type: "stdin", data });
|
|
1124
|
+
if (stdinEnded) send({ type: "stdinEnd" });
|
|
1125
|
+
});
|
|
1126
|
+
process.stdin.on("data", (chunk) => {
|
|
1127
|
+
const data = Buffer.from(chunk).toString("base64");
|
|
1128
|
+
if (connected) send({ type: "stdin", data });
|
|
1129
|
+
else pendingStdin.push(data);
|
|
1130
|
+
});
|
|
1131
|
+
process.stdin.on("end", () => {
|
|
1132
|
+
stdinEnded = true;
|
|
1133
|
+
if (connected) send({ type: "stdinEnd" });
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
socket.setEncoding("utf8");
|
|
1137
|
+
socket.on("data", (chunk) => {
|
|
1138
|
+
buffer += chunk;
|
|
1139
|
+
const parts = buffer.split(/\\n/);
|
|
1140
|
+
buffer = parts.pop() || "";
|
|
1141
|
+
for (const line of parts) {
|
|
1142
|
+
if (!line.trim()) continue;
|
|
1143
|
+
const message = JSON.parse(line);
|
|
1144
|
+
if (message.type === "data") {
|
|
1145
|
+
const out = Buffer.from(message.data || "", "base64");
|
|
1146
|
+
writeOutput(message.stream, out);
|
|
1147
|
+
} else if (message.type === "error") {
|
|
1148
|
+
writeOutput("stderr", String(message.message || "Process session bridge failed.") + "\\n");
|
|
1149
|
+
finish(1);
|
|
1150
|
+
} else if (message.type === "exit") {
|
|
1151
|
+
finish(typeof message.code === "number" ? message.code : 1);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
socket.on("close", () => {
|
|
1156
|
+
if (!exiting) process.exit(1);
|
|
1157
|
+
});
|
|
1158
|
+
`;
|
|
1159
|
+
}
|
|
1160
|
+
function getProcessSessionRemoteSource() {
|
|
1161
|
+
return `import { spawn } from "node:child_process";
|
|
1162
|
+
import { promises as fs } from "node:fs";
|
|
1163
|
+
import path from "node:path";
|
|
1164
|
+
|
|
1165
|
+
const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
|
|
1166
|
+
if (!sessionDir) throw new Error("Missing process session bridge env.");
|
|
1167
|
+
|
|
1168
|
+
const stdinDir = path.posix.join(sessionDir, "stdin");
|
|
1169
|
+
const eventsDir = path.posix.join(sessionDir, "events");
|
|
1170
|
+
const commandFilePath = path.posix.join(sessionDir, "command.json");
|
|
1171
|
+
let seq = 0;
|
|
1172
|
+
let stdinClosed = false;
|
|
1173
|
+
|
|
1174
|
+
const config = JSON.parse(await fs.readFile(commandFilePath, "utf8"));
|
|
1175
|
+
await fs.mkdir(stdinDir, { recursive: true });
|
|
1176
|
+
await fs.mkdir(eventsDir, { recursive: true });
|
|
1177
|
+
|
|
1178
|
+
let writeChain = Promise.resolve();
|
|
1179
|
+
|
|
1180
|
+
function writeEvent(event) {
|
|
1181
|
+
seq += 1;
|
|
1182
|
+
const file = path.posix.join(eventsDir, String(seq).padStart(12, "0") + ".json");
|
|
1183
|
+
const write = writeChain.then(async () => {
|
|
1184
|
+
await fs.writeFile(file + ".tmp", JSON.stringify(event) + "\\n", "utf8");
|
|
1185
|
+
await fs.rename(file + ".tmp", file);
|
|
1186
|
+
});
|
|
1187
|
+
writeChain = write.catch(() => undefined);
|
|
1188
|
+
return write;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], {
|
|
1192
|
+
cwd: config.cwd || process.cwd(),
|
|
1193
|
+
env: { ...process.env, ...(config.env || {}) },
|
|
1194
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
child.stdout.on("data", (chunk) => void writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") }));
|
|
1198
|
+
child.stderr.on("data", (chunk) => void writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") }));
|
|
1199
|
+
child.on("error", (error) => void writeEvent({ type: "error", message: error.message }));
|
|
1200
|
+
// "close" (not "exit") so stdout/stderr fully drain before the exit event;
|
|
1201
|
+
// the write chain then guarantees the exit file lands after every data file.
|
|
1202
|
+
child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal }));
|
|
1203
|
+
|
|
1204
|
+
async function pollStdin() {
|
|
1205
|
+
while (!stdinClosed) {
|
|
1206
|
+
const stdinEntries = await fs.readdir(stdinDir).catch(() => null);
|
|
1207
|
+
if (!stdinEntries) {
|
|
1208
|
+
stdinClosed = true;
|
|
1209
|
+
child.stdin.end();
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
const entries = stdinEntries.filter((name) => name.endsWith(".json")).sort();
|
|
1213
|
+
for (const name of entries) {
|
|
1214
|
+
const file = path.posix.join(stdinDir, name);
|
|
1215
|
+
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
1216
|
+
await fs.rm(file, { force: true }).catch(() => undefined);
|
|
1217
|
+
if (!raw) continue;
|
|
1218
|
+
const message = JSON.parse(raw);
|
|
1219
|
+
if (message.type === "stdin" && typeof message.data === "string") {
|
|
1220
|
+
child.stdin.write(Buffer.from(message.data, "base64"));
|
|
1221
|
+
} else if (message.type === "stdinEnd") {
|
|
1222
|
+
stdinClosed = true;
|
|
1223
|
+
child.stdin.end();
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
|
1232
|
+
`;
|
|
1233
|
+
}
|
|
755
1234
|
export async function startAdapterExecutionTargetPaperclipBridge(input) {
|
|
756
1235
|
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
|
|
757
1236
|
return null;
|