@arc402/daemon 1.2.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/bin/arc402-daemon +3 -0
- package/dist/abis.d.ts +20 -0
- package/dist/abis.d.ts.map +1 -0
- package/dist/abis.js +214 -0
- package/dist/abis.js.map +1 -0
- package/dist/api.d.ts +32 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +430 -0
- package/dist/api.js.map +1 -0
- package/dist/auth-server.d.ts +50 -0
- package/dist/auth-server.d.ts.map +1 -0
- package/dist/auth-server.js +266 -0
- package/dist/auth-server.js.map +1 -0
- package/dist/bundler.d.ts +68 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +181 -0
- package/dist/bundler.js.map +1 -0
- package/dist/capabilities.d.ts +17 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +57 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/compute-metering.d.ts +61 -0
- package/dist/compute-metering.d.ts.map +1 -0
- package/dist/compute-metering.js +299 -0
- package/dist/compute-metering.js.map +1 -0
- package/dist/compute-session.d.ts +100 -0
- package/dist/compute-session.d.ts.map +1 -0
- package/dist/compute-session.js +231 -0
- package/dist/compute-session.js.map +1 -0
- package/dist/config.d.ts +121 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +397 -0
- package/dist/config.js.map +1 -0
- package/dist/context-manager.d.ts +17 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +123 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/credentials.d.ts +24 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +80 -0
- package/dist/credentials.js.map +1 -0
- package/dist/delivery-client.d.ts +35 -0
- package/dist/delivery-client.d.ts.map +1 -0
- package/dist/delivery-client.js +231 -0
- package/dist/delivery-client.js.map +1 -0
- package/dist/endpoint-policy.d.ts +11 -0
- package/dist/endpoint-policy.d.ts.map +1 -0
- package/dist/endpoint-policy.js +107 -0
- package/dist/endpoint-policy.js.map +1 -0
- package/dist/event-watchers.d.ts +11 -0
- package/dist/event-watchers.d.ts.map +1 -0
- package/dist/event-watchers.js +24 -0
- package/dist/event-watchers.js.map +1 -0
- package/dist/exec-state.d.ts +37 -0
- package/dist/exec-state.d.ts.map +1 -0
- package/dist/exec-state.js +53 -0
- package/dist/exec-state.js.map +1 -0
- package/dist/file-delivery.d.ts +98 -0
- package/dist/file-delivery.d.ts.map +1 -0
- package/dist/file-delivery.js +473 -0
- package/dist/file-delivery.js.map +1 -0
- package/dist/handshake-watcher.d.ts +31 -0
- package/dist/handshake-watcher.d.ts.map +1 -0
- package/dist/handshake-watcher.js +157 -0
- package/dist/handshake-watcher.js.map +1 -0
- package/dist/hire-listener.d.ts +32 -0
- package/dist/hire-listener.d.ts.map +1 -0
- package/dist/hire-listener.js +237 -0
- package/dist/hire-listener.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/job-lifecycle.d.ts +62 -0
- package/dist/job-lifecycle.d.ts.map +1 -0
- package/dist/job-lifecycle.js +201 -0
- package/dist/job-lifecycle.js.map +1 -0
- package/dist/notify.d.ts +51 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +276 -0
- package/dist/notify.js.map +1 -0
- package/dist/permission-gate.d.ts +30 -0
- package/dist/permission-gate.d.ts.map +1 -0
- package/dist/permission-gate.js +180 -0
- package/dist/permission-gate.js.map +1 -0
- package/dist/prompt-guard.d.ts +18 -0
- package/dist/prompt-guard.d.ts.map +1 -0
- package/dist/prompt-guard.js +70 -0
- package/dist/prompt-guard.js.map +1 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1956 -0
- package/dist/server.js.map +1 -0
- package/dist/session-manager.d.ts +55 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +139 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/signer.d.ts +19 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +195 -0
- package/dist/signer.js.map +1 -0
- package/dist/token-metering.d.ts +42 -0
- package/dist/token-metering.d.ts.map +1 -0
- package/dist/token-metering.js +178 -0
- package/dist/token-metering.js.map +1 -0
- package/dist/userops.d.ts +24 -0
- package/dist/userops.d.ts.map +1 -0
- package/dist/userops.js +156 -0
- package/dist/userops.js.map +1 -0
- package/dist/wallet-monitor.d.ts +16 -0
- package/dist/wallet-monitor.d.ts.map +1 -0
- package/dist/wallet-monitor.js +57 -0
- package/dist/wallet-monitor.js.map +1 -0
- package/dist/worker-executor.d.ts +81 -0
- package/dist/worker-executor.d.ts.map +1 -0
- package/dist/worker-executor.js +527 -0
- package/dist/worker-executor.js.map +1 -0
- package/dist/worker-router.d.ts +63 -0
- package/dist/worker-router.d.ts.map +1 -0
- package/dist/worker-router.js +263 -0
- package/dist/worker-router.js.map +1 -0
- package/package.json +30 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.emitDaemonEvent = emitDaemonEvent;
|
|
40
|
+
exports.runDaemon = runDaemon;
|
|
41
|
+
/**
|
|
42
|
+
* ARC-402 Daemon — main process entry point.
|
|
43
|
+
*
|
|
44
|
+
* Startup sequence per Spec 32 §3.
|
|
45
|
+
* Runs when spawned by `arc402 daemon start` or invoked with --foreground.
|
|
46
|
+
*
|
|
47
|
+
* IPC: Unix socket at ~/.arc402/daemon.sock (JSON-lines protocol).
|
|
48
|
+
* Signals: SIGTERM → graceful shutdown.
|
|
49
|
+
*/
|
|
50
|
+
const fs = __importStar(require("fs"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
52
|
+
const os = __importStar(require("os"));
|
|
53
|
+
const net = __importStar(require("net"));
|
|
54
|
+
const http = __importStar(require("http"));
|
|
55
|
+
const ethers_1 = require("ethers");
|
|
56
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
57
|
+
const crypto = __importStar(require("crypto"));
|
|
58
|
+
const config_1 = require("./config");
|
|
59
|
+
const compute_metering_1 = require("./compute-metering");
|
|
60
|
+
const compute_session_1 = require("./compute-session");
|
|
61
|
+
const wallet_monitor_1 = require("./wallet-monitor");
|
|
62
|
+
const notify_1 = require("./notify");
|
|
63
|
+
const hire_listener_1 = require("./hire-listener");
|
|
64
|
+
const userops_1 = require("./userops");
|
|
65
|
+
const job_lifecycle_1 = require("./job-lifecycle");
|
|
66
|
+
const file_delivery_1 = require("./file-delivery");
|
|
67
|
+
const delivery_client_1 = require("./delivery-client");
|
|
68
|
+
const abis_1 = require("./abis");
|
|
69
|
+
const handshake_watcher_js_1 = require("./handshake-watcher.js");
|
|
70
|
+
const worker_executor_js_1 = require("./worker-executor.js");
|
|
71
|
+
const auth_server_js_1 = require("./auth-server.js");
|
|
72
|
+
const session_manager_js_1 = require("./session-manager.js");
|
|
73
|
+
const endpoint_policy_1 = require("./endpoint-policy");
|
|
74
|
+
const prompt_guard_1 = require("./prompt-guard");
|
|
75
|
+
function openStateDB(dbPath) {
|
|
76
|
+
const db = new better_sqlite3_1.default(dbPath);
|
|
77
|
+
db.pragma("journal_mode = WAL");
|
|
78
|
+
db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS hire_requests (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
agreement_id TEXT,
|
|
82
|
+
hirer_address TEXT NOT NULL,
|
|
83
|
+
capability TEXT,
|
|
84
|
+
price_eth TEXT,
|
|
85
|
+
deadline_unix INTEGER,
|
|
86
|
+
spec_hash TEXT,
|
|
87
|
+
task_description TEXT,
|
|
88
|
+
status TEXT NOT NULL,
|
|
89
|
+
created_at INTEGER,
|
|
90
|
+
updated_at INTEGER,
|
|
91
|
+
reject_reason TEXT
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS userop_queue (
|
|
95
|
+
id TEXT PRIMARY KEY,
|
|
96
|
+
hire_request_id TEXT,
|
|
97
|
+
call_data TEXT NOT NULL,
|
|
98
|
+
user_op_hash TEXT,
|
|
99
|
+
status TEXT NOT NULL,
|
|
100
|
+
submitted_at INTEGER,
|
|
101
|
+
included_at INTEGER,
|
|
102
|
+
retry_count INTEGER DEFAULT 0,
|
|
103
|
+
last_error TEXT
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE TABLE IF NOT EXISTS session_channels (
|
|
107
|
+
channel_id TEXT PRIMARY KEY,
|
|
108
|
+
counterparty TEXT NOT NULL,
|
|
109
|
+
token_address TEXT,
|
|
110
|
+
latest_state_seq INTEGER,
|
|
111
|
+
latest_state_bytes BLOB,
|
|
112
|
+
status TEXT NOT NULL,
|
|
113
|
+
challenge_deadline_unix INTEGER,
|
|
114
|
+
external_watcher_id TEXT
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
118
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
119
|
+
type TEXT NOT NULL,
|
|
120
|
+
payload TEXT,
|
|
121
|
+
sent_at INTEGER,
|
|
122
|
+
status TEXT
|
|
123
|
+
);
|
|
124
|
+
`);
|
|
125
|
+
// Migration: add task_description column to existing DBs that predate this field
|
|
126
|
+
try {
|
|
127
|
+
db.exec(`ALTER TABLE hire_requests ADD COLUMN task_description TEXT`);
|
|
128
|
+
}
|
|
129
|
+
catch { /* already exists */ }
|
|
130
|
+
const insertHireRequest = db.prepare(`
|
|
131
|
+
INSERT OR IGNORE INTO hire_requests
|
|
132
|
+
(id, agreement_id, hirer_address, capability, price_eth, deadline_unix, spec_hash, task_description, status, created_at, updated_at, reject_reason)
|
|
133
|
+
VALUES
|
|
134
|
+
(@id, @agreement_id, @hirer_address, @capability, @price_eth, @deadline_unix, @spec_hash, @task_description, @status, @created_at, @updated_at, @reject_reason)
|
|
135
|
+
`);
|
|
136
|
+
const getHireRequest = db.prepare(`SELECT * FROM hire_requests WHERE id = ?`);
|
|
137
|
+
const getHireRequestByAgreementId = db.prepare(`SELECT * FROM hire_requests WHERE agreement_id = ? ORDER BY created_at DESC LIMIT 1`);
|
|
138
|
+
const updateStatus = db.prepare(`UPDATE hire_requests SET status = ?, reject_reason = ?, updated_at = ? WHERE id = ?`);
|
|
139
|
+
const listPending = db.prepare(`SELECT * FROM hire_requests WHERE status = 'pending_approval' ORDER BY created_at ASC`);
|
|
140
|
+
const listActive = db.prepare(`SELECT * FROM hire_requests WHERE status IN ('accepted') ORDER BY created_at ASC`);
|
|
141
|
+
const countActive = db.prepare(`SELECT COUNT(*) as n FROM hire_requests WHERE status IN ('accepted')`);
|
|
142
|
+
return {
|
|
143
|
+
insertHireRequest(row) {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
insertHireRequest.run({ ...row, created_at: now, updated_at: now });
|
|
146
|
+
},
|
|
147
|
+
getHireRequest(id) {
|
|
148
|
+
return getHireRequest.get(id);
|
|
149
|
+
},
|
|
150
|
+
getHireRequestByAgreementId(agreementId) {
|
|
151
|
+
return getHireRequestByAgreementId.get(agreementId);
|
|
152
|
+
},
|
|
153
|
+
updateHireRequestStatus(id, status, rejectReason) {
|
|
154
|
+
updateStatus.run(status, rejectReason ?? null, Date.now(), id);
|
|
155
|
+
},
|
|
156
|
+
listPendingHireRequests() {
|
|
157
|
+
return listPending.all();
|
|
158
|
+
},
|
|
159
|
+
listActiveHireRequests() {
|
|
160
|
+
return listActive.all();
|
|
161
|
+
},
|
|
162
|
+
countActiveHireRequests() {
|
|
163
|
+
const row = countActive.get();
|
|
164
|
+
return row.n;
|
|
165
|
+
},
|
|
166
|
+
close() {
|
|
167
|
+
db.close();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ─── Auth token ───────────────────────────────────────────────────────────────
|
|
172
|
+
const DAEMON_TOKEN_FILE = path.join(path.dirname(config_1.DAEMON_SOCK), "daemon.token");
|
|
173
|
+
function generateApiToken() {
|
|
174
|
+
return crypto.randomBytes(32).toString("hex");
|
|
175
|
+
}
|
|
176
|
+
function saveApiToken(token) {
|
|
177
|
+
fs.mkdirSync(path.dirname(DAEMON_TOKEN_FILE), { recursive: true, mode: 0o700 });
|
|
178
|
+
fs.writeFileSync(DAEMON_TOKEN_FILE, token, { mode: 0o600 });
|
|
179
|
+
}
|
|
180
|
+
function loadApiToken() {
|
|
181
|
+
try {
|
|
182
|
+
return fs.readFileSync(DAEMON_TOKEN_FILE, "utf-8").trim();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// ─── SSE event bus ──────────────────────────────────────────────────────────────
|
|
189
|
+
const sseClients = new Set();
|
|
190
|
+
function emitDaemonEvent(type, data) {
|
|
191
|
+
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
192
|
+
for (const client of sseClients) {
|
|
193
|
+
try {
|
|
194
|
+
client.write(payload);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
sseClients.delete(client);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const rateLimitMap = new Map();
|
|
202
|
+
const RATE_LIMIT = 30;
|
|
203
|
+
const RATE_WINDOW_MS = 60000;
|
|
204
|
+
function checkRateLimit(ip) {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
let bucket = rateLimitMap.get(ip);
|
|
207
|
+
if (!bucket || now >= bucket.resetTime) {
|
|
208
|
+
bucket = { count: 0, resetTime: now + RATE_WINDOW_MS };
|
|
209
|
+
rateLimitMap.set(ip, bucket);
|
|
210
|
+
}
|
|
211
|
+
bucket.count++;
|
|
212
|
+
return bucket.count <= RATE_LIMIT;
|
|
213
|
+
}
|
|
214
|
+
// Cleanup stale rate limit entries every 5 minutes to prevent unbounded growth
|
|
215
|
+
let rateLimitCleanupInterval = null;
|
|
216
|
+
// ─── Body size limit ──────────────────────────────────────────────────────────
|
|
217
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
218
|
+
function collectInboundTaskTexts(payload) {
|
|
219
|
+
const values = [
|
|
220
|
+
payload.task,
|
|
221
|
+
payload.taskDescription,
|
|
222
|
+
payload.task_description,
|
|
223
|
+
payload.content,
|
|
224
|
+
payload.prompt,
|
|
225
|
+
payload.workloadDescription,
|
|
226
|
+
payload.workload_description,
|
|
227
|
+
];
|
|
228
|
+
return values.filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
229
|
+
}
|
|
230
|
+
// ─── Logger ───────────────────────────────────────────────────────────────────
|
|
231
|
+
function openLogger(logPath, foreground) {
|
|
232
|
+
let stream = null;
|
|
233
|
+
if (!foreground) {
|
|
234
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
235
|
+
stream = fs.createWriteStream(logPath, { flags: "a" });
|
|
236
|
+
}
|
|
237
|
+
return (entry) => {
|
|
238
|
+
const line = JSON.stringify({ ...entry, ts: new Date().toISOString() });
|
|
239
|
+
if (foreground) {
|
|
240
|
+
process.stdout.write(line + "\n");
|
|
241
|
+
}
|
|
242
|
+
else if (stream) {
|
|
243
|
+
stream.write(line + "\n");
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function startIpcServer(ctx, log, apiToken) {
|
|
248
|
+
// Remove stale socket
|
|
249
|
+
if (fs.existsSync(config_1.DAEMON_SOCK)) {
|
|
250
|
+
fs.unlinkSync(config_1.DAEMON_SOCK);
|
|
251
|
+
}
|
|
252
|
+
const server = net.createServer((socket) => {
|
|
253
|
+
let buf = "";
|
|
254
|
+
let authenticated = false;
|
|
255
|
+
socket.on("data", (data) => {
|
|
256
|
+
buf += data.toString();
|
|
257
|
+
const lines = buf.split("\n");
|
|
258
|
+
buf = lines.pop() ?? "";
|
|
259
|
+
for (const line of lines) {
|
|
260
|
+
if (!line.trim())
|
|
261
|
+
continue;
|
|
262
|
+
let cmd;
|
|
263
|
+
try {
|
|
264
|
+
cmd = JSON.parse(line);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
socket.write(JSON.stringify({ ok: false, error: "invalid_json" }) + "\n");
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// First message must be auth
|
|
271
|
+
if (!authenticated) {
|
|
272
|
+
if (cmd.auth === apiToken) {
|
|
273
|
+
authenticated = true;
|
|
274
|
+
socket.write(JSON.stringify({ ok: true, authenticated: true }) + "\n");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
log({ event: "ipc_auth_failed" });
|
|
278
|
+
socket.write(JSON.stringify({ ok: false, error: "unauthorized" }) + "\n");
|
|
279
|
+
socket.destroy();
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const response = handleIpcCommand(cmd, ctx, log);
|
|
284
|
+
socket.write(JSON.stringify(response) + "\n");
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
socket.on("error", () => { });
|
|
288
|
+
});
|
|
289
|
+
server.listen(config_1.DAEMON_SOCK, () => {
|
|
290
|
+
fs.chmodSync(config_1.DAEMON_SOCK, 0o600);
|
|
291
|
+
log({ event: "ipc_ready", socket: config_1.DAEMON_SOCK });
|
|
292
|
+
});
|
|
293
|
+
return server;
|
|
294
|
+
}
|
|
295
|
+
function handleIpcCommand(cmd, ctx, log) {
|
|
296
|
+
const uptimeSeconds = Math.floor((Date.now() - ctx.startTime) / 1000);
|
|
297
|
+
const uptimeStr = formatUptime(uptimeSeconds);
|
|
298
|
+
switch (cmd.command) {
|
|
299
|
+
case "status": {
|
|
300
|
+
const pending = ctx.db.listPendingHireRequests();
|
|
301
|
+
const active = ctx.db.listActiveHireRequests();
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
data: {
|
|
305
|
+
state: "running",
|
|
306
|
+
pid: process.pid,
|
|
307
|
+
uptime: uptimeStr,
|
|
308
|
+
wallet: ctx.walletAddress,
|
|
309
|
+
machine_key_address: ctx.machineKeyAddress,
|
|
310
|
+
relay_enabled: ctx.config.relay.enabled,
|
|
311
|
+
relay_url: ctx.config.relay.relay_url,
|
|
312
|
+
relay_poll_seconds: ctx.config.relay.poll_interval_seconds,
|
|
313
|
+
watchtower_enabled: ctx.config.watchtower.enabled,
|
|
314
|
+
bundler_mode: ctx.bundlerMode,
|
|
315
|
+
bundler_endpoint: ctx.bundlerEndpoint,
|
|
316
|
+
active_agreements: active.length,
|
|
317
|
+
pending_approval: pending.length,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
case "pending": {
|
|
322
|
+
return { ok: true, data: { requests: ctx.db.listPendingHireRequests() } };
|
|
323
|
+
}
|
|
324
|
+
case "agreements": {
|
|
325
|
+
return { ok: true, data: { agreements: ctx.db.listActiveHireRequests() } };
|
|
326
|
+
}
|
|
327
|
+
case "agreement": {
|
|
328
|
+
if (!cmd.id)
|
|
329
|
+
return { ok: false, error: "id required" };
|
|
330
|
+
const agreement = ctx.db.getHireRequest(cmd.id);
|
|
331
|
+
if (!agreement)
|
|
332
|
+
return { ok: false, error: "agreement not found" };
|
|
333
|
+
return { ok: true, data: { agreement } };
|
|
334
|
+
}
|
|
335
|
+
case "approve": {
|
|
336
|
+
if (!cmd.id)
|
|
337
|
+
return { ok: false, error: "id required" };
|
|
338
|
+
const hire = ctx.db.getHireRequest(cmd.id);
|
|
339
|
+
if (!hire)
|
|
340
|
+
return { ok: false, error: "hire request not found" };
|
|
341
|
+
if (hire.status !== "pending_approval") {
|
|
342
|
+
return { ok: false, error: `hire request status is '${hire.status}', not pending_approval` };
|
|
343
|
+
}
|
|
344
|
+
ctx.db.updateHireRequestStatus(cmd.id, "accepted");
|
|
345
|
+
log({ event: "hire_approved", id: cmd.id });
|
|
346
|
+
// Trigger accept UserOp (fire and forget)
|
|
347
|
+
if (ctx.userOps && ctx.config.serviceAgreementAddress) {
|
|
348
|
+
const callData = (0, userops_1.buildAcceptCalldata)(ctx.config.serviceAgreementAddress, hire.agreement_id ?? cmd.id, ctx.walletAddress);
|
|
349
|
+
ctx.userOps.submit(callData, ctx.walletAddress).then((hash) => {
|
|
350
|
+
log({ event: "userop_submitted", id: cmd.id, hash });
|
|
351
|
+
}).catch((err) => {
|
|
352
|
+
log({ event: "userop_error", id: cmd.id, error: String(err) });
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return { ok: true, data: { approved: true, id: cmd.id } };
|
|
356
|
+
}
|
|
357
|
+
case "reject": {
|
|
358
|
+
if (!cmd.id)
|
|
359
|
+
return { ok: false, error: "id required" };
|
|
360
|
+
const hire = ctx.db.getHireRequest(cmd.id);
|
|
361
|
+
if (!hire)
|
|
362
|
+
return { ok: false, error: "hire request not found" };
|
|
363
|
+
if (hire.status !== "pending_approval") {
|
|
364
|
+
return { ok: false, error: `hire request status is '${hire.status}', not pending_approval` };
|
|
365
|
+
}
|
|
366
|
+
const reason = cmd.reason ?? "operator_rejected";
|
|
367
|
+
ctx.db.updateHireRequestStatus(cmd.id, "rejected", reason);
|
|
368
|
+
log({ event: "hire_rejected", id: cmd.id, reason });
|
|
369
|
+
return { ok: true, data: { rejected: true, id: cmd.id, reason } };
|
|
370
|
+
}
|
|
371
|
+
case "complete": {
|
|
372
|
+
// Called after a job is delivered and accepted. Triggers post-job lifecycle:
|
|
373
|
+
// receipt generation, learning extraction, worker memory update.
|
|
374
|
+
if (!cmd.id)
|
|
375
|
+
return { ok: false, error: "id required" };
|
|
376
|
+
const hire = ctx.db.getHireRequest(cmd.id);
|
|
377
|
+
if (!hire)
|
|
378
|
+
return { ok: false, error: "hire request not found" };
|
|
379
|
+
const now = new Date().toISOString();
|
|
380
|
+
const startedAt = new Date(hire.created_at).toISOString();
|
|
381
|
+
// Generate execution receipt
|
|
382
|
+
const receipt = (0, job_lifecycle_1.generateReceipt)({
|
|
383
|
+
agreementId: hire.agreement_id ?? cmd.id,
|
|
384
|
+
deliverableHash: hire.spec_hash ?? "0x0",
|
|
385
|
+
walletAddress: ctx.walletAddress,
|
|
386
|
+
startedAt,
|
|
387
|
+
completedAt: now,
|
|
388
|
+
});
|
|
389
|
+
log({ event: "receipt_generated", id: cmd.id, receipt_hash: receipt.receipt_hash });
|
|
390
|
+
// Extract learnings
|
|
391
|
+
(0, job_lifecycle_1.extractLearnings)({
|
|
392
|
+
agreementId: hire.agreement_id ?? cmd.id,
|
|
393
|
+
taskDescription: hire.capability ?? "unknown",
|
|
394
|
+
deliverableHash: hire.spec_hash ?? "0x0",
|
|
395
|
+
priceEth: hire.price_eth ?? "0",
|
|
396
|
+
capability: hire.capability ?? "general",
|
|
397
|
+
wallClockSeconds: receipt.metrics.wall_clock_seconds,
|
|
398
|
+
success: true,
|
|
399
|
+
});
|
|
400
|
+
log({ event: "learnings_extracted", id: cmd.id });
|
|
401
|
+
// Update status to complete
|
|
402
|
+
ctx.db.updateHireRequestStatus(cmd.id, "complete");
|
|
403
|
+
// Clean job directory (keep receipt + memory)
|
|
404
|
+
(0, job_lifecycle_1.cleanJobDirectory)(hire.agreement_id ?? cmd.id);
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
data: {
|
|
408
|
+
completed: true,
|
|
409
|
+
id: cmd.id,
|
|
410
|
+
receipt_hash: receipt.receipt_hash,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
case "worker-status": {
|
|
415
|
+
if (!ctx.workerExecutor)
|
|
416
|
+
return { ok: true, data: { jobs: [], executor: "disabled" } };
|
|
417
|
+
return { ok: true, data: { jobs: ctx.workerExecutor.listAll() } };
|
|
418
|
+
}
|
|
419
|
+
case "worker-logs": {
|
|
420
|
+
if (!cmd.id)
|
|
421
|
+
return { ok: false, error: "id required" };
|
|
422
|
+
if (!ctx.workerExecutor)
|
|
423
|
+
return { ok: false, error: "worker executor not running" };
|
|
424
|
+
const tail = typeof cmd.tail === "number" ? cmd.tail : 100;
|
|
425
|
+
return { ok: true, data: { log: ctx.workerExecutor.readLog(cmd.id, tail) } };
|
|
426
|
+
}
|
|
427
|
+
default:
|
|
428
|
+
return { ok: false, error: `unknown command: ${cmd.command}` };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function formatUptime(seconds) {
|
|
432
|
+
const h = Math.floor(seconds / 3600);
|
|
433
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
434
|
+
const s = seconds % 60;
|
|
435
|
+
if (h > 0)
|
|
436
|
+
return `${h}h ${m}m`;
|
|
437
|
+
if (m > 0)
|
|
438
|
+
return `${m}m ${s}s`;
|
|
439
|
+
return `${s}s`;
|
|
440
|
+
}
|
|
441
|
+
function failStartup(stage, error, envVarName) {
|
|
442
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
443
|
+
process.stderr.write(`ARC-402 daemon startup blocked during ${stage}: ${detail}\n`);
|
|
444
|
+
process.stderr.write("\nNext steps:\n");
|
|
445
|
+
if (stage === "config") {
|
|
446
|
+
process.stderr.write(" 1. Run `arc402 daemon init` to create ~/.arc402/daemon.toml.\n");
|
|
447
|
+
process.stderr.write(" 2. Fill in wallet.contract_address and network.rpc_url in ~/.arc402/daemon.toml.\n");
|
|
448
|
+
process.stderr.write(" 3. Retry with `arc402-daemon` or `arc402 daemon start`.\n");
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
process.stderr.write(` 1. Export ${envVarName ?? "ARC402_MACHINE_KEY"} with your machine key before starting the daemon.\n`);
|
|
452
|
+
process.stderr.write(" 2. Confirm wallet.machine_key in ~/.arc402/daemon.toml points at the right env var.\n");
|
|
453
|
+
process.stderr.write(" 3. Retry after the machine key is available.\n");
|
|
454
|
+
}
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
// ─── Daemon main ──────────────────────────────────────────────────────────────
|
|
458
|
+
// serviceAgreementAddress is now part of DaemonConfig directly (see config.ts)
|
|
459
|
+
async function runDaemon(foreground = false) {
|
|
460
|
+
fs.mkdirSync(config_1.DAEMON_DIR, { recursive: true, mode: 0o700 });
|
|
461
|
+
const log = openLogger(config_1.DAEMON_LOG, foreground);
|
|
462
|
+
log({ event: "daemon_starting" });
|
|
463
|
+
// ── Step 1: Load config ──────────────────────────────────────────────────
|
|
464
|
+
let config;
|
|
465
|
+
try {
|
|
466
|
+
config = (0, config_1.loadDaemonConfig)();
|
|
467
|
+
log({ event: "config_loaded", path: require("path").join(require("os").homedir(), ".arc402", "daemon.toml") });
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
failStartup("config", err);
|
|
471
|
+
}
|
|
472
|
+
// ── Step 2: Load machine key ─────────────────────────────────────────────
|
|
473
|
+
let machineKeyAddress;
|
|
474
|
+
let machinePrivateKey;
|
|
475
|
+
try {
|
|
476
|
+
const mk = (0, config_1.loadMachineKey)(config);
|
|
477
|
+
machinePrivateKey = mk.privateKey;
|
|
478
|
+
machineKeyAddress = mk.address;
|
|
479
|
+
log({ event: "machine_key_loaded", address: machineKeyAddress });
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
failStartup("machine-key", err, config.wallet.machine_key.startsWith("env:") ? config.wallet.machine_key.slice(4) : undefined);
|
|
483
|
+
}
|
|
484
|
+
// ── Step 3: Connect to RPC ───────────────────────────────────────────────
|
|
485
|
+
const provider = new ethers_1.ethers.JsonRpcProvider(config.network.rpc_url);
|
|
486
|
+
try {
|
|
487
|
+
const chainId = (await provider.getNetwork()).chainId;
|
|
488
|
+
if (Number(chainId) !== config.network.chain_id) {
|
|
489
|
+
process.stderr.write(`RPC chain ID ${chainId} does not match config ${config.network.chain_id}\n`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
const networkName = config.network.chain_id === 8453 ? "Base Mainnet" : `Chain ${config.network.chain_id}`;
|
|
493
|
+
log({ event: "rpc_connected", chain_id: config.network.chain_id, network: networkName });
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
process.stderr.write(`RPC connection failed: ${err}\n`);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
// ── Step 4+5: Verify wallet ──────────────────────────────────────────────
|
|
500
|
+
try {
|
|
501
|
+
const walletStatus = await (0, wallet_monitor_1.verifyWallet)(config, provider, machineKeyAddress);
|
|
502
|
+
log({
|
|
503
|
+
event: "wallet_verified",
|
|
504
|
+
address: walletStatus.contractAddress,
|
|
505
|
+
owner: walletStatus.ownerAddress,
|
|
506
|
+
balance_eth: walletStatus.ethBalance,
|
|
507
|
+
machine_key_authorized: walletStatus.machineKeyAuthorized,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
process.stderr.write(`Wallet verification failed: ${err}\n`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
// Machine key signer — used for UserOp signatures and on-chain compute contract calls
|
|
515
|
+
const machineKeySigner = new ethers_1.ethers.Wallet(machinePrivateKey, provider);
|
|
516
|
+
// ── Step 6: Connect bundler ──────────────────────────────────────────────
|
|
517
|
+
const userOps = new userops_1.UserOpsManager(config, provider, machineKeySigner);
|
|
518
|
+
const bundlerOk = await userOps.pingBundler();
|
|
519
|
+
if (!bundlerOk) {
|
|
520
|
+
log({ event: "bundler_warn", msg: "Bundler endpoint unreachable — will retry on demand" });
|
|
521
|
+
}
|
|
522
|
+
const bundlerEndpoint = config.bundler.endpoint || "https://api.pimlico.io/v2/base/rpc";
|
|
523
|
+
log({ event: "bundler_configured", mode: config.bundler.mode, endpoint: bundlerEndpoint });
|
|
524
|
+
// ── Step 7: Open state DB ────────────────────────────────────────────────
|
|
525
|
+
let db;
|
|
526
|
+
try {
|
|
527
|
+
db = openStateDB(config_1.DAEMON_DB);
|
|
528
|
+
log({ event: "state_db_opened", path: config_1.DAEMON_DB });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
process.stderr.write(`State DB error: ${err}\n`);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
// ── Setup notifier ───────────────────────────────────────────────────────
|
|
535
|
+
const notifier = (0, notify_1.buildNotifier)(config);
|
|
536
|
+
// ── File delivery subsystem ──────────────────────────────────────────────
|
|
537
|
+
const fileDelivery = new file_delivery_1.FileDeliveryManager({
|
|
538
|
+
maxFileSizeMb: config.delivery.max_file_size_mb,
|
|
539
|
+
maxJobSizeMb: config.delivery.max_job_size_mb,
|
|
540
|
+
});
|
|
541
|
+
fileDelivery.setPartyResolver((agreementId) => {
|
|
542
|
+
const row = db.getHireRequestByAgreementId(agreementId);
|
|
543
|
+
if (!row)
|
|
544
|
+
return null;
|
|
545
|
+
return {
|
|
546
|
+
hirerAddress: row.hirer_address,
|
|
547
|
+
providerAddress: config.wallet.contract_address,
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
const deliveryClient = new delivery_client_1.DeliveryClient({ autoDownload: config.delivery.auto_download });
|
|
551
|
+
deliveryClient.log = log;
|
|
552
|
+
log({ event: "file_delivery_ready", serve_files: config.delivery.serve_files, auto_download: config.delivery.auto_download });
|
|
553
|
+
// ── Compute rental subsystem ─────────────────────────────────────────────
|
|
554
|
+
let computeMetering = null;
|
|
555
|
+
let computeSessions = null;
|
|
556
|
+
if (config.compute.enabled) {
|
|
557
|
+
computeMetering = new compute_metering_1.ComputeMetering(machinePrivateKey, config.network.chain_id, config.compute.compute_agreement_address || config.wallet.contract_address, config.compute.metering_interval_seconds, config.compute.report_interval_minutes, config.compute.compute_agreement_address ? provider : undefined);
|
|
558
|
+
computeSessions = new compute_session_1.ComputeSessionManager(computeMetering);
|
|
559
|
+
log({ event: "compute_enabled", gpu_spec: config.compute.gpu_spec, rate_wei: config.compute.rate_per_hour_wei });
|
|
560
|
+
}
|
|
561
|
+
// ── Worker executor ──────────────────────────────────────────────────────
|
|
562
|
+
const workerExecutor = new worker_executor_js_1.WorkerExecutor({
|
|
563
|
+
maxConcurrentJobs: config.worker?.max_concurrent_jobs ?? 2,
|
|
564
|
+
jobTimeoutSeconds: config.worker?.job_timeout_seconds ?? 3600,
|
|
565
|
+
agentType: config.worker?.agent_type ?? "claude-code",
|
|
566
|
+
autoExecute: config.worker?.auto_execute ?? true,
|
|
567
|
+
delivery: fileDelivery,
|
|
568
|
+
signer: machineKeySigner,
|
|
569
|
+
serviceAgreementAddress: config.serviceAgreementAddress ?? null,
|
|
570
|
+
});
|
|
571
|
+
workerExecutor.log = log;
|
|
572
|
+
const endpointPolicy = new endpoint_policy_1.EndpointPolicy();
|
|
573
|
+
// onJobCompleted: worker finished — submit fulfill UserOp on-chain, update DB, notify
|
|
574
|
+
workerExecutor.onJobCompleted = (agreementId, rootHash) => {
|
|
575
|
+
endpointPolicy.releaseJob(agreementId);
|
|
576
|
+
log({ event: "worker_job_completed", agreement_id: agreementId, root_hash: rootHash });
|
|
577
|
+
emitDaemonEvent("job_completed", { id: agreementId });
|
|
578
|
+
const hire = db.getHireRequestByAgreementId(agreementId);
|
|
579
|
+
if (!hire) {
|
|
580
|
+
log({ event: "worker_complete_no_hire", agreement_id: agreementId });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Mark delivered in DB
|
|
584
|
+
db.updateHireRequestStatus(hire.id, "delivered");
|
|
585
|
+
// Submit fulfill UserOp
|
|
586
|
+
if (config.serviceAgreementAddress) {
|
|
587
|
+
const callData = (0, userops_1.buildFulfillCalldata)(config.serviceAgreementAddress, agreementId, rootHash, config.wallet.contract_address);
|
|
588
|
+
userOps.submit(callData, config.wallet.contract_address)
|
|
589
|
+
.then((hash) => {
|
|
590
|
+
log({ event: "fulfill_userop_submitted", agreement_id: agreementId, userop_hash: hash, root_hash: rootHash });
|
|
591
|
+
emitDaemonEvent("deliverable_committed", { id: agreementId, hash: rootHash });
|
|
592
|
+
// Generate receipt + extract learnings
|
|
593
|
+
const now = new Date().toISOString();
|
|
594
|
+
const startedAt = new Date(hire.created_at).toISOString();
|
|
595
|
+
const receipt = (0, job_lifecycle_1.generateReceipt)({
|
|
596
|
+
agreementId,
|
|
597
|
+
deliverableHash: rootHash,
|
|
598
|
+
walletAddress: config.wallet.contract_address,
|
|
599
|
+
startedAt,
|
|
600
|
+
completedAt: now,
|
|
601
|
+
});
|
|
602
|
+
(0, job_lifecycle_1.extractLearnings)({
|
|
603
|
+
agreementId,
|
|
604
|
+
taskDescription: hire.capability ?? "unknown",
|
|
605
|
+
deliverableHash: rootHash,
|
|
606
|
+
priceEth: hire.price_eth ?? "0",
|
|
607
|
+
capability: hire.capability ?? "general",
|
|
608
|
+
wallClockSeconds: receipt.metrics.wall_clock_seconds,
|
|
609
|
+
success: true,
|
|
610
|
+
});
|
|
611
|
+
db.updateHireRequestStatus(hire.id, "complete");
|
|
612
|
+
log({ event: "job_lifecycle_complete", agreement_id: agreementId, receipt_hash: receipt.receipt_hash });
|
|
613
|
+
if (config.notifications.notify_on_delivery) {
|
|
614
|
+
void notifier.notifyDelivery(agreementId, rootHash, "");
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
.catch((err) => {
|
|
618
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
619
|
+
log({ event: "fulfill_userop_failed", agreement_id: agreementId, error: msg });
|
|
620
|
+
log({ event: "MANUAL_ACCEPT_REQUIRED", agreement_id: agreementId, message: `Run: arc402 deliver ${agreementId} --hash ${rootHash}` });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
// onJobFailed: worker failed — log, update DB, notify operator
|
|
625
|
+
workerExecutor.onJobFailed = (agreementId, error) => {
|
|
626
|
+
endpointPolicy.releaseJob(agreementId);
|
|
627
|
+
log({ event: "worker_job_failed", agreement_id: agreementId, error });
|
|
628
|
+
emitDaemonEvent("job_failed", { id: agreementId, reason: error });
|
|
629
|
+
const hire = db.getHireRequestByAgreementId(agreementId);
|
|
630
|
+
if (hire) {
|
|
631
|
+
db.updateHireRequestStatus(hire.id, "rejected", `worker_failed: ${error}`);
|
|
632
|
+
}
|
|
633
|
+
void notifier.send("hire_rejected", "Job Execution Failed", [
|
|
634
|
+
`Agreement: ${agreementId}`,
|
|
635
|
+
`Error: ${error}`,
|
|
636
|
+
``,
|
|
637
|
+
`Manual deliver: arc402 deliver ${agreementId} --hash <hash>`,
|
|
638
|
+
].join("\n")).catch(() => { });
|
|
639
|
+
};
|
|
640
|
+
log({ event: "worker_executor_ready", agent_type: workerExecutor["agentType"], max_concurrent: workerExecutor["maxConcurrentJobs"] });
|
|
641
|
+
// ── Step 10: Start relay listener ───────────────────────────────────────
|
|
642
|
+
const hireListener = new hire_listener_1.HireListener(config, db, notifier, config.wallet.contract_address);
|
|
643
|
+
const ipcCtx = {
|
|
644
|
+
db,
|
|
645
|
+
config,
|
|
646
|
+
startTime: Date.now(),
|
|
647
|
+
walletAddress: config.wallet.contract_address,
|
|
648
|
+
machineKeyAddress,
|
|
649
|
+
hireListener,
|
|
650
|
+
userOps,
|
|
651
|
+
workerExecutor,
|
|
652
|
+
activeAgreements: 0,
|
|
653
|
+
bundlerMode: config.bundler.mode,
|
|
654
|
+
bundlerEndpoint,
|
|
655
|
+
};
|
|
656
|
+
// Wire approve callback — submits UserOp when hire is auto-accepted, then enqueues execution
|
|
657
|
+
hireListener.setApproveCallback(async (hireId) => {
|
|
658
|
+
const hire = db.getHireRequest(hireId);
|
|
659
|
+
if (!hire || !hire.agreement_id || !config.serviceAgreementAddress)
|
|
660
|
+
return;
|
|
661
|
+
// Submit accept on-chain via UserOp; if it fails, require manual accept.
|
|
662
|
+
try {
|
|
663
|
+
const callData = (0, userops_1.buildAcceptCalldata)(config.serviceAgreementAddress, hire.agreement_id, config.wallet.contract_address);
|
|
664
|
+
const hash = await userOps.submit(callData, config.wallet.contract_address);
|
|
665
|
+
log({ event: "hire_auto_accepted_userop", id: hireId, userop_hash: hash });
|
|
666
|
+
if (config.notifications.notify_on_hire_accepted) {
|
|
667
|
+
await notifier.notifyHireAccepted(hireId, hire.agreement_id);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
672
|
+
log({ event: "accept_userop_failed", id: hireId, error: msg });
|
|
673
|
+
log({ event: "MANUAL_ACCEPT_REQUIRED", agreement_id: hire.agreement_id, message: `Run: arc402 accept ${hire.agreement_id}` });
|
|
674
|
+
}
|
|
675
|
+
// Enqueue task execution — worker runs the job, then delivers on completion
|
|
676
|
+
workerExecutor.enqueue({
|
|
677
|
+
agreementId: hire.agreement_id,
|
|
678
|
+
capability: hire.capability ?? "general",
|
|
679
|
+
specHash: hire.spec_hash ?? "0x0",
|
|
680
|
+
taskDescription: hire.task_description ?? hire.capability ?? undefined,
|
|
681
|
+
});
|
|
682
|
+
endpointPolicy.lockForJob(hire.agreement_id);
|
|
683
|
+
log({ event: "job_enqueued", id: hireId, agreement_id: hire.agreement_id, capability: hire.capability });
|
|
684
|
+
emitDaemonEvent("job_started", { id: hire.agreement_id, harness: "worker" });
|
|
685
|
+
// Seed staged deliverables if configured for this capability
|
|
686
|
+
const stagedDir = config.delivery.staged_dir;
|
|
687
|
+
const capCfg = config.delivery.capabilities?.find(c => c.name === hire.capability);
|
|
688
|
+
if (stagedDir && capCfg) {
|
|
689
|
+
const srcDir = path.join(stagedDir, capCfg.path);
|
|
690
|
+
const jobDir = path.join(os.homedir(), ".arc402", "jobs", `agreement-${hire.agreement_id}`);
|
|
691
|
+
if (fs.existsSync(srcDir)) {
|
|
692
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
693
|
+
const files = fs.readdirSync(srcDir);
|
|
694
|
+
for (const file of files) {
|
|
695
|
+
fs.copyFileSync(path.join(srcDir, file), path.join(jobDir, file));
|
|
696
|
+
}
|
|
697
|
+
log({ event: "staged_files_seeded", agreement_id: hire.agreement_id, capability: hire.capability, count: files.length });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
// Relay poll interval
|
|
702
|
+
let relayInterval = null;
|
|
703
|
+
if (config.relay.enabled && config.relay.relay_url) {
|
|
704
|
+
const pollMs = config.relay.poll_interval_seconds * 1000;
|
|
705
|
+
relayInterval = setInterval(() => { void hireListener.poll(); }, pollMs);
|
|
706
|
+
void hireListener.poll(); // immediate first poll
|
|
707
|
+
log({ event: "relay_started", url: config.relay.relay_url, poll_seconds: config.relay.poll_interval_seconds });
|
|
708
|
+
}
|
|
709
|
+
// Hire timeout checker — reject stale pending approvals
|
|
710
|
+
const timeoutInterval = setInterval(() => {
|
|
711
|
+
const pending = db.listPendingHireRequests();
|
|
712
|
+
const now = Math.floor(Date.now() / 1000);
|
|
713
|
+
for (const req of pending) {
|
|
714
|
+
const minLead = config.policy.min_hire_lead_time_seconds;
|
|
715
|
+
if (req.deadline_unix > 0 && req.deadline_unix < now + minLead) {
|
|
716
|
+
db.updateHireRequestStatus(req.id, "rejected", "approval_timeout");
|
|
717
|
+
log({ event: "hire_timeout_rejected", id: req.id });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}, 30000);
|
|
721
|
+
// Rate limit map cleanup — every 5 minutes (prevents unbounded growth)
|
|
722
|
+
rateLimitCleanupInterval = setInterval(() => {
|
|
723
|
+
const now = Date.now();
|
|
724
|
+
for (const [ip, bucket] of rateLimitMap) {
|
|
725
|
+
if (bucket.resetTime < now)
|
|
726
|
+
rateLimitMap.delete(ip);
|
|
727
|
+
}
|
|
728
|
+
}, 5 * 60 * 1000);
|
|
729
|
+
// Balance monitor — every 5 minutes
|
|
730
|
+
const balanceInterval = setInterval(async () => {
|
|
731
|
+
try {
|
|
732
|
+
const balance = await (0, wallet_monitor_1.getWalletBalance)(config.wallet.contract_address, provider);
|
|
733
|
+
const threshold = parseFloat(config.notifications.low_balance_threshold_eth);
|
|
734
|
+
if (parseFloat(balance) < threshold) {
|
|
735
|
+
log({ event: "low_balance", balance_eth: balance, threshold_eth: config.notifications.low_balance_threshold_eth });
|
|
736
|
+
await notifier.notifyLowBalance(balance, config.notifications.low_balance_threshold_eth);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch { /* non-fatal */ }
|
|
740
|
+
}, 5 * 60 * 1000);
|
|
741
|
+
// ── Handshake watcher ────────────────────────────────────────────────────
|
|
742
|
+
const handshakeWatcher = new handshake_watcher_js_1.HandshakeWatcher(provider, '0x4F5A38Bb746d7E5d49d8fd26CA6beD141Ec2DDb3', config.wallet.contract_address, async (event) => {
|
|
743
|
+
log({ event: "handshake_received", ...event, amount: event.amount.toString() });
|
|
744
|
+
}, path.join(os.homedir(), '.arc402', 'processed-handshakes.json'));
|
|
745
|
+
await handshakeWatcher.start();
|
|
746
|
+
log({ event: "handshake_watcher_started", address: config.wallet.contract_address });
|
|
747
|
+
// ── Step 11: Write PID file (if not foreground) ──────────────────────────
|
|
748
|
+
if (!foreground) {
|
|
749
|
+
fs.writeFileSync(config_1.DAEMON_PID, String(process.pid), { mode: 0o600 });
|
|
750
|
+
log({ event: "pid_written", pid: process.pid, path: config_1.DAEMON_PID });
|
|
751
|
+
}
|
|
752
|
+
// ── Generate and save API token ──────────────────────────────────────────
|
|
753
|
+
const apiToken = generateApiToken();
|
|
754
|
+
saveApiToken(apiToken);
|
|
755
|
+
log({ event: "auth_token_saved", path: DAEMON_TOKEN_FILE });
|
|
756
|
+
// ── Start IPC socket ─────────────────────────────────────────────────────
|
|
757
|
+
const ipcServer = startIpcServer(ipcCtx, log, apiToken);
|
|
758
|
+
// ── Start HTTP relay server (public endpoint) ────────────────────────────
|
|
759
|
+
const httpPort = config.relay.listen_port ?? 4402;
|
|
760
|
+
/**
|
|
761
|
+
* Optionally verifies X-ARC402-Signature against the request body.
|
|
762
|
+
* Logs the result but never rejects — unsigned requests are accepted for backwards compat.
|
|
763
|
+
*/
|
|
764
|
+
function verifyRequestSignature(body, req) {
|
|
765
|
+
const sig = req.headers["x-arc402-signature"];
|
|
766
|
+
if (!sig)
|
|
767
|
+
return;
|
|
768
|
+
const claimedSigner = req.headers["x-arc402-signer"];
|
|
769
|
+
try {
|
|
770
|
+
const recovered = ethers_1.ethers.verifyMessage(body, sig);
|
|
771
|
+
if (claimedSigner && recovered.toLowerCase() !== claimedSigner.toLowerCase()) {
|
|
772
|
+
log({ event: "sig_mismatch", claimed: claimedSigner, recovered });
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
log({ event: "sig_verified", signer: recovered });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
log({ event: "sig_invalid" });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Read request body with a size cap. Destroys the request and sends 413
|
|
784
|
+
* if the body exceeds MAX_BODY_SIZE. Returns null in that case.
|
|
785
|
+
*/
|
|
786
|
+
function readBody(req, res) {
|
|
787
|
+
return new Promise((resolve) => {
|
|
788
|
+
let body = "";
|
|
789
|
+
let size = 0;
|
|
790
|
+
req.on("data", (chunk) => {
|
|
791
|
+
size += chunk.length;
|
|
792
|
+
if (size > MAX_BODY_SIZE) {
|
|
793
|
+
req.destroy();
|
|
794
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
795
|
+
res.end(JSON.stringify({ error: "payload_too_large" }));
|
|
796
|
+
resolve(null);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
body += chunk.toString();
|
|
800
|
+
});
|
|
801
|
+
req.on("end", () => { resolve(body); });
|
|
802
|
+
req.on("error", () => { resolve(null); });
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const PUBLIC_GET_PATHS = new Set(["/", "/health", "/agent", "/capabilities", "/status", "/events", "/auth/challenge", "/auth/status"]);
|
|
806
|
+
const authCfg = {
|
|
807
|
+
daemonId: config.wallet.contract_address,
|
|
808
|
+
rpcUrl: config.network.rpc_url,
|
|
809
|
+
chainId: config.network.chain_id,
|
|
810
|
+
walletAddress: config.wallet.contract_address,
|
|
811
|
+
};
|
|
812
|
+
const authDb = new better_sqlite3_1.default(config_1.DAEMON_DB);
|
|
813
|
+
authDb.pragma("journal_mode = WAL");
|
|
814
|
+
const authSessions = new session_manager_js_1.SessionManager(authDb);
|
|
815
|
+
// Auth endpoints — open (challenge-response flow; no daemon token required).
|
|
816
|
+
// Protocol POST endpoints — open to external agents (no daemon token required).
|
|
817
|
+
// These are inbound P2P messages: hire proposals, handshakes, delivery notifications, etc.
|
|
818
|
+
// EIP-191 signature in the request body is the trust mechanism, not the daemon bearer token.
|
|
819
|
+
// The bearer token is for operator/admin actions only (approve, reject, status).
|
|
820
|
+
const PUBLIC_POST_PATHS = new Set([
|
|
821
|
+
"/auth/session",
|
|
822
|
+
"/auth/revoke",
|
|
823
|
+
"/hire",
|
|
824
|
+
"/hire/accepted",
|
|
825
|
+
"/handshake",
|
|
826
|
+
"/message",
|
|
827
|
+
"/delivery",
|
|
828
|
+
"/delivery/accepted",
|
|
829
|
+
"/dispute",
|
|
830
|
+
"/dispute/resolved",
|
|
831
|
+
"/workroom/status",
|
|
832
|
+
]);
|
|
833
|
+
// CORS whitelist — localhost for local tooling, arc402.xyz for the web app
|
|
834
|
+
const CORS_WHITELIST = new Set(["localhost", "127.0.0.1", "arc402.xyz", "app.arc402.xyz"]);
|
|
835
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
836
|
+
// CORS — only reflect origin header if it's in the whitelist
|
|
837
|
+
const origin = (req.headers["origin"] ?? "");
|
|
838
|
+
if (origin) {
|
|
839
|
+
try {
|
|
840
|
+
const { hostname } = new URL(origin);
|
|
841
|
+
if (CORS_WHITELIST.has(hostname)) {
|
|
842
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
843
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
844
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch { /* ignore invalid origin */ }
|
|
848
|
+
}
|
|
849
|
+
if (req.method === "OPTIONS") {
|
|
850
|
+
res.writeHead(204);
|
|
851
|
+
res.end();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const url = new URL(req.url || "/", `http://localhost:${httpPort}`);
|
|
855
|
+
const pathname = url.pathname;
|
|
856
|
+
const jobId = (0, endpoint_policy_1.resolveJobId)(req.headers);
|
|
857
|
+
// Rate limiting (all endpoints)
|
|
858
|
+
const clientIp = (req.socket.remoteAddress ?? "unknown").replace(/^::ffff:/, "");
|
|
859
|
+
if (!checkRateLimit(clientIp)) {
|
|
860
|
+
log({ event: "rate_limited", ip: clientIp, path: pathname });
|
|
861
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
862
|
+
res.end(JSON.stringify({ error: "too_many_requests" }));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
// Auth: protocol POST endpoints are open (P2P — external agents have no daemon token).
|
|
866
|
+
// Operator GET paths and all non-protocol POSTs require the daemon bearer token.
|
|
867
|
+
// /job/* GET routes use party-based EIP-191 auth (verifyPartyAccess), not bearer token.
|
|
868
|
+
const isPublicPost = req.method === "POST" && PUBLIC_POST_PATHS.has(pathname);
|
|
869
|
+
const isPublicGet = req.method === "GET" && (PUBLIC_GET_PATHS.has(pathname) || pathname.startsWith("/job/") || pathname.startsWith("/newsletter/"));
|
|
870
|
+
if (!isPublicPost && !isPublicGet) {
|
|
871
|
+
const authHeader = req.headers["authorization"] ?? "";
|
|
872
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
873
|
+
if (token !== apiToken) {
|
|
874
|
+
log({ event: "http_unauthorized", ip: clientIp, path: pathname });
|
|
875
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
876
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
// SSE live event stream
|
|
881
|
+
if (pathname === "/events" && req.method === "GET") {
|
|
882
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
883
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
884
|
+
res.setHeader("Connection", "keep-alive");
|
|
885
|
+
res.setHeader("Access-Control-Allow-Origin", "http://127.0.0.1");
|
|
886
|
+
res.flushHeaders();
|
|
887
|
+
res.write("data: connected\n\n");
|
|
888
|
+
sseClients.add(res);
|
|
889
|
+
req.on("close", () => sseClients.delete(res));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
// Health / info
|
|
893
|
+
if (pathname === "/" || pathname === "/health") {
|
|
894
|
+
const info = {
|
|
895
|
+
protocol: "arc-402",
|
|
896
|
+
version: "0.3.0",
|
|
897
|
+
agent: config.wallet.contract_address,
|
|
898
|
+
status: "online",
|
|
899
|
+
capabilities: config.policy.allowed_capabilities,
|
|
900
|
+
uptime: Math.floor((Date.now() - ipcCtx.startTime) / 1000),
|
|
901
|
+
};
|
|
902
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
903
|
+
res.end(JSON.stringify(info));
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// Agent info
|
|
907
|
+
if (pathname === "/agent") {
|
|
908
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
909
|
+
res.end(JSON.stringify({
|
|
910
|
+
wallet: config.wallet.contract_address,
|
|
911
|
+
owner: config.wallet.owner_address,
|
|
912
|
+
machineKey: machineKeyAddress,
|
|
913
|
+
chainId: config.network.chain_id,
|
|
914
|
+
bundlerMode: config.bundler.mode,
|
|
915
|
+
relay: config.relay.enabled,
|
|
916
|
+
}));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// ── Auth routes (Spec 46 §11) ─────────────────────────────────────────────
|
|
920
|
+
if (pathname === "/auth/challenge" && req.method === "GET") {
|
|
921
|
+
const walletQ = url.searchParams.get("wallet") ?? config.wallet.contract_address;
|
|
922
|
+
const scopeQ = url.searchParams.get("scope") ?? "operator";
|
|
923
|
+
const challenge = (0, auth_server_js_1.issueAuthChallenge)(authSessions, authCfg, walletQ, scopeQ);
|
|
924
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
925
|
+
res.end(JSON.stringify(challenge));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (pathname === "/auth/session" && req.method === "POST") {
|
|
929
|
+
const body = await readBody(req, res);
|
|
930
|
+
if (body === null)
|
|
931
|
+
return;
|
|
932
|
+
try {
|
|
933
|
+
const { challengeId, signature } = JSON.parse(body);
|
|
934
|
+
if (!challengeId || !signature) {
|
|
935
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
936
|
+
res.end(JSON.stringify({ error: "challengeId and signature required" }));
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const result = await (0, auth_server_js_1.consumeAuthChallenge)(authSessions, authCfg, {}, provider, challengeId, signature);
|
|
940
|
+
if (!result.ok) {
|
|
941
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
942
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
946
|
+
res.end(JSON.stringify(result));
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
950
|
+
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
951
|
+
}
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (pathname === "/auth/status" && req.method === "GET") {
|
|
955
|
+
const authHeader = (req.headers["authorization"] ?? "");
|
|
956
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
957
|
+
if (!token) {
|
|
958
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
959
|
+
res.end(JSON.stringify({ authenticated: false }));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const session = authSessions.validateSession(token);
|
|
963
|
+
if (!session) {
|
|
964
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
965
|
+
res.end(JSON.stringify({ authenticated: false }));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const expiresIn = Math.floor((session.expiresAt - Date.now()) / 1000);
|
|
969
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
970
|
+
res.end(JSON.stringify({ authenticated: true, walletAddress: session.wallet, scope: session.scope, expiresIn }));
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (pathname === "/auth/revoke" && req.method === "POST") {
|
|
974
|
+
const authHeader = (req.headers["authorization"] ?? "");
|
|
975
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
976
|
+
if (!token) {
|
|
977
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
978
|
+
res.end(JSON.stringify({ error: "no_session" }));
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const session = authSessions.validateSession(token);
|
|
982
|
+
if (!session) {
|
|
983
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
984
|
+
res.end(JSON.stringify({ error: "invalid_session" }));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
authSessions.revokeByWallet(session.wallet);
|
|
988
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
989
|
+
res.end(JSON.stringify({ ok: true, revoked: session.wallet }));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
// Receive hire proposal
|
|
993
|
+
if (pathname === "/hire" && req.method === "POST") {
|
|
994
|
+
const body = await readBody(req, res);
|
|
995
|
+
if (body === null)
|
|
996
|
+
return;
|
|
997
|
+
verifyRequestSignature(body, req);
|
|
998
|
+
try {
|
|
999
|
+
const msg = JSON.parse(body);
|
|
1000
|
+
if (jobId) {
|
|
1001
|
+
endpointPolicy.lockForJob(jobId);
|
|
1002
|
+
if ((0, endpoint_policy_1.hasExplicitCommerceDelegation)(msg, pathname)) {
|
|
1003
|
+
endpointPolicy.grantCommerceDelegate(jobId);
|
|
1004
|
+
}
|
|
1005
|
+
if (!endpointPolicy.isAllowed(jobId, pathname)) {
|
|
1006
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1007
|
+
res.end(JSON.stringify({
|
|
1008
|
+
error: "commerce_delegation_required",
|
|
1009
|
+
reason: "This job was not granted commerce delegation. The worker agent cannot initiate hires or subscriptions.",
|
|
1010
|
+
}));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Resolve hirer address — prefer the on-chain smart wallet client field
|
|
1015
|
+
let resolvedHirerAddress = String(msg.hirerAddress ?? msg.hirer_address ?? msg.from ?? "");
|
|
1016
|
+
const rawAgreementId = msg.agreementId ? String(msg.agreementId) : undefined;
|
|
1017
|
+
if (rawAgreementId && config.serviceAgreementAddress) {
|
|
1018
|
+
try {
|
|
1019
|
+
const sa = new ethers_1.ethers.Contract(config.serviceAgreementAddress, abis_1.SERVICE_AGREEMENT_ABI, provider);
|
|
1020
|
+
const ag = await sa.getAgreement(BigInt(rawAgreementId));
|
|
1021
|
+
if (ag.client && ag.client !== ethers_1.ethers.ZeroAddress) {
|
|
1022
|
+
resolvedHirerAddress = ag.client;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
catch { /* use fallback */ }
|
|
1026
|
+
}
|
|
1027
|
+
const taskDescription = String(msg.task ?? msg.taskDescription ?? "");
|
|
1028
|
+
for (const text of collectInboundTaskTexts(msg)) {
|
|
1029
|
+
const guardResult = (0, prompt_guard_1.guardTaskContent)(text);
|
|
1030
|
+
if (!guardResult.safe) {
|
|
1031
|
+
log({
|
|
1032
|
+
event: "prompt_guard_rejected",
|
|
1033
|
+
path: pathname,
|
|
1034
|
+
job_id: jobId,
|
|
1035
|
+
category: guardResult.category,
|
|
1036
|
+
severity: guardResult.severity,
|
|
1037
|
+
excerpt: guardResult.excerpt,
|
|
1038
|
+
});
|
|
1039
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1040
|
+
res.end(JSON.stringify({
|
|
1041
|
+
error: "task_rejected",
|
|
1042
|
+
reason: "Task content failed security screening",
|
|
1043
|
+
code: "PROMPT_INJECTION_DETECTED",
|
|
1044
|
+
category: guardResult.category,
|
|
1045
|
+
}));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// Feed into the hire listener's message handler
|
|
1050
|
+
const proposal = {
|
|
1051
|
+
messageId: String(msg.messageId ?? msg.id ?? `http_${Date.now()}`),
|
|
1052
|
+
hirerAddress: resolvedHirerAddress,
|
|
1053
|
+
capability: String(msg.capability ?? msg.serviceType ?? ""),
|
|
1054
|
+
priceEth: String(msg.priceEth ?? msg.price_eth ?? "0"),
|
|
1055
|
+
deadlineUnix: Number(msg.deadlineUnix ?? msg.deadline ?? 0),
|
|
1056
|
+
specHash: String(msg.specHash ?? msg.spec_hash ?? ""),
|
|
1057
|
+
agreementId: rawAgreementId,
|
|
1058
|
+
signature: msg.signature ? String(msg.signature) : undefined,
|
|
1059
|
+
};
|
|
1060
|
+
// Dedup
|
|
1061
|
+
const existing = db.getHireRequest(proposal.messageId);
|
|
1062
|
+
if (existing) {
|
|
1063
|
+
log({ event: "hire_duplicate", id: proposal.messageId, status: existing.status });
|
|
1064
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1065
|
+
res.end(JSON.stringify({ status: existing.status, id: proposal.messageId }));
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Policy check
|
|
1069
|
+
const { evaluatePolicy } = await Promise.resolve().then(() => __importStar(require("./hire-listener")));
|
|
1070
|
+
const activeCount = db.countActiveHireRequests();
|
|
1071
|
+
const policyResult = evaluatePolicy(proposal, config, activeCount);
|
|
1072
|
+
if (!policyResult.allowed) {
|
|
1073
|
+
db.insertHireRequest({
|
|
1074
|
+
id: proposal.messageId,
|
|
1075
|
+
agreement_id: proposal.agreementId ?? null,
|
|
1076
|
+
hirer_address: proposal.hirerAddress,
|
|
1077
|
+
capability: proposal.capability,
|
|
1078
|
+
price_eth: proposal.priceEth,
|
|
1079
|
+
deadline_unix: proposal.deadlineUnix,
|
|
1080
|
+
spec_hash: proposal.specHash,
|
|
1081
|
+
task_description: taskDescription || null,
|
|
1082
|
+
status: "rejected",
|
|
1083
|
+
reject_reason: policyResult.reason ?? "policy_violation",
|
|
1084
|
+
});
|
|
1085
|
+
log({ event: "http_hire_rejected", id: proposal.messageId, reason: policyResult.reason });
|
|
1086
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1087
|
+
res.end(JSON.stringify({ status: "rejected", reason: policyResult.reason, id: proposal.messageId }));
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const status = config.policy.auto_accept ? "accepted" : "pending_approval";
|
|
1091
|
+
db.insertHireRequest({
|
|
1092
|
+
id: proposal.messageId,
|
|
1093
|
+
agreement_id: proposal.agreementId ?? null,
|
|
1094
|
+
hirer_address: proposal.hirerAddress,
|
|
1095
|
+
capability: proposal.capability,
|
|
1096
|
+
price_eth: proposal.priceEth,
|
|
1097
|
+
deadline_unix: proposal.deadlineUnix,
|
|
1098
|
+
spec_hash: proposal.specHash,
|
|
1099
|
+
task_description: taskDescription || null,
|
|
1100
|
+
status,
|
|
1101
|
+
reject_reason: null,
|
|
1102
|
+
});
|
|
1103
|
+
log({ event: "http_hire_received", id: proposal.messageId, hirer: proposal.hirerAddress, status });
|
|
1104
|
+
emitDaemonEvent("hire_proposed", { id: proposal.messageId, from: proposal.hirerAddress, amount: proposal.priceEth, capability: proposal.capability });
|
|
1105
|
+
if (config.notifications.notify_on_hire_request) {
|
|
1106
|
+
await notifier.notifyHireRequest(proposal.messageId, proposal.hirerAddress, proposal.priceEth, proposal.capability);
|
|
1107
|
+
}
|
|
1108
|
+
// If auto-accepted, enqueue job for execution immediately, then try to
|
|
1109
|
+
// submit accept UserOp on-chain (best-effort — may already be accepted).
|
|
1110
|
+
if (status === "accepted" && proposal.agreementId) {
|
|
1111
|
+
// Enqueue first — job execution is independent of whether the on-chain
|
|
1112
|
+
// accept succeeds (it may already be accepted from a prior call).
|
|
1113
|
+
workerExecutor.enqueue({
|
|
1114
|
+
agreementId: proposal.agreementId,
|
|
1115
|
+
capability: proposal.capability,
|
|
1116
|
+
specHash: proposal.specHash,
|
|
1117
|
+
taskDescription: taskDescription || undefined,
|
|
1118
|
+
});
|
|
1119
|
+
endpointPolicy.lockForJob(proposal.agreementId);
|
|
1120
|
+
log({ event: "http_job_enqueued", id: proposal.messageId, agreement_id: proposal.agreementId });
|
|
1121
|
+
// Seed staged deliverables if configured for this capability
|
|
1122
|
+
const httpStagedDir = config.delivery.staged_dir;
|
|
1123
|
+
const httpCapCfg = config.delivery.capabilities?.find(c => c.name === proposal.capability);
|
|
1124
|
+
if (httpStagedDir && httpCapCfg) {
|
|
1125
|
+
const srcDir = path.join(httpStagedDir, httpCapCfg.path);
|
|
1126
|
+
const jobDir = path.join(os.homedir(), ".arc402", "jobs", `agreement-${proposal.agreementId}`);
|
|
1127
|
+
if (fs.existsSync(srcDir)) {
|
|
1128
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
1129
|
+
const files = fs.readdirSync(srcDir);
|
|
1130
|
+
for (const file of files) {
|
|
1131
|
+
fs.copyFileSync(path.join(srcDir, file), path.join(jobDir, file));
|
|
1132
|
+
}
|
|
1133
|
+
log({ event: "staged_files_seeded", agreement_id: proposal.agreementId, capability: proposal.capability, count: files.length });
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Try to submit accept UserOp (non-blocking, best-effort). If it fails,
|
|
1137
|
+
// manual accept is required because ServiceAgreement only accepts the smart wallet.
|
|
1138
|
+
if (config.serviceAgreementAddress) {
|
|
1139
|
+
void (async () => {
|
|
1140
|
+
try {
|
|
1141
|
+
const callData = (0, userops_1.buildAcceptCalldata)(config.serviceAgreementAddress, proposal.agreementId, config.wallet.contract_address);
|
|
1142
|
+
const hash = await userOps.submit(callData, config.wallet.contract_address);
|
|
1143
|
+
log({ event: "http_accept_userop_submitted", id: proposal.messageId, hash });
|
|
1144
|
+
emitDaemonEvent("agreement_accepted", { id: proposal.agreementId, provider: config.wallet.contract_address });
|
|
1145
|
+
if (config.notifications.notify_on_hire_accepted) {
|
|
1146
|
+
await notifier.notifyHireAccepted(proposal.messageId, proposal.agreementId);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
catch (err) {
|
|
1150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1151
|
+
log({ event: "http_accept_userop_failed", id: proposal.messageId, error: msg });
|
|
1152
|
+
log({ event: "MANUAL_ACCEPT_REQUIRED", agreement_id: proposal.agreementId, message: `Run: arc402 accept ${proposal.agreementId}` });
|
|
1153
|
+
}
|
|
1154
|
+
})();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1158
|
+
res.end(JSON.stringify({ status, id: proposal.messageId }));
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
log({ event: "http_hire_error", error: String(err) });
|
|
1162
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1163
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1164
|
+
}
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
// Handshake acknowledgment endpoint
|
|
1168
|
+
if (pathname === "/handshake" && req.method === "POST") {
|
|
1169
|
+
const body = await readBody(req, res);
|
|
1170
|
+
if (body === null)
|
|
1171
|
+
return;
|
|
1172
|
+
verifyRequestSignature(body, req);
|
|
1173
|
+
try {
|
|
1174
|
+
const msg = JSON.parse(body);
|
|
1175
|
+
log({ event: "handshake_received", from: msg.from, type: msg.type, note: msg.note });
|
|
1176
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1177
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1181
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1182
|
+
}
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// POST /hire/accepted — provider accepted, client notified
|
|
1186
|
+
if (pathname === "/hire/accepted" && req.method === "POST") {
|
|
1187
|
+
const body = await readBody(req, res);
|
|
1188
|
+
if (body === null)
|
|
1189
|
+
return;
|
|
1190
|
+
try {
|
|
1191
|
+
const msg = JSON.parse(body);
|
|
1192
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
1193
|
+
const from = String(msg.from ?? "");
|
|
1194
|
+
log({ event: "hire_accepted_inbound", agreementId, from });
|
|
1195
|
+
if (config.notifications.notify_on_hire_accepted) {
|
|
1196
|
+
await notifier.notifyHireAccepted(agreementId, agreementId);
|
|
1197
|
+
}
|
|
1198
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1199
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1203
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1204
|
+
}
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
// POST /message — off-chain negotiation message
|
|
1208
|
+
if (pathname === "/message" && req.method === "POST") {
|
|
1209
|
+
const body = await readBody(req, res);
|
|
1210
|
+
if (body === null)
|
|
1211
|
+
return;
|
|
1212
|
+
verifyRequestSignature(body, req);
|
|
1213
|
+
try {
|
|
1214
|
+
const msg = JSON.parse(body);
|
|
1215
|
+
const from = String(msg.from ?? "");
|
|
1216
|
+
const to = String(msg.to ?? "");
|
|
1217
|
+
const content = String(msg.content ?? "");
|
|
1218
|
+
const timestamp = Number(msg.timestamp ?? Date.now());
|
|
1219
|
+
log({ event: "message_received", from, to, timestamp, content_len: content.length });
|
|
1220
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1221
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1225
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1226
|
+
}
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// POST /delivery — provider committed a deliverable
|
|
1230
|
+
if (pathname === "/delivery" && req.method === "POST") {
|
|
1231
|
+
const body = await readBody(req, res);
|
|
1232
|
+
if (body === null)
|
|
1233
|
+
return;
|
|
1234
|
+
verifyRequestSignature(body, req);
|
|
1235
|
+
try {
|
|
1236
|
+
const msg = JSON.parse(body);
|
|
1237
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
1238
|
+
const deliverableHash = String(msg.deliverableHash ?? msg.deliverable_hash ?? "");
|
|
1239
|
+
const filesUrl = String(msg.files_url ?? msg.filesUrl ?? "");
|
|
1240
|
+
const from = String(msg.from ?? "");
|
|
1241
|
+
log({ event: "delivery_received", agreementId, deliverableHash, from, has_files_url: !!filesUrl });
|
|
1242
|
+
// Update DB: mark delivered
|
|
1243
|
+
const active = db.listActiveHireRequests();
|
|
1244
|
+
const found = active.find(r => r.agreement_id === agreementId);
|
|
1245
|
+
if (found)
|
|
1246
|
+
db.updateHireRequestStatus(found.id, "delivered");
|
|
1247
|
+
if (config.notifications.notify_on_delivery) {
|
|
1248
|
+
await notifier.notifyDelivery(agreementId, deliverableHash, "");
|
|
1249
|
+
}
|
|
1250
|
+
// Auto-download and verify if files_url provided and auto_download enabled
|
|
1251
|
+
if (filesUrl && config.delivery.auto_download) {
|
|
1252
|
+
void deliveryClient.handleDeliveryNotification({ agreementId, deliverableHash, filesUrl })
|
|
1253
|
+
.then(result => {
|
|
1254
|
+
if (result)
|
|
1255
|
+
log({ event: "delivery_auto_downloaded", agreementId, ok: result.ok, root_hash_match: result.rootHashMatch, files: result.fileResults.length });
|
|
1256
|
+
})
|
|
1257
|
+
.catch(err => log({ event: "delivery_download_error", agreementId, error: String(err) }));
|
|
1258
|
+
}
|
|
1259
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1260
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1264
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1265
|
+
}
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
// POST /delivery/accepted — client accepted delivery, payment releasing
|
|
1269
|
+
if (pathname === "/delivery/accepted" && req.method === "POST") {
|
|
1270
|
+
const body = await readBody(req, res);
|
|
1271
|
+
if (body === null)
|
|
1272
|
+
return;
|
|
1273
|
+
try {
|
|
1274
|
+
const msg = JSON.parse(body);
|
|
1275
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
1276
|
+
const from = String(msg.from ?? "");
|
|
1277
|
+
log({ event: "delivery_accepted_inbound", agreementId, from });
|
|
1278
|
+
// Update DB: mark complete
|
|
1279
|
+
const all = db.listActiveHireRequests();
|
|
1280
|
+
const found = all.find(r => r.agreement_id === agreementId);
|
|
1281
|
+
if (found)
|
|
1282
|
+
db.updateHireRequestStatus(found.id, "complete");
|
|
1283
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1284
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1288
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1289
|
+
}
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
// POST /dispute — dispute raised against this agent
|
|
1293
|
+
if (pathname === "/dispute" && req.method === "POST") {
|
|
1294
|
+
const body = await readBody(req, res);
|
|
1295
|
+
if (body === null)
|
|
1296
|
+
return;
|
|
1297
|
+
try {
|
|
1298
|
+
const msg = JSON.parse(body);
|
|
1299
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
1300
|
+
const reason = String(msg.reason ?? "");
|
|
1301
|
+
const from = String(msg.from ?? "");
|
|
1302
|
+
log({ event: "dispute_received", agreementId, reason, from });
|
|
1303
|
+
if (config.notifications.notify_on_dispute) {
|
|
1304
|
+
await notifier.notifyDispute(agreementId, from);
|
|
1305
|
+
}
|
|
1306
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1307
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1311
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1312
|
+
}
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
// POST /dispute/resolved — dispute resolved by arbitrator
|
|
1316
|
+
if (pathname === "/dispute/resolved" && req.method === "POST") {
|
|
1317
|
+
const body = await readBody(req, res);
|
|
1318
|
+
if (body === null)
|
|
1319
|
+
return;
|
|
1320
|
+
try {
|
|
1321
|
+
const msg = JSON.parse(body);
|
|
1322
|
+
const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
|
|
1323
|
+
const outcome = String(msg.outcome ?? "");
|
|
1324
|
+
const from = String(msg.from ?? "");
|
|
1325
|
+
log({ event: "dispute_resolved_inbound", agreementId, outcome, from });
|
|
1326
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1327
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1331
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
// POST /workroom/status — workroom lifecycle events
|
|
1336
|
+
if (pathname === "/workroom/status" && req.method === "POST") {
|
|
1337
|
+
const body = await readBody(req, res);
|
|
1338
|
+
if (body === null)
|
|
1339
|
+
return;
|
|
1340
|
+
try {
|
|
1341
|
+
const msg = JSON.parse(body);
|
|
1342
|
+
const event = String(msg.event ?? "");
|
|
1343
|
+
const agentAddress = String(msg.agentAddress ?? config.wallet.contract_address);
|
|
1344
|
+
const jobId = msg.jobId ? String(msg.jobId) : undefined;
|
|
1345
|
+
const timestamp = Number(msg.timestamp ?? Date.now());
|
|
1346
|
+
log({ event: "workroom_lifecycle", workroom_event: event, agentAddress, jobId, timestamp });
|
|
1347
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1348
|
+
res.end(JSON.stringify({ received: true, agent: config.wallet.contract_address }));
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1352
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
// GET /capabilities — agent capabilities from config
|
|
1357
|
+
if (pathname === "/capabilities" && req.method === "GET") {
|
|
1358
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1359
|
+
res.end(JSON.stringify({
|
|
1360
|
+
capabilities: config.policy.allowed_capabilities,
|
|
1361
|
+
max_price_eth: config.policy.max_price_eth,
|
|
1362
|
+
auto_accept: config.policy.auto_accept,
|
|
1363
|
+
max_concurrent_agreements: config.relay.max_concurrent_agreements,
|
|
1364
|
+
}));
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
// GET /status — health with active agreement count (sensitive counts only for authenticated)
|
|
1368
|
+
if (pathname === "/status" && req.method === "GET") {
|
|
1369
|
+
const statusAuth = (req.headers["authorization"] ?? "");
|
|
1370
|
+
const statusToken = statusAuth.startsWith("Bearer ") ? statusAuth.slice(7) : "";
|
|
1371
|
+
const statusAuthed = statusToken === apiToken;
|
|
1372
|
+
const statusPayload = {
|
|
1373
|
+
protocol: "arc-402",
|
|
1374
|
+
version: "0.3.0",
|
|
1375
|
+
agent: config.wallet.contract_address,
|
|
1376
|
+
status: "online",
|
|
1377
|
+
uptime: Math.floor((Date.now() - ipcCtx.startTime) / 1000),
|
|
1378
|
+
capabilities: config.policy.allowed_capabilities,
|
|
1379
|
+
};
|
|
1380
|
+
if (statusAuthed) {
|
|
1381
|
+
statusPayload.active_agreements = db.listActiveHireRequests().length;
|
|
1382
|
+
statusPayload.pending_approval = db.listPendingHireRequests().length;
|
|
1383
|
+
if (config.delivery.serve_files) {
|
|
1384
|
+
const deliveriesDir = path.join(config_1.DAEMON_DIR, "deliveries");
|
|
1385
|
+
let totalDeliveries = 0, totalFiles = 0;
|
|
1386
|
+
if (fs.existsSync(deliveriesDir)) {
|
|
1387
|
+
const entries = fs.readdirSync(deliveriesDir);
|
|
1388
|
+
totalDeliveries = entries.length;
|
|
1389
|
+
for (const entry of entries) {
|
|
1390
|
+
const entryPath = path.join(deliveriesDir, entry);
|
|
1391
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
1392
|
+
totalFiles += fs.readdirSync(entryPath).filter(f => f !== "_manifest.json").length;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
statusPayload.file_delivery = { total_deliveries: totalDeliveries, total_files: totalFiles };
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1400
|
+
res.end(JSON.stringify(statusPayload));
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
// ── Compute rental routes ─────────────────────────────────────────────
|
|
1404
|
+
// POST /compute/propose — client proposes a compute session
|
|
1405
|
+
if (pathname === "/compute/propose" && req.method === "POST") {
|
|
1406
|
+
const body = await readBody(req, res);
|
|
1407
|
+
if (body === null)
|
|
1408
|
+
return;
|
|
1409
|
+
try {
|
|
1410
|
+
const msg = JSON.parse(body);
|
|
1411
|
+
if (jobId) {
|
|
1412
|
+
endpointPolicy.lockForJob(jobId);
|
|
1413
|
+
if ((0, endpoint_policy_1.hasExplicitCommerceDelegation)(msg, pathname)) {
|
|
1414
|
+
endpointPolicy.grantCommerceDelegate(jobId);
|
|
1415
|
+
}
|
|
1416
|
+
if (!endpointPolicy.isAllowed(jobId, pathname)) {
|
|
1417
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1418
|
+
res.end(JSON.stringify({
|
|
1419
|
+
error: "commerce_delegation_required",
|
|
1420
|
+
reason: "This job was not granted commerce delegation. The worker agent cannot initiate hires or subscriptions.",
|
|
1421
|
+
}));
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
for (const text of collectInboundTaskTexts(msg)) {
|
|
1426
|
+
const guardResult = (0, prompt_guard_1.guardTaskContent)(text);
|
|
1427
|
+
if (!guardResult.safe) {
|
|
1428
|
+
log({
|
|
1429
|
+
event: "prompt_guard_rejected",
|
|
1430
|
+
path: pathname,
|
|
1431
|
+
job_id: jobId,
|
|
1432
|
+
category: guardResult.category,
|
|
1433
|
+
severity: guardResult.severity,
|
|
1434
|
+
excerpt: guardResult.excerpt,
|
|
1435
|
+
});
|
|
1436
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1437
|
+
res.end(JSON.stringify({
|
|
1438
|
+
error: "task_rejected",
|
|
1439
|
+
reason: "Task content failed security screening",
|
|
1440
|
+
code: "PROMPT_INJECTION_DETECTED",
|
|
1441
|
+
category: guardResult.category,
|
|
1442
|
+
}));
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (!computeSessions) {
|
|
1447
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1448
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
const proposal = {
|
|
1452
|
+
sessionId: String(msg.sessionId ?? ""),
|
|
1453
|
+
clientAddress: String(msg.clientAddress ?? msg.client ?? ""),
|
|
1454
|
+
providerAddress: config.wallet.contract_address,
|
|
1455
|
+
ratePerHourWei: config.compute.rate_per_hour_wei,
|
|
1456
|
+
maxHours: Number(msg.maxHours ?? 1),
|
|
1457
|
+
gpuSpecHash: String(msg.gpuSpecHash ?? "0x0000000000000000000000000000000000000000000000000000000000000000"),
|
|
1458
|
+
workloadDescription: String(msg.workloadDescription ?? ""),
|
|
1459
|
+
depositAmount: String(msg.depositAmount ?? "0"),
|
|
1460
|
+
proposedAt: Math.floor(Date.now() / 1000),
|
|
1461
|
+
};
|
|
1462
|
+
if (!proposal.sessionId) {
|
|
1463
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1464
|
+
res.end(JSON.stringify({ error: "sessionId required" }));
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
// Check capacity
|
|
1468
|
+
const activeSessions = computeSessions.countByStatus("active");
|
|
1469
|
+
if (activeSessions >= config.compute.max_concurrent_sessions) {
|
|
1470
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1471
|
+
res.end(JSON.stringify({ status: "rejected", reason: "at_capacity" }));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const result = computeSessions.handleProposal(proposal);
|
|
1475
|
+
if (!result.ok) {
|
|
1476
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1477
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
log({ event: "compute_proposed", sessionId: proposal.sessionId, client: proposal.clientAddress });
|
|
1481
|
+
// Auto-accept if configured
|
|
1482
|
+
if (config.compute.auto_accept_compute) {
|
|
1483
|
+
computeSessions.acceptSession(proposal.sessionId);
|
|
1484
|
+
log({ event: "compute_auto_accepted", sessionId: proposal.sessionId });
|
|
1485
|
+
// Wire to on-chain: provider calls acceptSession
|
|
1486
|
+
if (config.compute.compute_agreement_address) {
|
|
1487
|
+
try {
|
|
1488
|
+
const caContract = new ethers_1.ethers.Contract(config.compute.compute_agreement_address, abis_1.COMPUTE_AGREEMENT_ABI, machineKeySigner);
|
|
1489
|
+
const tx = await caContract.acceptSession(proposal.sessionId);
|
|
1490
|
+
await tx.wait();
|
|
1491
|
+
log({ event: "compute_accept_onchain", sessionId: proposal.sessionId, txHash: tx.hash });
|
|
1492
|
+
}
|
|
1493
|
+
catch (onchainErr) {
|
|
1494
|
+
log({ event: "compute_accept_onchain_error", sessionId: proposal.sessionId, error: String(onchainErr) });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const status = config.compute.auto_accept_compute ? "accepted" : "proposed";
|
|
1499
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1500
|
+
res.end(JSON.stringify({ status, sessionId: proposal.sessionId }));
|
|
1501
|
+
}
|
|
1502
|
+
catch (err) {
|
|
1503
|
+
log({ event: "compute_propose_error", error: String(err) });
|
|
1504
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1505
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1506
|
+
}
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// POST /compute/accept — provider accepts a proposed session
|
|
1510
|
+
if (pathname === "/compute/accept" && req.method === "POST") {
|
|
1511
|
+
const body = await readBody(req, res);
|
|
1512
|
+
if (body === null)
|
|
1513
|
+
return;
|
|
1514
|
+
try {
|
|
1515
|
+
const msg = JSON.parse(body);
|
|
1516
|
+
const sessionId = String(msg.sessionId ?? "");
|
|
1517
|
+
if (!computeSessions) {
|
|
1518
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1519
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const result = computeSessions.acceptSession(sessionId);
|
|
1523
|
+
if (!result.ok) {
|
|
1524
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1525
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
log({ event: "compute_accepted", sessionId });
|
|
1529
|
+
// Wire to on-chain: provider calls acceptSession
|
|
1530
|
+
if (config.compute.compute_agreement_address) {
|
|
1531
|
+
try {
|
|
1532
|
+
const caContract = new ethers_1.ethers.Contract(config.compute.compute_agreement_address, abis_1.COMPUTE_AGREEMENT_ABI, machineKeySigner);
|
|
1533
|
+
const tx = await caContract.acceptSession(sessionId);
|
|
1534
|
+
await tx.wait();
|
|
1535
|
+
log({ event: "compute_accept_onchain", sessionId, txHash: tx.hash });
|
|
1536
|
+
}
|
|
1537
|
+
catch (onchainErr) {
|
|
1538
|
+
log({ event: "compute_accept_onchain_error", sessionId, error: String(onchainErr) });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1542
|
+
res.end(JSON.stringify({ status: "accepted", sessionId }));
|
|
1543
|
+
}
|
|
1544
|
+
catch (err) {
|
|
1545
|
+
log({ event: "compute_accept_error", error: String(err) });
|
|
1546
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1547
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1548
|
+
}
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
// POST /compute/started — provider marks session as started
|
|
1552
|
+
if (pathname === "/compute/started" && req.method === "POST") {
|
|
1553
|
+
const body = await readBody(req, res);
|
|
1554
|
+
if (body === null)
|
|
1555
|
+
return;
|
|
1556
|
+
try {
|
|
1557
|
+
const msg = JSON.parse(body);
|
|
1558
|
+
const sessionId = String(msg.sessionId ?? "");
|
|
1559
|
+
const accessEndpoint = msg.accessEndpoint ? String(msg.accessEndpoint) : undefined;
|
|
1560
|
+
if (!computeSessions) {
|
|
1561
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1562
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const result = computeSessions.startSession(sessionId, accessEndpoint);
|
|
1566
|
+
if (!result.ok) {
|
|
1567
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1568
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
log({ event: "compute_started", sessionId });
|
|
1572
|
+
// Wire to on-chain: provider calls startSession
|
|
1573
|
+
if (config.compute.compute_agreement_address) {
|
|
1574
|
+
try {
|
|
1575
|
+
const caContract = new ethers_1.ethers.Contract(config.compute.compute_agreement_address, abis_1.COMPUTE_AGREEMENT_ABI, machineKeySigner);
|
|
1576
|
+
const tx = await caContract.startSession(sessionId);
|
|
1577
|
+
await tx.wait();
|
|
1578
|
+
log({ event: "compute_start_onchain", sessionId, txHash: tx.hash });
|
|
1579
|
+
}
|
|
1580
|
+
catch (onchainErr) {
|
|
1581
|
+
log({ event: "compute_start_onchain_error", sessionId, error: String(onchainErr) });
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1585
|
+
res.end(JSON.stringify({ status: "active", sessionId }));
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
log({ event: "compute_started_error", error: String(err) });
|
|
1589
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1590
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1591
|
+
}
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
// POST /compute/metrics — get current metrics (polling endpoint)
|
|
1595
|
+
if (pathname === "/compute/metrics" && req.method === "POST") {
|
|
1596
|
+
const body = await readBody(req, res);
|
|
1597
|
+
if (body === null)
|
|
1598
|
+
return;
|
|
1599
|
+
try {
|
|
1600
|
+
const msg = JSON.parse(body);
|
|
1601
|
+
const sessionId = String(msg.sessionId ?? "");
|
|
1602
|
+
if (!computeMetering || !computeSessions) {
|
|
1603
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1604
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const session = computeSessions.getSession(sessionId);
|
|
1608
|
+
if (!session) {
|
|
1609
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1610
|
+
res.end(JSON.stringify({ error: "session_not_found" }));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const current = computeMetering.getCurrentMetrics(sessionId);
|
|
1614
|
+
const reports = computeMetering.getUsageReports(sessionId);
|
|
1615
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1616
|
+
res.end(JSON.stringify({ sessionId, current, reports, consumedMinutes: session.consumedMinutes }));
|
|
1617
|
+
}
|
|
1618
|
+
catch (err) {
|
|
1619
|
+
log({ event: "compute_metrics_error", error: String(err) });
|
|
1620
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1621
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// POST /compute/end — either party ends the session
|
|
1626
|
+
if (pathname === "/compute/end" && req.method === "POST") {
|
|
1627
|
+
const body = await readBody(req, res);
|
|
1628
|
+
if (body === null)
|
|
1629
|
+
return;
|
|
1630
|
+
try {
|
|
1631
|
+
const msg = JSON.parse(body);
|
|
1632
|
+
const sessionId = String(msg.sessionId ?? "");
|
|
1633
|
+
if (!computeSessions) {
|
|
1634
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1635
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const endResult = await computeSessions.endSession(sessionId);
|
|
1639
|
+
if (!endResult.ok) {
|
|
1640
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1641
|
+
res.end(JSON.stringify({ error: endResult.error }));
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const r = endResult.result;
|
|
1645
|
+
log({
|
|
1646
|
+
event: "compute_ended",
|
|
1647
|
+
sessionId,
|
|
1648
|
+
consumedMinutes: r.consumedMinutes,
|
|
1649
|
+
costWei: r.costWei.toString(),
|
|
1650
|
+
refundWei: r.refundWei.toString(),
|
|
1651
|
+
});
|
|
1652
|
+
// Wire to on-chain: call endSession
|
|
1653
|
+
if (config.compute.compute_agreement_address) {
|
|
1654
|
+
try {
|
|
1655
|
+
const caContract = new ethers_1.ethers.Contract(config.compute.compute_agreement_address, abis_1.COMPUTE_AGREEMENT_ABI, machineKeySigner);
|
|
1656
|
+
const tx = await caContract.endSession(sessionId);
|
|
1657
|
+
await tx.wait();
|
|
1658
|
+
log({ event: "compute_end_onchain", sessionId, txHash: tx.hash });
|
|
1659
|
+
}
|
|
1660
|
+
catch (onchainErr) {
|
|
1661
|
+
log({ event: "compute_end_onchain_error", sessionId, error: String(onchainErr) });
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1665
|
+
res.end(JSON.stringify({
|
|
1666
|
+
status: "completed",
|
|
1667
|
+
sessionId,
|
|
1668
|
+
consumedMinutes: r.consumedMinutes,
|
|
1669
|
+
costWei: r.costWei.toString(),
|
|
1670
|
+
refundWei: r.refundWei.toString(),
|
|
1671
|
+
reports: r.reports,
|
|
1672
|
+
}));
|
|
1673
|
+
}
|
|
1674
|
+
catch (err) {
|
|
1675
|
+
log({ event: "compute_end_error", error: String(err) });
|
|
1676
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1677
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1678
|
+
}
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
// POST /compute/dispute — client disputes a session
|
|
1682
|
+
if (pathname === "/compute/dispute" && req.method === "POST") {
|
|
1683
|
+
const body = await readBody(req, res);
|
|
1684
|
+
if (body === null)
|
|
1685
|
+
return;
|
|
1686
|
+
try {
|
|
1687
|
+
const msg = JSON.parse(body);
|
|
1688
|
+
const sessionId = String(msg.sessionId ?? "");
|
|
1689
|
+
if (!computeSessions) {
|
|
1690
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1691
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const result = computeSessions.disputeSession(sessionId);
|
|
1695
|
+
if (!result.ok) {
|
|
1696
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1697
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
log({ event: "compute_disputed", sessionId });
|
|
1701
|
+
if (config.notifications.notify_on_dispute) {
|
|
1702
|
+
await notifier.notifyDispute(sessionId, String(msg.from ?? "client"));
|
|
1703
|
+
}
|
|
1704
|
+
// Wire to on-chain: call disputeSession
|
|
1705
|
+
if (config.compute.compute_agreement_address) {
|
|
1706
|
+
try {
|
|
1707
|
+
const caContract = new ethers_1.ethers.Contract(config.compute.compute_agreement_address, abis_1.COMPUTE_AGREEMENT_ABI, machineKeySigner);
|
|
1708
|
+
const tx = await caContract.disputeSession(sessionId);
|
|
1709
|
+
await tx.wait();
|
|
1710
|
+
log({ event: "compute_dispute_onchain", sessionId, txHash: tx.hash });
|
|
1711
|
+
}
|
|
1712
|
+
catch (onchainErr) {
|
|
1713
|
+
log({ event: "compute_dispute_onchain_error", sessionId, error: String(onchainErr) });
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1717
|
+
res.end(JSON.stringify({ status: "disputed", sessionId }));
|
|
1718
|
+
}
|
|
1719
|
+
catch (err) {
|
|
1720
|
+
log({ event: "compute_dispute_error", error: String(err) });
|
|
1721
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1722
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
1723
|
+
}
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
// GET /compute/session/:id — session details
|
|
1727
|
+
if (pathname.startsWith("/compute/session/") && req.method === "GET") {
|
|
1728
|
+
const sessionId = pathname.slice("/compute/session/".length);
|
|
1729
|
+
if (!computeSessions) {
|
|
1730
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1731
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const session = computeSessions.getSession(sessionId);
|
|
1735
|
+
if (!session) {
|
|
1736
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1737
|
+
res.end(JSON.stringify({ error: "session_not_found" }));
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
const current = computeMetering ? computeMetering.getCurrentMetrics(sessionId) : null;
|
|
1741
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1742
|
+
res.end(JSON.stringify({ session, current }));
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
// GET /compute/sessions — list all sessions
|
|
1746
|
+
if (pathname === "/compute/sessions" && req.method === "GET") {
|
|
1747
|
+
if (!computeSessions) {
|
|
1748
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1749
|
+
res.end(JSON.stringify({ error: "compute_disabled" }));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const sessions = computeSessions.listSessions();
|
|
1753
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1754
|
+
res.end(JSON.stringify({ sessions, count: sessions.length }));
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
// ── File delivery routes ──────────────────────────────────────────────
|
|
1758
|
+
// POST /job/:id/upload — store a file (daemon auth required via global gate above)
|
|
1759
|
+
if (pathname.startsWith("/job/") && req.method === "POST") {
|
|
1760
|
+
const parts = pathname.split("/");
|
|
1761
|
+
// /job/<id>/upload → ["", "job", "<id>", "upload"]
|
|
1762
|
+
if (parts.length === 4 && parts[3] === "upload") {
|
|
1763
|
+
const agreementId = parts[2];
|
|
1764
|
+
if (!config.delivery.serve_files) {
|
|
1765
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1766
|
+
res.end(JSON.stringify({ error: "file_delivery_disabled" }));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
await fileDelivery.handleUpload(req, res, agreementId, log);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
// GET /job/:id/files, GET /job/:id/files/:name, GET /job/:id/manifest — party-gated
|
|
1774
|
+
if (pathname.startsWith("/job/") && req.method === "GET") {
|
|
1775
|
+
const parts = pathname.split("/");
|
|
1776
|
+
// /job/<id>/files/<name> → ["", "job", "<id>", "files", "<name>"]
|
|
1777
|
+
if (parts.length === 5 && parts[3] === "files") {
|
|
1778
|
+
const agreementId = parts[2];
|
|
1779
|
+
const filename = decodeURIComponent(parts[4]);
|
|
1780
|
+
if (!config.delivery.serve_files) {
|
|
1781
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1782
|
+
res.end(JSON.stringify({ error: "file_delivery_disabled" }));
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
fileDelivery.handleDownloadFile(req, res, agreementId, filename, apiToken, log);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
// /job/<id>/files → ["", "job", "<id>", "files"]
|
|
1789
|
+
if (parts.length === 4 && parts[3] === "files") {
|
|
1790
|
+
const agreementId = parts[2];
|
|
1791
|
+
if (!config.delivery.serve_files) {
|
|
1792
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1793
|
+
res.end(JSON.stringify({ error: "file_delivery_disabled" }));
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
fileDelivery.handleListFiles(req, res, agreementId, apiToken, log);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
// /job/<id>/manifest → ["", "job", "<id>", "manifest"]
|
|
1800
|
+
if (parts.length === 4 && parts[3] === "manifest") {
|
|
1801
|
+
const agreementId = parts[2];
|
|
1802
|
+
if (!config.delivery.serve_files) {
|
|
1803
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
1804
|
+
res.end(JSON.stringify({ error: "file_delivery_disabled" }));
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
fileDelivery.handleManifest(req, res, agreementId, apiToken, log);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// ── Newsletter content delivery — subscriber-gated ──────────────────────
|
|
1812
|
+
// GET /newsletter/:newsletterId/issues/:issueHash
|
|
1813
|
+
// Serves newsletter issue content peer-to-peer.
|
|
1814
|
+
// Gating: requester must prove active subscription via SubscriptionAgreement.
|
|
1815
|
+
// Auth headers: X-ARC402-Signer + X-ARC402-Signature (EIP-191 of "arc402:newsletter:<newsletterId>:<issueHash>")
|
|
1816
|
+
if (pathname.startsWith("/newsletter/") && req.method === "GET") {
|
|
1817
|
+
const parts = pathname.split("/");
|
|
1818
|
+
// /newsletter/<newsletterId>/issues/<issueHash>
|
|
1819
|
+
if (parts.length === 5 && parts[3] === "issues") {
|
|
1820
|
+
const newsletterId = parts[2];
|
|
1821
|
+
const issueHash = parts[4];
|
|
1822
|
+
const sig = (req.headers["x-arc402-signature"] ?? "");
|
|
1823
|
+
const signerAddr = (req.headers["x-arc402-signer"] ?? "");
|
|
1824
|
+
// Daemon bearer token always allowed (local/automated)
|
|
1825
|
+
const authHeader = (req.headers["authorization"] ?? "");
|
|
1826
|
+
if (authHeader.startsWith("Bearer ") && authHeader.slice(7) === apiToken) {
|
|
1827
|
+
// serve — fall through to file serving below
|
|
1828
|
+
}
|
|
1829
|
+
else if (!sig || !signerAddr) {
|
|
1830
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1831
|
+
res.end(JSON.stringify({ error: "missing_auth", detail: "Provide X-ARC402-Signature + X-ARC402-Signer headers" }));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
else {
|
|
1835
|
+
// Verify EIP-191 signature
|
|
1836
|
+
const message = `arc402:newsletter:${newsletterId}:${issueHash}`;
|
|
1837
|
+
let recovered;
|
|
1838
|
+
try {
|
|
1839
|
+
const { ethers } = await Promise.resolve().then(() => __importStar(require("ethers")));
|
|
1840
|
+
recovered = ethers.verifyMessage(message, sig);
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1844
|
+
res.end(JSON.stringify({ error: "invalid_signature" }));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (recovered.toLowerCase() !== signerAddr.toLowerCase()) {
|
|
1848
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1849
|
+
res.end(JSON.stringify({ error: "signer_mismatch" }));
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
// Check SubscriptionAgreement.isActiveSubscriber(publisherWallet, subscriberWallet)
|
|
1853
|
+
// publisherWallet = this daemon's wallet (config.wallet.contract_address)
|
|
1854
|
+
// subscriberWallet = recovered signer address
|
|
1855
|
+
const SUBSCRIPTION_AGREEMENT = "0x809c1D997Eab3531Eb2d01FCD5120Ac786D850D6";
|
|
1856
|
+
const SUBSCRIPTION_ABI = [
|
|
1857
|
+
"function isActiveSubscriber(address publisher, address subscriber) external view returns (bool)"
|
|
1858
|
+
];
|
|
1859
|
+
try {
|
|
1860
|
+
const { ethers } = await Promise.resolve().then(() => __importStar(require("ethers")));
|
|
1861
|
+
const provider = new ethers.JsonRpcProvider(config.network.rpc_url);
|
|
1862
|
+
const subAgreement = new ethers.Contract(SUBSCRIPTION_AGREEMENT, SUBSCRIPTION_ABI, provider);
|
|
1863
|
+
const isActive = await subAgreement.isActiveSubscriber(config.wallet.contract_address, recovered);
|
|
1864
|
+
if (!isActive) {
|
|
1865
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1866
|
+
res.end(JSON.stringify({ error: "not_subscribed", detail: "No active subscription found for this address" }));
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
catch (err) {
|
|
1871
|
+
log({ event: "newsletter_gate_error", error: String(err) });
|
|
1872
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1873
|
+
res.end(JSON.stringify({ error: "gate_check_failed" }));
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
// Serve the newsletter issue file from ~/.arc402/newsletters/<newsletterId>/<issueHash>
|
|
1878
|
+
const path = await Promise.resolve().then(() => __importStar(require("path")));
|
|
1879
|
+
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
|
|
1880
|
+
const os = await Promise.resolve().then(() => __importStar(require("os")));
|
|
1881
|
+
const issueDir = path.join(os.homedir(), ".arc402", "newsletters", newsletterId);
|
|
1882
|
+
const issueFile = path.join(issueDir, `${issueHash}.md`);
|
|
1883
|
+
if (!fs.existsSync(issueFile)) {
|
|
1884
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1885
|
+
res.end(JSON.stringify({ error: "issue_not_found" }));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const content = fs.readFileSync(issueFile, "utf-8");
|
|
1889
|
+
res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
1890
|
+
res.end(content);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
// 404
|
|
1895
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1896
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
1897
|
+
});
|
|
1898
|
+
httpServer.listen(httpPort, "0.0.0.0", () => {
|
|
1899
|
+
log({ event: "http_server_started", port: httpPort });
|
|
1900
|
+
});
|
|
1901
|
+
// ── Step 12: Startup complete ────────────────────────────────────────────
|
|
1902
|
+
const subsystems = [];
|
|
1903
|
+
if (config.relay.enabled)
|
|
1904
|
+
subsystems.push("relay");
|
|
1905
|
+
if (config.watchtower.enabled)
|
|
1906
|
+
subsystems.push("watchtower");
|
|
1907
|
+
subsystems.push(`bundler(${config.bundler.mode})`);
|
|
1908
|
+
log({
|
|
1909
|
+
event: "daemon_started",
|
|
1910
|
+
wallet: config.wallet.contract_address,
|
|
1911
|
+
subsystems,
|
|
1912
|
+
pid: process.pid,
|
|
1913
|
+
});
|
|
1914
|
+
await notifier.notifyStarted(config.wallet.contract_address, subsystems);
|
|
1915
|
+
// ── Graceful shutdown ────────────────────────────────────────────────────
|
|
1916
|
+
const shutdown = async (signal) => {
|
|
1917
|
+
log({ event: "daemon_stopping", signal });
|
|
1918
|
+
// Stop on-chain watchers
|
|
1919
|
+
await handshakeWatcher.stop();
|
|
1920
|
+
// Stop accepting new hire requests
|
|
1921
|
+
if (relayInterval)
|
|
1922
|
+
clearInterval(relayInterval);
|
|
1923
|
+
clearInterval(timeoutInterval);
|
|
1924
|
+
clearInterval(balanceInterval);
|
|
1925
|
+
if (rateLimitCleanupInterval)
|
|
1926
|
+
clearInterval(rateLimitCleanupInterval);
|
|
1927
|
+
// Close HTTP + IPC
|
|
1928
|
+
httpServer.close();
|
|
1929
|
+
ipcServer.close();
|
|
1930
|
+
if (fs.existsSync(config_1.DAEMON_SOCK))
|
|
1931
|
+
fs.unlinkSync(config_1.DAEMON_SOCK);
|
|
1932
|
+
await notifier.notifyStopped();
|
|
1933
|
+
log({ event: "daemon_stopped" });
|
|
1934
|
+
// Clean up PID file
|
|
1935
|
+
if (!foreground && fs.existsSync(config_1.DAEMON_PID)) {
|
|
1936
|
+
fs.unlinkSync(config_1.DAEMON_PID);
|
|
1937
|
+
}
|
|
1938
|
+
db.close();
|
|
1939
|
+
process.exit(0);
|
|
1940
|
+
};
|
|
1941
|
+
process.on("SIGTERM", () => { void shutdown("SIGTERM"); });
|
|
1942
|
+
process.on("SIGINT", () => { void shutdown("SIGINT"); });
|
|
1943
|
+
// Keep process alive
|
|
1944
|
+
process.stdin.resume();
|
|
1945
|
+
}
|
|
1946
|
+
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
1947
|
+
// Run when spawned directly (node dist/daemon/index.js [--foreground])
|
|
1948
|
+
if (require.main === module || process.env.ARC402_DAEMON_PROCESS === "1") {
|
|
1949
|
+
const foreground = process.argv.includes("--foreground") ||
|
|
1950
|
+
process.env.ARC402_DAEMON_FOREGROUND === "1";
|
|
1951
|
+
runDaemon(foreground).catch((err) => {
|
|
1952
|
+
process.stderr.write(`Daemon fatal error: ${err}\n`);
|
|
1953
|
+
process.exit(1);
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
//# sourceMappingURL=server.js.map
|