palmier 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -33
- package/dist/agents/agent-instructions.md +4 -11
- package/dist/agents/agent.d.ts +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +6 -6
- package/dist/agents/codex.d.ts +1 -1
- package/dist/agents/codex.js +5 -5
- package/dist/agents/copilot.d.ts +1 -1
- package/dist/agents/copilot.js +5 -5
- package/dist/agents/gemini.d.ts +1 -1
- package/dist/agents/gemini.js +7 -7
- package/dist/agents/openclaw.d.ts +1 -1
- package/dist/agents/openclaw.js +3 -3
- package/dist/agents/shared-prompt.d.ts +2 -4
- package/dist/agents/shared-prompt.js +9 -4
- package/dist/commands/init.js +31 -2
- package/dist/commands/pair.d.ts +1 -1
- package/dist/commands/pair.js +12 -15
- package/dist/commands/run.js +33 -54
- package/dist/commands/serve.d.ts +1 -1
- package/dist/commands/serve.js +9 -2
- package/dist/events.d.ts +2 -2
- package/dist/events.js +15 -16
- package/dist/index.js +0 -25
- package/dist/pending-requests.d.ts +27 -0
- package/dist/pending-requests.js +39 -0
- package/dist/rpc-handler.js +15 -8
- package/dist/transports/http-transport.d.ts +4 -2
- package/dist/transports/http-transport.js +226 -77
- package/dist/types.d.ts +7 -16
- package/package.json +1 -1
- package/src/agents/agent-instructions.md +4 -11
- package/src/agents/agent.ts +2 -2
- package/src/agents/claude.ts +5 -5
- package/src/agents/codex.ts +4 -4
- package/src/agents/copilot.ts +5 -5
- package/src/agents/gemini.ts +6 -6
- package/src/agents/openclaw.ts +3 -3
- package/src/agents/shared-prompt.ts +12 -6
- package/src/commands/init.ts +34 -3
- package/src/commands/pair.ts +11 -14
- package/src/commands/run.ts +31 -68
- package/src/commands/serve.ts +11 -2
- package/src/events.ts +14 -15
- package/src/index.ts +0 -26
- package/src/pending-requests.ts +55 -0
- package/src/rpc-handler.ts +15 -9
- package/src/transports/http-transport.ts +235 -135
- package/src/types.ts +10 -16
- package/test/agent-output-parsing.test.ts +1 -14
- package/dist/commands/lan.d.ts +0 -8
- package/dist/commands/lan.js +0 -44
- package/dist/commands/notify.d.ts +0 -9
- package/dist/commands/notify.js +0 -43
- package/dist/commands/request-input.d.ts +0 -10
- package/dist/commands/request-input.js +0 -49
- package/dist/lan-lock.d.ts +0 -7
- package/dist/lan-lock.js +0 -18
- package/dist/user-input.d.ts +0 -15
- package/dist/user-input.js +0 -50
- package/src/commands/lan.ts +0 -48
- package/src/commands/notify.ts +0 -44
- package/src/commands/request-input.ts +0 -51
- package/src/lan-lock.ts +0 -16
- package/src/user-input.ts +0 -67
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
2
|
import * as os from "os";
|
|
3
|
+
import { StringCodec, type NatsConnection } from "nats";
|
|
3
4
|
import { validateSession, addSession } from "../session-store.js";
|
|
4
|
-
import
|
|
5
|
+
import { registerPending } from "../pending-requests.js";
|
|
6
|
+
import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
|
|
7
|
+
import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
|
|
5
8
|
|
|
6
9
|
const PWA_ORIGIN = "https://app.palmier.me";
|
|
7
10
|
|
|
8
|
-
// ──
|
|
11
|
+
// ── On-the-fly PWA asset cache ──────────────────────────────────────────
|
|
9
12
|
|
|
10
13
|
interface CachedAsset {
|
|
11
14
|
data: Buffer;
|
|
12
15
|
contentType: string;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
const assetCache = new Map<string, CachedAsset>();
|
|
19
|
+
/** Paths currently being fetched (dedup concurrent requests). */
|
|
20
|
+
const assetInflight = new Map<string, Promise<CachedAsset | null>>();
|
|
21
|
+
|
|
15
22
|
const CONTENT_TYPES: Record<string, string> = {
|
|
16
23
|
".html": "text/html; charset=utf-8",
|
|
17
24
|
".js": "application/javascript",
|
|
18
25
|
".css": "text/css",
|
|
19
26
|
".json": "application/json",
|
|
20
|
-
|
|
21
27
|
".png": "image/png",
|
|
22
28
|
".ico": "image/x-icon",
|
|
23
29
|
".woff2": "font/woff2",
|
|
@@ -26,6 +32,7 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
26
32
|
};
|
|
27
33
|
|
|
28
34
|
function guessContentType(urlPath: string): string {
|
|
35
|
+
if (urlPath === "/") return "text/html; charset=utf-8";
|
|
29
36
|
const ext = urlPath.match(/\.[^.]+$/)?.[0] ?? "";
|
|
30
37
|
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
31
38
|
}
|
|
@@ -37,59 +44,38 @@ async function fetchBuffer(url: string): Promise<Buffer> {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
47
|
+
* Fetch a PWA asset on-the-fly, caching in memory.
|
|
48
|
+
* Returns null if the asset cannot be fetched.
|
|
42
49
|
*/
|
|
43
|
-
async function
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
// 1. Fetch index.html
|
|
47
|
-
const html = await fetchBuffer(`${PWA_ORIGIN}/`);
|
|
48
|
-
assets.set("/", { data: html, contentType: "text/html; charset=utf-8" });
|
|
49
|
-
|
|
50
|
-
const htmlStr = html.toString("utf-8");
|
|
51
|
-
|
|
52
|
-
// 2. Extract references from HTML (src="..." and href="...")
|
|
53
|
-
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
54
|
-
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
55
|
-
const refRegex = /(?:src|href)="([^"]+)"/g;
|
|
56
|
-
const htmlRefs = new Set<string>();
|
|
57
|
-
let match;
|
|
58
|
-
while ((match = refRegex.exec(htmlStr)) !== null) {
|
|
59
|
-
const ref = match[1];
|
|
60
|
-
if (ref.startsWith("/") && !ref.startsWith("//") && !SKIP.has(ref)) {
|
|
61
|
-
htmlRefs.add(ref);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
50
|
+
async function getAsset(urlPath: string): Promise<CachedAsset | null> {
|
|
51
|
+
const cached = assetCache.get(urlPath);
|
|
52
|
+
if (cached) return cached;
|
|
64
53
|
|
|
65
|
-
//
|
|
66
|
-
|
|
54
|
+
// Dedup concurrent requests for the same path
|
|
55
|
+
const inflight = assetInflight.get(urlPath);
|
|
56
|
+
if (inflight) return inflight;
|
|
57
|
+
|
|
58
|
+
const promise = (async () => {
|
|
67
59
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const cssStr = data.toString("utf-8");
|
|
74
|
-
const urlRegex = /url\(["']?([^"')]+)["']?\)/g;
|
|
75
|
-
let cssMatch;
|
|
76
|
-
while ((cssMatch = urlRegex.exec(cssStr)) !== null) {
|
|
77
|
-
let fontRef = cssMatch[1];
|
|
78
|
-
if (fontRef.startsWith("data:")) continue;
|
|
79
|
-
// Resolve relative URLs against the CSS file's directory
|
|
80
|
-
if (!fontRef.startsWith("/")) {
|
|
81
|
-
const cssDir = ref.substring(0, ref.lastIndexOf("/") + 1);
|
|
82
|
-
fontRef = cssDir + fontRef;
|
|
83
|
-
}
|
|
84
|
-
htmlRefs.add(fontRef);
|
|
85
|
-
}
|
|
60
|
+
let data = await fetchBuffer(`${PWA_ORIGIN}${urlPath}`);
|
|
61
|
+
// Inject LAN mode marker into index HTML so the PWA can detect it's served by palmier
|
|
62
|
+
if (urlPath === "/") {
|
|
63
|
+
const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
|
|
64
|
+
data = Buffer.from(html, "utf-8");
|
|
86
65
|
}
|
|
66
|
+
const asset: CachedAsset = { data, contentType: guessContentType(urlPath) };
|
|
67
|
+
assetCache.set(urlPath, asset);
|
|
68
|
+
return asset;
|
|
87
69
|
} catch (err) {
|
|
88
|
-
console.warn(`[pwa] Failed to fetch ${
|
|
70
|
+
console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
|
|
71
|
+
return null;
|
|
72
|
+
} finally {
|
|
73
|
+
assetInflight.delete(urlPath);
|
|
89
74
|
}
|
|
90
|
-
}
|
|
75
|
+
})();
|
|
91
76
|
|
|
92
|
-
|
|
77
|
+
assetInflight.set(urlPath, promise);
|
|
78
|
+
return promise;
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
type SseClient = http.ServerResponse;
|
|
@@ -114,30 +100,26 @@ export function detectLanIp(): string {
|
|
|
114
100
|
}
|
|
115
101
|
|
|
116
102
|
/**
|
|
117
|
-
* Start the HTTP transport:
|
|
103
|
+
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
104
|
+
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
118
105
|
*/
|
|
119
106
|
export async function startHttpTransport(
|
|
120
107
|
config: HostConfig,
|
|
121
108
|
handleRpc: (req: RpcMessage) => Promise<unknown>,
|
|
122
109
|
port: number,
|
|
110
|
+
nc: NatsConnection | undefined,
|
|
123
111
|
pairingCode?: string,
|
|
124
112
|
onReady?: () => void,
|
|
125
113
|
): Promise<void> {
|
|
126
|
-
// Download PWA assets into memory before starting the server
|
|
127
|
-
console.log("[http] Downloading PWA assets...");
|
|
128
|
-
const pwaAssets = await downloadPwaAssets();
|
|
129
|
-
console.log(`[http] Cached ${pwaAssets.size} PWA assets in memory.`);
|
|
130
|
-
|
|
131
114
|
const sseClients = new Set<SseClient>();
|
|
115
|
+
const lanEnabled = config.lanEnabled ?? false;
|
|
116
|
+
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
132
117
|
|
|
133
|
-
// If a pairing code is provided
|
|
118
|
+
// If a pairing code is provided, pre-register it
|
|
134
119
|
if (pairingCode) {
|
|
135
|
-
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
120
|
+
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
136
121
|
const timer = setTimeout(() => { pendingPairs.delete(pairingCode); }, EXPIRY_MS);
|
|
137
|
-
pendingPairs.set(pairingCode, {
|
|
138
|
-
resolve: () => {},
|
|
139
|
-
timer,
|
|
140
|
-
});
|
|
122
|
+
pendingPairs.set(pairingCode, { resolve: () => {}, timer });
|
|
141
123
|
}
|
|
142
124
|
|
|
143
125
|
function broadcastSseEvent(data: unknown) {
|
|
@@ -147,12 +129,10 @@ export async function startHttpTransport(
|
|
|
147
129
|
}
|
|
148
130
|
}
|
|
149
131
|
|
|
150
|
-
|
|
151
132
|
function checkAuth(req: http.IncomingMessage): boolean {
|
|
152
133
|
const auth = req.headers.authorization;
|
|
153
134
|
if (!auth || !auth.startsWith("Bearer ")) return false;
|
|
154
|
-
|
|
155
|
-
return validateSession(token);
|
|
135
|
+
return validateSession(auth.slice(7));
|
|
156
136
|
}
|
|
157
137
|
|
|
158
138
|
function extractSessionToken(req: http.IncomingMessage): string | undefined {
|
|
@@ -180,50 +160,42 @@ export async function startHttpTransport(
|
|
|
180
160
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
181
161
|
}
|
|
182
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Publish an event via NATS and SSE.
|
|
165
|
+
*/
|
|
166
|
+
async function publishEvent(taskId: string, payload: Record<string, unknown>): Promise<void> {
|
|
167
|
+
const sc = StringCodec();
|
|
168
|
+
const subject = `host-event.${config.hostId}.${taskId}`;
|
|
169
|
+
if (nc) {
|
|
170
|
+
nc.publish(subject, sc.encode(JSON.stringify(payload)));
|
|
171
|
+
}
|
|
172
|
+
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
173
|
+
}
|
|
174
|
+
|
|
183
175
|
const server = http.createServer(async (req, res) => {
|
|
184
176
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
185
177
|
const pathname = url.pathname;
|
|
186
178
|
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
179
|
+
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
180
|
+
|
|
181
|
+
if (req.method === "POST" && pathname === "/event") {
|
|
182
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
193
183
|
try {
|
|
194
184
|
const body = await readBody(req);
|
|
195
185
|
const event = JSON.parse(body);
|
|
196
186
|
broadcastSseEvent(event);
|
|
197
187
|
sendJson(res, 200, { ok: true });
|
|
198
|
-
} catch {
|
|
199
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
200
|
-
}
|
|
188
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
201
189
|
return;
|
|
202
190
|
}
|
|
203
191
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
207
|
-
if (!isLocalhost(req)) {
|
|
208
|
-
sendJson(res, 403, { error: "localhost only" });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
192
|
+
if (req.method === "POST" && pathname === "/pair-register") {
|
|
193
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
211
194
|
try {
|
|
212
195
|
const body = await readBody(req);
|
|
213
|
-
const { code, expiryMs } = JSON.parse(body) as {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
if (!code) {
|
|
219
|
-
sendJson(res, 400, { error: "Missing code" });
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (pendingPairs.has(code)) {
|
|
224
|
-
sendJson(res, 409, { error: "Code already registered" });
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
196
|
+
const { code, expiryMs } = JSON.parse(body) as { code: string; expiryMs: number };
|
|
197
|
+
if (!code) { sendJson(res, 400, { error: "Missing code" }); return; }
|
|
198
|
+
if (pendingPairs.has(code)) { sendJson(res, 409, { error: "Code already registered" }); return; }
|
|
227
199
|
|
|
228
200
|
const result = await new Promise<{ paired: boolean }>((resolve) => {
|
|
229
201
|
const timer = setTimeout(() => {
|
|
@@ -232,8 +204,6 @@ export async function startHttpTransport(
|
|
|
232
204
|
}, expiryMs ?? 5 * 60 * 1000);
|
|
233
205
|
|
|
234
206
|
pendingPairs.set(code, { resolve, timer });
|
|
235
|
-
|
|
236
|
-
// Clean up if the CLI disconnects early
|
|
237
207
|
req.on("close", () => {
|
|
238
208
|
if (pendingPairs.has(code)) {
|
|
239
209
|
clearTimeout(timer);
|
|
@@ -243,33 +213,163 @@ export async function startHttpTransport(
|
|
|
243
213
|
});
|
|
244
214
|
|
|
245
215
|
sendJson(res, 200, result);
|
|
246
|
-
} catch {
|
|
247
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
248
|
-
}
|
|
216
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
249
217
|
return;
|
|
250
218
|
}
|
|
251
219
|
|
|
252
|
-
//
|
|
253
|
-
|
|
220
|
+
// ── GET /notify — send push notification via NATS ──────────────────
|
|
221
|
+
|
|
222
|
+
if (req.method === "GET" && pathname === "/notify") {
|
|
223
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
224
|
+
if (!nc) { sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" }); return; }
|
|
225
|
+
|
|
254
226
|
try {
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
227
|
+
const title = url.searchParams.get("title");
|
|
228
|
+
const notifBody = url.searchParams.get("body");
|
|
229
|
+
if (!title || !notifBody) { sendJson(res, 400, { error: "title and body query params are required" }); return; }
|
|
230
|
+
|
|
231
|
+
const sc = StringCodec();
|
|
232
|
+
const payload = { hostId: config.hostId, title, body: notifBody };
|
|
233
|
+
const subject = `host.${config.hostId}.push.send`;
|
|
234
|
+
const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
235
|
+
const result = JSON.parse(sc.decode(reply.data)) as { ok?: boolean; error?: string };
|
|
236
|
+
|
|
237
|
+
if (result.ok) {
|
|
238
|
+
sendJson(res, 200, { ok: true });
|
|
239
|
+
} else {
|
|
240
|
+
sendJson(res, 502, { error: result.error ?? "Push notification failed" });
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
sendJson(res, 500, { error: `Failed to send notification: ${err}` });
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
260
247
|
|
|
261
|
-
|
|
262
|
-
|
|
248
|
+
// ── GET /request-input — held connection until user responds ────────
|
|
249
|
+
|
|
250
|
+
if (req.method === "GET" && pathname === "/request-input") {
|
|
251
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
252
|
+
try {
|
|
253
|
+
const taskId = url.searchParams.get("taskId");
|
|
254
|
+
const runId = url.searchParams.get("runId");
|
|
255
|
+
const descriptions = url.searchParams.getAll("descriptions");
|
|
256
|
+
if (!taskId || !descriptions.length) {
|
|
257
|
+
sendJson(res, 400, { error: "taskId and descriptions query params are required" });
|
|
263
258
|
return;
|
|
264
259
|
}
|
|
265
260
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
261
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
262
|
+
const task = parseTaskFile(taskDir);
|
|
263
|
+
|
|
264
|
+
await publishEvent(taskId, {
|
|
265
|
+
event_type: "input-request",
|
|
266
|
+
host_id: config.hostId,
|
|
267
|
+
input_descriptions: descriptions,
|
|
268
|
+
name: task.frontmatter.name,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const response = await registerPending(taskId, "input", descriptions);
|
|
272
|
+
|
|
273
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
274
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
275
|
+
if (runId) {
|
|
276
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
277
|
+
}
|
|
278
|
+
sendJson(res, 200, { aborted: true });
|
|
279
|
+
} else {
|
|
280
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
281
|
+
if (runId) {
|
|
282
|
+
const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
283
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
284
|
+
}
|
|
285
|
+
sendJson(res, 200, { values: response });
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
sendJson(res, 500, { error: String(err) });
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── GET /request-confirmation — held connection ─────────────────────
|
|
294
|
+
|
|
295
|
+
if (req.method === "GET" && pathname === "/request-confirmation") {
|
|
296
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
297
|
+
try {
|
|
298
|
+
const taskId = url.searchParams.get("taskId");
|
|
299
|
+
if (!taskId) { sendJson(res, 400, { error: "taskId query param is required" }); return; }
|
|
300
|
+
|
|
301
|
+
await publishEvent(taskId, {
|
|
302
|
+
event_type: "confirm-request",
|
|
303
|
+
host_id: config.hostId,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const response = await registerPending(taskId, "confirmation");
|
|
307
|
+
const confirmed = response[0] === "confirmed";
|
|
308
|
+
|
|
309
|
+
await publishEvent(taskId, {
|
|
310
|
+
event_type: "confirm-resolved",
|
|
311
|
+
host_id: config.hostId,
|
|
312
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
sendJson(res, 200, { confirmed });
|
|
316
|
+
} catch (err) {
|
|
317
|
+
sendJson(res, 500, { error: String(err) });
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── GET /request-permission — held connection ───────────────────────
|
|
323
|
+
|
|
324
|
+
if (req.method === "GET" && pathname === "/request-permission") {
|
|
325
|
+
if (!isLocalhost(req)) { sendJson(res, 403, { error: "localhost only" }); return; }
|
|
326
|
+
try {
|
|
327
|
+
const taskId = url.searchParams.get("taskId");
|
|
328
|
+
const taskName = url.searchParams.get("taskName");
|
|
329
|
+
const permissionsRaw = url.searchParams.get("permissions");
|
|
330
|
+
let permissions: RequiredPermission[] = [];
|
|
331
|
+
if (permissionsRaw) {
|
|
332
|
+
try { permissions = JSON.parse(permissionsRaw) as RequiredPermission[]; } catch { /* ignore */ }
|
|
333
|
+
}
|
|
334
|
+
if (!taskId || !permissions.length) {
|
|
335
|
+
sendJson(res, 400, { error: "taskId and permissions query params are required" });
|
|
269
336
|
return;
|
|
270
337
|
}
|
|
271
338
|
|
|
272
|
-
|
|
339
|
+
await publishEvent(taskId, {
|
|
340
|
+
event_type: "permission-request",
|
|
341
|
+
host_id: config.hostId,
|
|
342
|
+
required_permissions: permissions,
|
|
343
|
+
name: taskName,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const response = await registerPending(taskId, "permission", permissions);
|
|
347
|
+
const status = response[0] as "granted" | "granted_all" | "aborted";
|
|
348
|
+
|
|
349
|
+
await publishEvent(taskId, {
|
|
350
|
+
event_type: "permission-resolved",
|
|
351
|
+
host_id: config.hostId,
|
|
352
|
+
status,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
sendJson(res, 200, { response: status });
|
|
356
|
+
} catch (err) {
|
|
357
|
+
sendJson(res, 500, { error: String(err) });
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Public pair endpoint — no auth, PWA posts OTP code here ────────
|
|
363
|
+
|
|
364
|
+
if (req.method === "POST" && pathname === "/pair") {
|
|
365
|
+
try {
|
|
366
|
+
const body = await readBody(req);
|
|
367
|
+
const { code, label } = JSON.parse(body) as { code: string; label?: string };
|
|
368
|
+
if (!code) { sendJson(res, 400, { error: "Missing code" }); return; }
|
|
369
|
+
|
|
370
|
+
const pending = pendingPairs.get(code);
|
|
371
|
+
if (!pending) { sendJson(res, 401, { error: "Invalid code" }); return; }
|
|
372
|
+
|
|
273
373
|
const session = addSession(label);
|
|
274
374
|
const ip = detectLanIp();
|
|
275
375
|
const response: Record<string, unknown> = {
|
|
@@ -278,34 +378,42 @@ export async function startHttpTransport(
|
|
|
278
378
|
directUrl: `http://${ip}:${port}`,
|
|
279
379
|
};
|
|
280
380
|
|
|
281
|
-
// Resolve the long-poll and clean up
|
|
282
381
|
clearTimeout(pending.timer);
|
|
283
382
|
pendingPairs.delete(code);
|
|
284
383
|
pending.resolve({ paired: true });
|
|
285
384
|
|
|
286
385
|
sendJson(res, 200, response);
|
|
287
|
-
} catch {
|
|
288
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
289
|
-
}
|
|
386
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); }
|
|
290
387
|
return;
|
|
291
388
|
}
|
|
292
389
|
|
|
293
|
-
//
|
|
390
|
+
// ── PWA assets (on-the-fly, cached) ────────────────────────────────
|
|
391
|
+
|
|
392
|
+
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
393
|
+
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
394
|
+
|
|
294
395
|
const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
|
|
295
396
|
if (!isApiRoute) {
|
|
296
|
-
|
|
297
|
-
|
|
397
|
+
if (SKIP.has(pathname)) { sendJson(res, 404, { error: "Not found" }); return; }
|
|
398
|
+
|
|
399
|
+
// Try exact path, then fall back to index.html (SPA routing)
|
|
400
|
+
let asset = await getAsset(pathname);
|
|
401
|
+
if (!asset && pathname !== "/") {
|
|
402
|
+
asset = await getAsset("/");
|
|
403
|
+
}
|
|
404
|
+
|
|
298
405
|
if (asset) {
|
|
299
406
|
res.writeHead(200, { "Content-Type": asset.contentType });
|
|
300
407
|
res.end(asset.data);
|
|
301
408
|
} else {
|
|
302
|
-
sendJson(res,
|
|
409
|
+
sendJson(res, 502, { error: "Failed to fetch PWA assets" });
|
|
303
410
|
}
|
|
304
411
|
return;
|
|
305
412
|
}
|
|
306
413
|
|
|
307
|
-
// API endpoints require auth
|
|
308
|
-
|
|
414
|
+
// ── API endpoints require auth (localhost is trusted) ───────────────
|
|
415
|
+
|
|
416
|
+
if (!isLocalhost(req) && !checkAuth(req)) {
|
|
309
417
|
sendJson(res, 401, { error: "Unauthorized" });
|
|
310
418
|
return;
|
|
311
419
|
}
|
|
@@ -319,7 +427,6 @@ export async function startHttpTransport(
|
|
|
319
427
|
});
|
|
320
428
|
res.write(":ok\n\n");
|
|
321
429
|
|
|
322
|
-
// Send heartbeat every 5 seconds
|
|
323
430
|
const heartbeat = setInterval(() => {
|
|
324
431
|
res.write("data: {\"heartbeat\":true}\n\n");
|
|
325
432
|
}, 5000);
|
|
@@ -335,10 +442,7 @@ export async function startHttpTransport(
|
|
|
335
442
|
// RPC endpoint: POST /rpc/<method>
|
|
336
443
|
if (req.method === "POST" && pathname.startsWith("/rpc/")) {
|
|
337
444
|
const method = pathname.slice("/rpc/".length);
|
|
338
|
-
if (!method) {
|
|
339
|
-
sendJson(res, 400, { error: "Missing RPC method" });
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
445
|
+
if (!method) { sendJson(res, 400, { error: "Missing RPC method" }); return; }
|
|
342
446
|
|
|
343
447
|
let params: Record<string, unknown> = {};
|
|
344
448
|
try {
|
|
@@ -346,16 +450,13 @@ export async function startHttpTransport(
|
|
|
346
450
|
if (body.trim().length > 0) {
|
|
347
451
|
params = JSON.parse(body);
|
|
348
452
|
}
|
|
349
|
-
} catch {
|
|
350
|
-
sendJson(res, 400, { error: "Invalid JSON" });
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
453
|
+
} catch { sendJson(res, 400, { error: "Invalid JSON" }); return; }
|
|
353
454
|
|
|
354
455
|
const sessionToken = extractSessionToken(req);
|
|
355
456
|
console.log(`[http] RPC: ${method}`);
|
|
356
457
|
|
|
357
458
|
try {
|
|
358
|
-
const response = await handleRpc({ method, params, sessionToken });
|
|
459
|
+
const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
|
|
359
460
|
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
360
461
|
sendJson(res, 200, response);
|
|
361
462
|
} catch (err) {
|
|
@@ -369,11 +470,10 @@ export async function startHttpTransport(
|
|
|
369
470
|
});
|
|
370
471
|
|
|
371
472
|
return new Promise<void>((resolve, reject) => {
|
|
372
|
-
server.listen(port, () => {
|
|
373
|
-
console.log(`[http] Listening on
|
|
473
|
+
server.listen(port, bindAddress, () => {
|
|
474
|
+
console.log(`[http] Listening on ${bindAddress}:${port}`);
|
|
374
475
|
onReady?.();
|
|
375
476
|
|
|
376
|
-
// Graceful shutdown
|
|
377
477
|
const shutdown = () => {
|
|
378
478
|
console.log("[http] Shutting down...");
|
|
379
479
|
for (const client of sseClients) {
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,11 @@ export interface HostConfig {
|
|
|
8
8
|
|
|
9
9
|
// Detected agent CLIs
|
|
10
10
|
agents?: Array<{ key: string; label: string }>;
|
|
11
|
+
|
|
12
|
+
// HTTP server port (default 7400)
|
|
13
|
+
httpPort?: number;
|
|
14
|
+
// Whether to accept non-localhost HTTP connections
|
|
15
|
+
lanEnabled?: boolean;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface TaskFrontmatter {
|
|
@@ -33,8 +38,6 @@ export interface ParsedTask {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
|
-
* State machine: started → (pending_confirmation | pending_permission | pending_input) → finished | aborted | failed
|
|
37
|
-
*
|
|
38
41
|
* - `started`: task is actively running
|
|
39
42
|
* - `finished`: agent completed successfully
|
|
40
43
|
* - `aborted`: user declined confirmation, permission, or input
|
|
@@ -43,26 +46,15 @@ export interface ParsedTask {
|
|
|
43
46
|
export type TaskRunningState = "started" | "finished" | "aborted" | "failed";
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
* Persisted to `status.json` in the task directory.
|
|
47
|
-
* and
|
|
48
|
-
*
|
|
49
|
-
* Interactive request flow: the run process sets a `pending_*` field and waits
|
|
50
|
-
* for `user_input` to be populated by an RPC call (task.user_input). Only one
|
|
51
|
-
* `pending_*` field is set at a time.
|
|
49
|
+
* Persisted to `status.json` in the task directory. Used for crash detection
|
|
50
|
+
* (checkStaleTasks) and abort signalling. Interactive request flows (confirmation,
|
|
51
|
+
* permission, input) are handled via held HTTP connections on the serve daemon.
|
|
52
52
|
*/
|
|
53
53
|
export interface TaskStatus {
|
|
54
54
|
running_state: TaskRunningState;
|
|
55
55
|
time_stamp: number;
|
|
56
56
|
/** PID of the palmier run process (used on Windows to kill the process tree). */
|
|
57
57
|
pid?: number;
|
|
58
|
-
/** Set when the task has `requires_confirmation` and is awaiting user approval. */
|
|
59
|
-
pending_confirmation?: boolean;
|
|
60
|
-
/** Set when the agent requests permissions not yet granted. Contains the permissions needed. */
|
|
61
|
-
pending_permission?: RequiredPermission[];
|
|
62
|
-
/** Set when the agent requests user input. Contains descriptions of each requested value. */
|
|
63
|
-
pending_input?: string[];
|
|
64
|
-
/** Written by the RPC handler to deliver the user's response to the waiting run process. */
|
|
65
|
-
user_input?: string[];
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
export interface HistoryEntry {
|
|
@@ -87,4 +79,6 @@ export interface RpcMessage {
|
|
|
87
79
|
method: string;
|
|
88
80
|
params: Record<string, unknown>;
|
|
89
81
|
sessionToken?: string;
|
|
82
|
+
/** Trusted localhost request — skip session validation. */
|
|
83
|
+
localhost?: boolean;
|
|
90
84
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { parseTaskOutcome, parseReportFiles, parsePermissions
|
|
3
|
+
import { parseTaskOutcome, parseReportFiles, parsePermissions } from "../src/commands/run.js";
|
|
4
4
|
|
|
5
5
|
describe("parseTaskOutcome", () => {
|
|
6
6
|
it("returns 'finished' for success marker", () => {
|
|
@@ -59,16 +59,3 @@ describe("parsePermissions", () => {
|
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
describe("parseInputRequests", () => {
|
|
63
|
-
it("extracts input descriptions", () => {
|
|
64
|
-
const output = "[PALMIER_INPUT] What is the API key?\n[PALMIER_INPUT] Database connection string?";
|
|
65
|
-
assert.deepEqual(parseInputRequests(output), [
|
|
66
|
-
"What is the API key?",
|
|
67
|
-
"Database connection string?",
|
|
68
|
-
]);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("returns empty array when no inputs", () => {
|
|
72
|
-
assert.deepEqual(parseInputRequests("no inputs"), []);
|
|
73
|
-
});
|
|
74
|
-
});
|
package/dist/commands/lan.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Start an on-demand LAN server for direct HTTP connections.
|
|
3
|
-
* Generates a pairing code and displays it — no separate `palmier pair` needed.
|
|
4
|
-
*/
|
|
5
|
-
export declare function lanCommand(opts: {
|
|
6
|
-
port: number;
|
|
7
|
-
}): Promise<void>;
|
|
8
|
-
//# sourceMappingURL=lan.d.ts.map
|