palmier 0.4.5 → 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 +29 -31
- package/dist/agents/agent-instructions.md +4 -11
- package/dist/agents/claude.js +3 -3
- package/dist/agents/codex.js +2 -2
- package/dist/agents/copilot.js +3 -3
- package/dist/agents/gemini.js +3 -3
- package/dist/agents/openclaw.js +2 -2
- 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 +19 -43
- 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/claude.ts +3 -3
- package/src/agents/codex.ts +2 -2
- package/src/agents/copilot.ts +3 -3
- package/src/agents/gemini.ts +3 -3
- package/src/agents/openclaw.ts +2 -2
- 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 +17 -57
- 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/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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RequiredPermission } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface PendingRequest {
|
|
4
|
+
type: "confirmation" | "permission" | "input";
|
|
5
|
+
resolve: (value: string[]) => void;
|
|
6
|
+
/** Permission list (for 'permission') or input descriptions (for 'input'). */
|
|
7
|
+
params?: RequiredPermission[] | string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const pending = new Map<string, PendingRequest>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register a pending request for a task. Returns a Promise that resolves
|
|
14
|
+
* when `resolvePending` is called with the user's response.
|
|
15
|
+
* Only one pending request per task at a time.
|
|
16
|
+
*/
|
|
17
|
+
export function registerPending(
|
|
18
|
+
taskId: string,
|
|
19
|
+
type: PendingRequest["type"],
|
|
20
|
+
params?: PendingRequest["params"],
|
|
21
|
+
): Promise<string[]> {
|
|
22
|
+
if (pending.has(taskId)) {
|
|
23
|
+
return Promise.reject(new Error(`Task ${taskId} already has a pending request`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new Promise<string[]>((resolve) => {
|
|
27
|
+
pending.set(taskId, { type, resolve, params });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a pending request with the user's response.
|
|
33
|
+
* Returns true if a pending request was found and resolved.
|
|
34
|
+
*/
|
|
35
|
+
export function resolvePending(taskId: string, value: string[]): boolean {
|
|
36
|
+
const entry = pending.get(taskId);
|
|
37
|
+
if (!entry) return false;
|
|
38
|
+
pending.delete(taskId);
|
|
39
|
+
entry.resolve(value);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the current pending request for a task (if any).
|
|
45
|
+
*/
|
|
46
|
+
export function getPending(taskId: string): PendingRequest | undefined {
|
|
47
|
+
return pending.get(taskId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove a pending request without resolving it.
|
|
52
|
+
*/
|
|
53
|
+
export function removePending(taskId: string): void {
|
|
54
|
+
pending.delete(taskId);
|
|
55
|
+
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn, type ChildProcess } from "child_process";
|
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
7
|
import { type NatsConnection } from "nats";
|
|
8
8
|
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
|
|
9
|
+
import { resolvePending, getPending } from "./pending-requests.js";
|
|
9
10
|
import { getPlatform } from "./platform/index.js";
|
|
10
11
|
import { spawnCommand } from "./spawn-command.js";
|
|
11
12
|
import crossSpawn from "cross-spawn";
|
|
@@ -157,8 +158,8 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
async function handleRpc(request: RpcMessage): Promise<unknown> {
|
|
160
|
-
// Session token validation:
|
|
161
|
-
if (!request.sessionToken || !validateSession(request.sessionToken)) {
|
|
161
|
+
// Session token validation: skip for trusted localhost requests
|
|
162
|
+
if (!request.localhost && (!request.sessionToken || !validateSession(request.sessionToken))) {
|
|
162
163
|
return { error: "Unauthorized" };
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -521,7 +522,14 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
521
522
|
if (!status) {
|
|
522
523
|
return { task_id: params.id, error: "No status found" };
|
|
523
524
|
}
|
|
524
|
-
|
|
525
|
+
const pending = getPending(params.id);
|
|
526
|
+
return {
|
|
527
|
+
task_id: params.id,
|
|
528
|
+
...status,
|
|
529
|
+
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
530
|
+
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
531
|
+
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
532
|
+
};
|
|
525
533
|
}
|
|
526
534
|
|
|
527
535
|
case "task.result": {
|
|
@@ -570,17 +578,15 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
570
578
|
|
|
571
579
|
case "task.user_input": {
|
|
572
580
|
const params = request.params as { id: string; value: string[] };
|
|
573
|
-
const taskDir = getTaskDir(config.projectRoot, params.id);
|
|
574
581
|
|
|
575
|
-
const
|
|
576
|
-
if (!
|
|
582
|
+
const pending = getPending(params.id);
|
|
583
|
+
if (!pending) {
|
|
577
584
|
return { ok: false, error: "not pending" };
|
|
578
585
|
}
|
|
579
586
|
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
const resolved = resolvePending(params.id, params.value);
|
|
582
588
|
console.log(`[task.user_input] ${params.id} → ${params.value}`);
|
|
583
|
-
return { ok:
|
|
589
|
+
return { ok: resolved };
|
|
584
590
|
}
|
|
585
591
|
|
|
586
592
|
case "taskrun.list": {
|
|
@@ -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) {
|