pwragent 0.0.0 → 1.3.8

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.
Files changed (88) hide show
  1. package/README.md +494 -2
  2. package/bin/pwragent.js +10 -1
  3. package/dist/THIRD_PARTY_LICENSES +165 -0
  4. package/dist/federation-core.js +19573 -0
  5. package/dist/ws-LICENSE +20 -0
  6. package/examples/pwragent-headless.service +25 -0
  7. package/licenses/xterm-LICENSE +26 -0
  8. package/node_modules/node-addon-api/LICENSE.md +9 -0
  9. package/node_modules/node-addon-api/README.md +319 -0
  10. package/node_modules/node-addon-api/common.gypi +20 -0
  11. package/node_modules/node-addon-api/except.gypi +25 -0
  12. package/node_modules/node-addon-api/index.js +12 -0
  13. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  14. package/node_modules/node-addon-api/napi-inl.h +6607 -0
  15. package/node_modules/node-addon-api/napi.h +3201 -0
  16. package/node_modules/node-addon-api/node_addon_api.gyp +32 -0
  17. package/node_modules/node-addon-api/node_api.gyp +9 -0
  18. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  19. package/node_modules/node-addon-api/nothing.c +0 -0
  20. package/node_modules/node-addon-api/package-support.json +21 -0
  21. package/node_modules/node-addon-api/package.json +480 -0
  22. package/node_modules/node-addon-api/tools/README.md +73 -0
  23. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  24. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  25. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  26. package/node_modules/node-addon-api/tools/eslint-format.js +79 -0
  27. package/node_modules/node-pty/LICENSE +69 -0
  28. package/node_modules/node-pty/README.md +164 -0
  29. package/node_modules/node-pty/binding.gyp +150 -0
  30. package/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  31. package/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  32. package/node_modules/node-pty/lib/index.js +52 -0
  33. package/node_modules/node-pty/lib/interfaces.js +7 -0
  34. package/node_modules/node-pty/lib/shared/conout.js +10 -0
  35. package/node_modules/node-pty/lib/terminal.js +190 -0
  36. package/node_modules/node-pty/lib/types.js +7 -0
  37. package/node_modules/node-pty/lib/unixTerminal.js +351 -0
  38. package/node_modules/node-pty/lib/utils.js +38 -0
  39. package/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  40. package/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  41. package/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  42. package/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  43. package/node_modules/node-pty/package.json +66 -0
  44. package/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  45. package/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  46. package/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  47. package/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  48. package/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  49. package/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  50. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  51. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  52. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  53. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb +0 -0
  54. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  55. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb +0 -0
  56. package/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  57. package/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  58. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  59. package/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb +0 -0
  60. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  61. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb +0 -0
  62. package/node_modules/node-pty/scripts/post-install.js +76 -0
  63. package/node_modules/node-pty/scripts/prebuild.js +34 -0
  64. package/node_modules/node-pty/src/unix/pty.cc +876 -0
  65. package/node_modules/node-pty/src/unix/spawn-helper.cc +23 -0
  66. package/node_modules/node-pty/src/win/conpty.cc +616 -0
  67. package/node_modules/node-pty/src/win/conpty.h +41 -0
  68. package/node_modules/node-pty/src/win/conpty_console_list.cc +44 -0
  69. package/node_modules/node-pty/src/win/path_util.cc +95 -0
  70. package/node_modules/node-pty/src/win/path_util.h +26 -0
  71. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/OpenConsole.exe +0 -0
  72. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/conpty.dll +0 -0
  73. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/OpenConsole.exe +0 -0
  74. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/conpty.dll +0 -0
  75. package/node_modules/node-pty/typings/node-pty.d.ts +215 -0
  76. package/package.json +32 -5
  77. package/src/cli.js +123 -0
  78. package/src/client.js +27 -0
  79. package/src/codex.js +112 -0
  80. package/src/daemon.js +125 -0
  81. package/src/federation-backend.js +453 -0
  82. package/src/federation-core.ts +24 -0
  83. package/src/federation.js +454 -0
  84. package/src/handoffs.js +141 -0
  85. package/src/projects.js +140 -0
  86. package/src/runtime.js +265 -0
  87. package/src/store.js +80 -0
  88. package/src/wire.js +33 -0
@@ -0,0 +1,140 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { mkdir, rm } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { atomicJson } from "./store.js";
7
+
8
+ const execute = promisify(execFile);
9
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
10
+
11
+ /** Git receives only destination-local, explicitly selected authentication. */
12
+ export class OutpostGit {
13
+ constructor(directory, run = execute, workspace) {
14
+ this.directory = directory;
15
+ this.run = run;
16
+ this.workspace = workspace;
17
+ this.file = path.join(directory, "git-auth.json");
18
+ }
19
+
20
+ privateFile(file, executable = false) {
21
+ if (typeof file !== "string" || !path.isAbsolute(file)) throw new Error("Git authentication paths must be absolute outpost paths");
22
+ const info = lstatSync(file);
23
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o077)
24
+ || (executable && !(info.mode & 0o100))) throw new Error("Git authentication files must be private, owned regular files on the outpost");
25
+ return file;
26
+ }
27
+
28
+ configure(config) {
29
+ if (!config || Object.keys(config).some((key) => !["sshKeyPath", "knownHostsPath", "credentialHelperPath"].includes(key))) throw new Error("Unsupported Git authentication setting");
30
+ if (Boolean(config.sshKeyPath) !== Boolean(config.knownHostsPath)) throw new Error("SSH requires both an outpost key and a pinned known-hosts file");
31
+ for (const [key, value] of Object.entries(config)) this.privateFile(value, key === "credentialHelperPath");
32
+ atomicJson(this.file, { version: 1, ...config });
33
+ return { configured: true, ssh: Boolean(config.sshKeyPath), credentialHelper: Boolean(config.credentialHelperPath) };
34
+ }
35
+
36
+ settings() {
37
+ try {
38
+ this.privateFile(this.file);
39
+ const { version, ...config } = JSON.parse(readFileSync(this.file, "utf8"));
40
+ if (version !== 1) throw new Error("Unsupported Git authentication version");
41
+ for (const [key, value] of Object.entries(config)) {
42
+ if (!["sshKeyPath", "knownHostsPath", "credentialHelperPath"].includes(key)) throw new Error("Unsupported Git authentication setting");
43
+ this.privateFile(value, key === "credentialHelperPath");
44
+ }
45
+ return config;
46
+ } catch (error) { if (error.code === "ENOENT" && !this.hasSettings()) return {}; throw error; }
47
+ }
48
+
49
+ hasSettings() { try { lstatSync(this.file); return true; } catch (error) { if (error.code === "ENOENT") return false; throw error; } }
50
+
51
+ async git(cwd, args, options = {}) {
52
+ const config = this.settings();
53
+ const gitHome = path.join(this.directory, "git-home");
54
+ mkdirSync(gitHome, { recursive: true, mode: 0o700 });
55
+ const ssh = ["ssh", "-F", "/dev/null", "-o", "BatchMode=yes", "-o", "IdentityAgent=none", "-o", "IdentitiesOnly=yes",
56
+ "-o", "StrictHostKeyChecking=yes", "-o", "GlobalKnownHostsFile=/dev/null", "-o", "UserKnownHostsFile=" + (config.knownHostsPath || "/dev/null"),
57
+ "-i", config.sshKeyPath || "none"].map(quote).join(" ");
58
+ const env = { PATH: process.env.PATH, LANG: "C.UTF-8", HOME: gitHome, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "Never",
59
+ GIT_CEILING_DIRECTORIES: path.dirname(this.workspace || cwd), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ALLOW_PROTOCOL: "https:http:ssh:file" };
60
+ try {
61
+ const work = this.run("git", ["-c", "credential.helper=", ...(config.credentialHelperPath ? ["-c", `credential.helper=!${quote(config.credentialHelperPath)}`] : []),
62
+ "-c", `core.sshCommand=${ssh}`, "-c", "core.hooksPath=/dev/null", "-c", "protocol.ext.allow=never", "-C", cwd, ...args],
63
+ { ...options, env, timeout: options.timeout ?? 120000, maxBuffer: options.maxBuffer ?? 1024 * 1024 });
64
+ if (options.input && work.child) work.child.stdin.end(options.input);
65
+ return await work;
66
+ } catch (error) {
67
+ const failure = new Error("Git operation failed on the outpost. Check the repository, published ref and explicitly configured outpost authentication.");
68
+ if (/not a git repository/i.test(error.stderr || "")) failure.stderr = "not a git repository";
69
+ throw failure;
70
+ }
71
+ }
72
+ }
73
+
74
+ export class OutpostProjects {
75
+ constructor(directory, workspace, git = new OutpostGit(directory, execute, workspace)) {
76
+ this.file = path.join(directory, "projects.json");
77
+ this.workspace = workspace;
78
+ this.git = git;
79
+ try {
80
+ const data = JSON.parse(readFileSync(this.file, "utf8"));
81
+ if (data.version !== 1 || !Array.isArray(data.projects)) throw new Error("Invalid outpost project registry");
82
+ this.projects = new Map(data.projects.map((project) => [project.path, project]));
83
+ } catch (error) { if (error.code !== "ENOENT") throw error; this.projects = new Map(); }
84
+ if (!this.projects.has(workspace)) this.projects.set(workspace, { path: workspace, label: path.basename(workspace) });
85
+ }
86
+
87
+ save() { atomicJson(this.file, { version: 1, projects: [...this.projects.values()] }); }
88
+ list() { return [...this.projects.values()]; }
89
+
90
+ contained(value) {
91
+ if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error("Project path must be absolute on the outpost");
92
+ const relative = path.relative(this.workspace, value);
93
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("Project path must be inside the outpost workspace");
94
+ return value;
95
+ }
96
+
97
+ resolve(value = this.workspace) {
98
+ const project = this.projects.get(value);
99
+ if (!project) throw new Error("Project is not owned by this outpost; register its directory first");
100
+ const cwd = this.contained(realpathSync(project.path));
101
+ if (cwd !== project.path || !statSync(cwd).isDirectory()) throw new Error("Outpost project directory changed or is unavailable");
102
+ return cwd;
103
+ }
104
+
105
+ async add({ path: directory, label }) {
106
+ const cwd = this.contained(realpathSync(directory));
107
+ if (!statSync(cwd).isDirectory()) throw new Error("Project must be an existing outpost directory");
108
+ if (label !== undefined && (typeof label !== "string" || !label.trim() || label.length > 200)) throw new Error("Invalid project label");
109
+ this.projects.set(cwd, { path: cwd, label: label?.trim() || path.basename(cwd) });
110
+ this.save();
111
+ return this.projects.get(cwd);
112
+ }
113
+
114
+ async create({ name, remote, label }) {
115
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) throw new Error("Project name must be a simple directory name");
116
+ if (remote !== undefined) {
117
+ if (typeof remote !== "string" || !remote || remote.length > 4096 || /[\x00-\x20]/.test(remote) || remote.startsWith("-")) throw new Error("Invalid repository URL");
118
+ if (!path.isAbsolute(remote)) {
119
+ try {
120
+ const url = new URL(remote);
121
+ if (!["https:", "http:", "ssh:"].includes(url.protocol) || url.password || (url.protocol !== "ssh:" && url.username) || url.search || url.hash) throw new Error();
122
+ } catch { if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9/._-]+$/.test(remote)) throw new Error("Use a credential-free HTTPS/SSH URL or an absolute local repository path"); }
123
+ }
124
+ }
125
+ const target = path.join(this.resolve(), name);
126
+ await mkdir(target, { mode: 0o700 });
127
+ try {
128
+ if (remote) await this.git.git(this.workspace, ["clone", "--", remote, target]);
129
+ return await this.add({ path: target, label });
130
+ } catch (error) { await rm(target, { recursive: true, force: true }); throw error; }
131
+ }
132
+
133
+ remove(directory, threads = []) {
134
+ if (directory === this.workspace) throw new Error("The default workspace cannot be unregistered");
135
+ if (threads.some((thread) => thread.cwd === directory)) throw new Error("The project still owns outpost threads");
136
+ this.projects.delete(directory);
137
+ this.save();
138
+ return { removed: directory };
139
+ }
140
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,265 @@
1
+ import { OutpostProjects } from "./projects.js";
2
+ import path from "node:path";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { AgentToolRouter, buildPwrAgentMessagingToolDefinitions, readPwrAgentMessagingDynamicToolCall, resolveMessagingOutboundFile, validateMessagingFileMetadata, FILE_PUSH_MAX_BYTES } from "../dist/federation-core.js";
5
+ import { Codex } from "./codex.js";
6
+ import { ACTIVE, atomicJson, digest, privateDirectory, validId } from "./store.js";
7
+
8
+ export class Runtime {
9
+ constructor({ store, workspace, home, command, onFailure }) {
10
+ this.store = store;
11
+ this.workspace = workspace;
12
+ this.projects = new OutpostProjects(path.dirname(store.directory), workspace);
13
+ this.messagingTools = new AgentToolRouter(buildPwrAgentMessagingToolDefinitions((request) => this.messagingRequest(request), { nativeDynamicPdfImages: true }));
14
+ this.fileAccess = { privateStorageRoots: [home, process.env.PWRAGENT_HOME].filter(Boolean),
15
+ deniedRoots: [home, path.dirname(store.directory)], denyHardLinks: true };
16
+ this.toolLedgerDirectory = path.join(path.dirname(store.directory), "tool-calls");
17
+ privateDirectory(this.toolLedgerDirectory);
18
+ this.toolLedger = new Map();
19
+ this.toolLedgerCount = readdirSync(this.toolLedgerDirectory).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).length;
20
+ this.loadedThreads = new Set();
21
+ this.onFailure = onFailure;
22
+ this.codex = new Codex({
23
+ command, home, workspace,
24
+ onNotification: (message) => { this.notification(message); this.onEvent?.(message); },
25
+ onRequest: (message) => this.serverRequest(message),
26
+ onFailure,
27
+ });
28
+ }
29
+
30
+ save(job) {
31
+ try {
32
+ const saved = this.store.save(job);
33
+ this.onEvent?.({ method: "headless/jobChanged", params: saved });
34
+ return saved;
35
+ } catch (error) {
36
+ this.onFailure(error);
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ async handle(method, params = {}) {
42
+ if (this.stopping) throw new Error("Daemon is stopping");
43
+ switch (method) {
44
+ case "status": return { version: 1, ready: true, pid: process.pid, activeJob: this.active || null, workspace: this.workspace };
45
+ case "project-list": return this.projects.list();
46
+ case "project-create": case "project-clone": return this.projects.create(params);
47
+ case "project-add": return this.projects.add(params);
48
+ case "project-remove": return this.projects.remove(params.path, this.listOwnedThreads?.() || []);
49
+ case "git-auth-configure": return this.projects.git.configure(params);
50
+ case "list": return [...this.store.jobs.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100);
51
+ case "show": return this.store.get(params.id);
52
+ case "submit": return this.submit(params);
53
+ case "read": {
54
+ const job = this.store.get(params.id);
55
+ if (!job.threadId) throw new Error("This job has no confirmed thread ID");
56
+ return this.codex.request("thread/read", { threadId: job.threadId, includeTurns: true });
57
+ }
58
+ case "interrupt": {
59
+ const job = this.store.get(params.id);
60
+ if (!ACTIVE.has(job.status)) return job;
61
+ if (!job.turnId) throw new Error("Turn is still starting; retry interrupt after its turn ID is confirmed");
62
+ if (job.status === "interrupting") return job;
63
+ this.save({ ...job, status: "interrupting" });
64
+ try {
65
+ await this.codex.request("turn/interrupt", { threadId: job.threadId, turnId: job.turnId });
66
+ } catch (error) {
67
+ const current = this.store.get(job.id);
68
+ if (current.status === "interrupting" && current.turnId === job.turnId) {
69
+ this.save({ ...current, status: job.status });
70
+ }
71
+ throw error;
72
+ }
73
+ return this.store.get(job.id);
74
+ }
75
+ default: throw new Error(`Unknown command: ${method}`);
76
+ }
77
+ }
78
+
79
+ submit(params) {
80
+ const { id, prompt, threadId = null, model = null, sandbox = "read-only", reasoningEffort = null, serviceTier = null } = params;
81
+ if (!validId(id)) throw new Error("Job ID must be 1–80 letters, digits, underscores or hyphens, beginning with a letter or digit");
82
+ if (typeof prompt !== "string" || !prompt.trim() || Buffer.byteLength(prompt) > 256 * 1024) {
83
+ throw new Error("Prompt must be nonempty and at most 256 KiB");
84
+ }
85
+ if (!["read-only", "workspace-write"].includes(sandbox)) throw new Error("Sandbox must be read-only or workspace-write");
86
+ if (model !== null && (typeof model !== "string" || !model.trim() || model.length > 200)) throw new Error("Invalid model");
87
+ for (const value of [reasoningEffort, serviceTier]) {
88
+ if (value !== null && (typeof value !== "string" || value.length > 200)) throw new Error("Invalid turn settings");
89
+ }
90
+ if (threadId !== null && (typeof threadId !== "string" || (!this.ownsThread?.(threadId) && ![...this.store.jobs.values()].some((job) => job.threadId === threadId)))) {
91
+ throw new Error("Only threads created by this headless profile may be continued");
92
+ }
93
+ if (threadId && this.threadLocked?.(threadId)) throw new Error("Thread handoff is in progress");
94
+ // Preserve fingerprints created before optional turn settings were added.
95
+ const cwd = this.projects.resolve(threadId ? this.threadCwd?.(threadId) || params.cwd || this.workspace : params.cwd);
96
+ if (threadId && params.cwd && params.cwd !== cwd) throw new Error("Turn cwd differs from its owner thread project");
97
+ const fingerprint = digest({ prompt, threadId, model, sandbox, ...(cwd !== this.workspace ? { cwd } : {}), ...(params.messageOrigin ? { messageOrigin: params.messageOrigin } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}) });
98
+ const prior = this.store.jobs.get(id);
99
+ if (prior) {
100
+ if (prior.fingerprint !== fingerprint) throw new Error("Job ID was already used with different input");
101
+ return prior;
102
+ }
103
+ if (this.intakeBusy) throw new Error("Outpost thread intake is in progress; no job was queued");
104
+ if (this.active) throw new Error(`Daemon is busy with job ${this.active}; no job was queued`);
105
+ if (this.store.jobs.size >= 10000) throw new Error("Job retention limit reached (10000); use a new profile");
106
+ const job = this.save({ version: 1, id, fingerprint, status: "starting", threadId,
107
+ model, sandbox, cwd, messageOrigin: params.messageOrigin, reasoningEffort, serviceTier, createdAt: new Date().toISOString() });
108
+ this.active = id;
109
+ this.earlyCompletion = null;
110
+ this.blockedReason = null;
111
+ this.work = this.start(job, prompt).catch((error) => {
112
+ const current = this.store.get(id);
113
+ if (ACTIVE.has(current.status)) {
114
+ this.finish(id, this.codex.failure || this.stopping ? "unknown" : "failed", error.message);
115
+ }
116
+ });
117
+ // The durable acceptance precedes inference. Socket loss cannot cancel it.
118
+ return job;
119
+ }
120
+
121
+ async start(job, prompt) {
122
+ const cwd = this.projects.resolve(job.cwd || this.workspace);
123
+ const params = { cwd, approvalPolicy: "never", sandbox: job.sandbox,
124
+ ...(job.model ? { model: job.model } : {}) };
125
+ const result = job.threadId && this.loadedThreads.has(job.threadId) ? { thread: { id: job.threadId } }
126
+ : await this.codex.request(job.threadId ? "thread/resume" : "thread/start", {
127
+ ...params,
128
+ ...(job.threadId ? { threadId: job.threadId } : { ephemeral: false, dynamicTools: this.messagingTools.buildDynamicToolSpecs() }),
129
+ });
130
+ if (typeof result?.thread?.id !== "string" || !result.thread.id || result.thread.id.length > 256
131
+ || (job.threadId && result.thread.id !== job.threadId)) {
132
+ throw new Error("Codex returned an invalid thread ID");
133
+ }
134
+ this.loadedThreads.add(result.thread.id);
135
+ this.save({ ...this.store.get(job.id), threadId: result.thread.id,
136
+ ...(!job.threadId ? { messagingFileTools: true } : {}) });
137
+ const turn = await this.codex.request("turn/start", {
138
+ threadId: result.thread.id,
139
+ input: [{ type: "text", text: prompt, text_elements: [] }],
140
+ cwd,
141
+ approvalPolicy: "never",
142
+ sandboxPolicy: job.sandbox === "workspace-write"
143
+ ? { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false, excludeTmpdirEnvVar: true, excludeSlashTmp: true }
144
+ : { type: "readOnly", networkAccess: false },
145
+ ...(job.model ? { model: job.model } : {}),
146
+ ...(job.reasoningEffort ? { effort: job.reasoningEffort } : {}),
147
+ ...(job.serviceTier ? { serviceTier: job.serviceTier } : {}),
148
+ });
149
+ if (typeof turn?.turn?.id !== "string" || !turn.turn.id || turn.turn.id.length > 256) {
150
+ this.onFailure(new Error("Codex returned an invalid turn ID; execution outcome is unknown"));
151
+ return;
152
+ }
153
+ this.save({ ...this.store.get(job.id), turnId: turn.turn.id, status: "running" });
154
+ if (this.earlyCompletion?.turn?.id === turn.turn.id) {
155
+ this.complete(this.earlyCompletion);
156
+ } else if (["completed", "failed", "interrupted"].includes(turn.turn.status)) {
157
+ this.complete({ threadId: result.thread.id, turn: turn.turn });
158
+ } else if (this.blockedReason) {
159
+ await this.handle("interrupt", { id: job.id });
160
+ }
161
+ }
162
+
163
+ notification({ method, params }) {
164
+ if (method === "thread/closed") this.loadedThreads.delete(params?.threadId);
165
+ if (method !== "turn/completed" || !this.active) return;
166
+ const job = this.store.get(this.active);
167
+ if (params?.threadId !== job.threadId) return;
168
+ if (!job.turnId) this.earlyCompletion = params;
169
+ else if (params.turn?.id === job.turnId) this.complete(params);
170
+ }
171
+
172
+ complete(params) {
173
+ const status = params.turn.status;
174
+ if (!["completed", "failed", "interrupted"].includes(status)) {
175
+ this.onFailure(new Error("Codex returned an invalid terminal status"));
176
+ return;
177
+ }
178
+ this.finish(this.active, this.blockedReason ? "blocked" : status,
179
+ this.blockedReason || params.turn.error?.message);
180
+ }
181
+
182
+ async dispatchTool(call) {
183
+ const job = this.active && this.store.get(this.active);
184
+ if (!job || job.threadId !== call.threadId || (job.turnId && job.turnId !== call.turnId)) throw new Error("Dynamic tool call does not belong to the active outpost turn");
185
+ const key = digest([call.threadId, call.turnId, call.callId]);
186
+ const fingerprint = digest([call.namespace, call.tool, call.arguments]);
187
+ const file = path.join(this.toolLedgerDirectory, `${key}.json`);
188
+ let prior = this.toolLedger.get(key);
189
+ if (!prior) {
190
+ try { prior = JSON.parse(readFileSync(file, "utf8")); }
191
+ catch (error) { if (error.code !== "ENOENT") throw error; }
192
+ }
193
+ if (prior) {
194
+ if (prior.fingerprint !== fingerprint) throw new Error("Tool call identity was reused with different input");
195
+ if (prior.result) return prior.result;
196
+ throw new Error("Original tool outcome is uncertain; reconcile it with the messaging owner. No send was replayed.");
197
+ }
198
+ if (this.toolLedgerCount >= 10000) throw new Error("Tool-call retention limit reached");
199
+ this.toolLedger.set(key, { fingerprint });
200
+ atomicJson(file, { fingerprint });
201
+ this.toolLedgerCount++;
202
+ const result = await this.messagingTools.handleDynamicToolCall({ backend: "codex", call });
203
+ this.toolLedger.set(key, { fingerprint, result });
204
+ atomicJson(file, { fingerprint, result });
205
+ return result;
206
+ }
207
+
208
+ async messagingRequest(request) {
209
+ const job = this.active && this.store.get(this.active);
210
+ if (!job || job.threadId !== request.context.threadId || (job.turnId && job.turnId !== request.context.turnId)) return { ok: false, error: { code: "forbidden", message: "Messaging call does not belong to the active outpost turn" } };
211
+ const origin = job.messageOrigin;
212
+ const scoped = { ...request, context: { ...request.context, causalMessagingSource: origin?.causalMessagingSource,
213
+ causalMessagingAmbiguous: origin?.causalMessagingAmbiguous } };
214
+ const loadFile = request.operation === "send_messaging_file" ? async (capabilities) => {
215
+ const invalid = validateMessagingFileMetadata(request.args);
216
+ if (invalid) return invalid;
217
+ if (capabilities.maxUploadBytes !== undefined
218
+ && (!Number.isSafeInteger(capabilities.maxUploadBytes) || capabilities.maxUploadBytes < 1)) {
219
+ return { ok: false, code: "unsupported_operation", message: "The messaging owner supplied an invalid upload limit" };
220
+ }
221
+ // Git authentication may be explicitly selected from a workspace. Those
222
+ // files remain private even though their parent is an allowed project.
223
+ const gitAuth = this.projects.git.settings();
224
+ return await resolveMessagingOutboundFile(request.args, {
225
+ ...capabilities, maxUploadBytes: Math.min(capabilities.maxUploadBytes ?? FILE_PUSH_MAX_BYTES, FILE_PUSH_MAX_BYTES),
226
+ }, { ...this.fileAccess, allowedRoots: [this.projects.resolve(job.cwd || this.workspace)],
227
+ deniedRoots: [...this.fileAccess.deniedRoots, ...Object.values(gitAuth)] });
228
+ } : undefined;
229
+ return await this.requestMessaging?.(scoped, loadFile)
230
+ || { ok: false, error: { code: "peer_unavailable", message: "The original messaging owner is unavailable" } };
231
+ }
232
+
233
+ serverRequest(message) {
234
+ const call = readPwrAgentMessagingDynamicToolCall(message);
235
+ if (call) {
236
+ void this.dispatchTool(call).then((result) => this.codex.send({ id: message.id, result }),
237
+ (error) => this.codex.send({ id: message.id, result: { success: false, contentItems: [{ type: "inputText", text: error.message }] } }));
238
+ return;
239
+ }
240
+ // Never grant unattended approvals or invent answers to user questions.
241
+ const method = message.method;
242
+ let result;
243
+ if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval"].includes(method)) result = { decision: "decline" };
244
+ else if (method === "item/permissions/requestApproval") result = { permissions: {}, scope: "turn" };
245
+ this.codex.send(result
246
+ ? { id: message.id, result }
247
+ : { id: message.id, error: { code: -32601, message: "Interactive requests are not supported by PwrAgent Headless" } });
248
+ if (!this.active) return;
249
+ this.blockedReason = `Interactive request requires an operator: ${method}`;
250
+ const job = this.store.get(this.active);
251
+ if (job.turnId) void this.handle("interrupt", { id: job.id }).catch(this.onFailure);
252
+ }
253
+
254
+ finish(id, status, reason) {
255
+ this.save({ ...this.store.get(id), status, ...(reason ? { reason: String(reason).slice(0, 4096) } : {}), finishedAt: new Date().toISOString() });
256
+ if (this.active === id) this.active = null;
257
+ }
258
+
259
+ async stop() {
260
+ this.stopping = true;
261
+ await this.codex.stop();
262
+ await this.work;
263
+ if (this.active) this.finish(this.active, "unknown", "Daemon stopped; inspect the thread before explicitly continuing");
264
+ }
265
+ }
package/src/store.js ADDED
@@ -0,0 +1,80 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const ACTIVE = new Set(["starting", "running", "interrupting"]);
6
+ export const TERMINAL = new Set(["completed", "failed", "interrupted", "blocked", "unknown"]);
7
+ export const validId = (id) => typeof id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id);
8
+ export const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
9
+
10
+ export function privateDirectory(directory) {
11
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
12
+ const stat = lstatSync(directory);
13
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077)) {
14
+ throw new Error(`Expected a private directory owned by this user: ${directory}`);
15
+ }
16
+ }
17
+
18
+ export function atomicJson(file, value) {
19
+ const temporary = `${file}.${randomUUID()}.tmp`;
20
+ const fd = openSync(temporary, "wx", 0o600);
21
+ try {
22
+ try {
23
+ writeFileSync(fd, JSON.stringify(value) + "\n");
24
+ fsyncSync(fd);
25
+ } finally {
26
+ closeSync(fd);
27
+ }
28
+ renameSync(temporary, file);
29
+ const directory = openSync(path.dirname(file), "r");
30
+ try { fsyncSync(directory); } finally { closeSync(directory); }
31
+ } catch (error) {
32
+ // Preserve the original storage failure even if cleanup also fails.
33
+ try { unlinkSync(temporary); } catch { /* A renamed file is already gone. */ }
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ export class JobStore {
39
+ constructor(directory, identity) {
40
+ this.directory = path.join(directory, "jobs");
41
+ privateDirectory(this.directory);
42
+ const files = readdirSync(this.directory).filter((file) => file.endsWith(".json"));
43
+ const identityFile = path.join(directory, "identity.json");
44
+ try {
45
+ if (JSON.stringify(JSON.parse(readFileSync(identityFile, "utf8"))) !== JSON.stringify(identity)) {
46
+ throw new Error("This headless state belongs to a different workspace or Codex home");
47
+ }
48
+ } catch (error) {
49
+ if (error.code !== "ENOENT") throw error;
50
+ if (files.length) throw new Error("Headless identity is missing for an existing job registry", { cause: error });
51
+ atomicJson(identityFile, identity);
52
+ }
53
+ this.jobs = new Map();
54
+ for (const file of files) {
55
+ const job = JSON.parse(readFileSync(path.join(this.directory, file), "utf8"));
56
+ if (job.version !== 1 || !validId(job.id) || file !== `${job.id}.json`
57
+ || (!ACTIVE.has(job.status) && !TERMINAL.has(job.status))) {
58
+ throw new Error(`Invalid headless job record: ${file}`);
59
+ }
60
+ this.jobs.set(job.id, job);
61
+ if (ACTIVE.has(job.status)) {
62
+ this.save({ ...job, status: "unknown", reason: "Daemon stopped before a terminal outcome was recorded; no work was replayed" });
63
+ }
64
+ }
65
+ }
66
+
67
+ save(job) {
68
+ const saved = { ...job, updatedAt: new Date().toISOString() };
69
+ atomicJson(path.join(this.directory, `${job.id}.json`), saved);
70
+ this.jobs.set(job.id, saved);
71
+ return saved;
72
+ }
73
+
74
+ get(id) {
75
+ if (!validId(id)) throw new Error("Invalid job ID");
76
+ const job = this.jobs.get(id);
77
+ if (!job) throw new Error(`Unknown job: ${id}`);
78
+ return job;
79
+ }
80
+ }
package/src/wire.js ADDED
@@ -0,0 +1,33 @@
1
+ // Both transports use bounded newline-delimited JSON. No shell is involved.
2
+ export function readMessages(stream, limit, onMessage, onError) {
3
+ let pending = Buffer.alloc(0);
4
+ let failed = false;
5
+ stream.on("data", (chunk) => {
6
+ if (failed) return;
7
+ try {
8
+ pending = Buffer.concat([pending, chunk]);
9
+ let newline;
10
+ while ((newline = pending.indexOf(10)) !== -1) {
11
+ if (newline > limit) throw new Error("Protocol message exceeds size limit");
12
+ const line = pending.subarray(0, newline);
13
+ pending = pending.subarray(newline + 1);
14
+ if (!line.length) continue;
15
+ const message = JSON.parse(line.toString("utf8"));
16
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
17
+ throw new Error("Expected a protocol object");
18
+ }
19
+ onMessage(message);
20
+ }
21
+ if (pending.length > limit) throw new Error("Protocol message exceeds size limit");
22
+ } catch (error) {
23
+ failed = true;
24
+ onError(error);
25
+ }
26
+ });
27
+ stream.on("end", () => {
28
+ if (!failed && pending.length) onError(new Error("Truncated protocol message"));
29
+ });
30
+ }
31
+
32
+ export const MAX_REQUEST_BYTES = 512 * 1024;
33
+ export const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;