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
package/dist/events.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { StringCodec } from "nats";
|
|
2
|
-
import {
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
3
|
const sc = StringCodec();
|
|
4
4
|
/**
|
|
5
|
-
* Broadcast an event to connected clients via NATS and HTTP SSE
|
|
5
|
+
* Broadcast an event to connected clients via NATS and HTTP SSE.
|
|
6
6
|
*
|
|
7
7
|
* - NATS: publishes to `host-event.{hostId}.{taskId}`
|
|
8
|
-
* - HTTP: POSTs to the
|
|
8
|
+
* - HTTP: POSTs to the serve daemon's `/event` endpoint
|
|
9
9
|
*/
|
|
10
10
|
export async function publishHostEvent(nc, hostId, taskId, payload) {
|
|
11
11
|
const subject = `host-event.${hostId}.${taskId}`;
|
|
@@ -13,19 +13,18 @@ export async function publishHostEvent(nc, hostId, taskId, payload) {
|
|
|
13
13
|
nc.publish(subject, sc.encode(JSON.stringify(payload)));
|
|
14
14
|
console.log(`[nats] ${subject} →`, payload);
|
|
15
15
|
}
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const port = config.httpPort ?? 7400;
|
|
18
|
+
try {
|
|
19
|
+
await fetch(`http://localhost:${port}/event`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ task_id: taskId, ...payload }),
|
|
23
|
+
});
|
|
24
|
+
console.log(`[http] host-event: ${taskId} →`, payload);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Serve HTTP may not be ready yet — ignore
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
30
|
//# sourceMappingURL=events.js.map
|
package/dist/index.js
CHANGED
|
@@ -8,10 +8,7 @@ import { initCommand } from "./commands/init.js";
|
|
|
8
8
|
import { infoCommand } from "./commands/info.js";
|
|
9
9
|
import { runCommand } from "./commands/run.js";
|
|
10
10
|
import { serveCommand } from "./commands/serve.js";
|
|
11
|
-
import { notifyCommand } from "./commands/notify.js";
|
|
12
|
-
import { requestInputCommand } from "./commands/request-input.js";
|
|
13
11
|
import { pairCommand } from "./commands/pair.js";
|
|
14
|
-
import { lanCommand } from "./commands/lan.js";
|
|
15
12
|
import { restartCommand } from "./commands/restart.js";
|
|
16
13
|
import { sessionsListCommand, sessionsRevokeCommand, sessionsRevokeAllCommand } from "./commands/sessions.js";
|
|
17
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -51,34 +48,12 @@ program
|
|
|
51
48
|
.action(async () => {
|
|
52
49
|
await restartCommand();
|
|
53
50
|
});
|
|
54
|
-
program
|
|
55
|
-
.command("notify")
|
|
56
|
-
.description("Send a push notification to the user")
|
|
57
|
-
.requiredOption("--title <title>", "Notification title")
|
|
58
|
-
.requiredOption("--body <body>", "Notification body text")
|
|
59
|
-
.action(async (opts) => {
|
|
60
|
-
await notifyCommand(opts);
|
|
61
|
-
});
|
|
62
|
-
program
|
|
63
|
-
.command("request-input")
|
|
64
|
-
.description("Request input from the user (requires PALMIER_TASK_ID env var)")
|
|
65
|
-
.requiredOption("--description <desc...>", "Input descriptions to show the user")
|
|
66
|
-
.action(async (opts) => {
|
|
67
|
-
await requestInputCommand(opts);
|
|
68
|
-
});
|
|
69
51
|
program
|
|
70
52
|
.command("pair")
|
|
71
53
|
.description("Generate a pairing code for connecting a PWA client")
|
|
72
54
|
.action(async () => {
|
|
73
55
|
await pairCommand();
|
|
74
56
|
});
|
|
75
|
-
program
|
|
76
|
-
.command("lan")
|
|
77
|
-
.description("Start an on-demand LAN server for direct HTTP connections")
|
|
78
|
-
.option("-p, --port <port>", "Port to listen on", "7400")
|
|
79
|
-
.action(async (opts) => {
|
|
80
|
-
await lanCommand({ port: parseInt(opts.port, 10) });
|
|
81
|
-
});
|
|
82
57
|
const sessionsCmd = program
|
|
83
58
|
.command("sessions")
|
|
84
59
|
.description("Manage paired client sessions");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RequiredPermission } from "./types.js";
|
|
2
|
+
export interface PendingRequest {
|
|
3
|
+
type: "confirmation" | "permission" | "input";
|
|
4
|
+
resolve: (value: string[]) => void;
|
|
5
|
+
/** Permission list (for 'permission') or input descriptions (for 'input'). */
|
|
6
|
+
params?: RequiredPermission[] | string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Register a pending request for a task. Returns a Promise that resolves
|
|
10
|
+
* when `resolvePending` is called with the user's response.
|
|
11
|
+
* Only one pending request per task at a time.
|
|
12
|
+
*/
|
|
13
|
+
export declare function registerPending(taskId: string, type: PendingRequest["type"], params?: PendingRequest["params"]): Promise<string[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a pending request with the user's response.
|
|
16
|
+
* Returns true if a pending request was found and resolved.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolvePending(taskId: string, value: string[]): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Get the current pending request for a task (if any).
|
|
21
|
+
*/
|
|
22
|
+
export declare function getPending(taskId: string): PendingRequest | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Remove a pending request without resolving it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function removePending(taskId: string): void;
|
|
27
|
+
//# sourceMappingURL=pending-requests.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const pending = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Register a pending request for a task. Returns a Promise that resolves
|
|
4
|
+
* when `resolvePending` is called with the user's response.
|
|
5
|
+
* Only one pending request per task at a time.
|
|
6
|
+
*/
|
|
7
|
+
export function registerPending(taskId, type, params) {
|
|
8
|
+
if (pending.has(taskId)) {
|
|
9
|
+
return Promise.reject(new Error(`Task ${taskId} already has a pending request`));
|
|
10
|
+
}
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
pending.set(taskId, { type, resolve, params });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a pending request with the user's response.
|
|
17
|
+
* Returns true if a pending request was found and resolved.
|
|
18
|
+
*/
|
|
19
|
+
export function resolvePending(taskId, value) {
|
|
20
|
+
const entry = pending.get(taskId);
|
|
21
|
+
if (!entry)
|
|
22
|
+
return false;
|
|
23
|
+
pending.delete(taskId);
|
|
24
|
+
entry.resolve(value);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Get the current pending request for a task (if any).
|
|
29
|
+
*/
|
|
30
|
+
export function getPending(taskId) {
|
|
31
|
+
return pending.get(taskId);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Remove a pending request without resolving it.
|
|
35
|
+
*/
|
|
36
|
+
export function removePending(taskId) {
|
|
37
|
+
pending.delete(taskId);
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=pending-requests.js.map
|
package/dist/rpc-handler.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "url";
|
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
7
|
import { listTasks, parseTaskFile, writeTaskFile, getTaskDir, readTaskStatus, writeTaskStatus, readHistory, deleteHistoryEntry, appendTaskList, removeFromTaskList, appendHistory, createRunDir, appendRunMessage, getRunDir } from "./task.js";
|
|
8
|
+
import { resolvePending, getPending } from "./pending-requests.js";
|
|
8
9
|
import { getPlatform } from "./platform/index.js";
|
|
9
10
|
import { spawnCommand } from "./spawn-command.js";
|
|
10
11
|
import crossSpawn from "cross-spawn";
|
|
@@ -131,8 +132,8 @@ export function createRpcHandler(config, nc) {
|
|
|
131
132
|
};
|
|
132
133
|
}
|
|
133
134
|
async function handleRpc(request) {
|
|
134
|
-
// Session token validation:
|
|
135
|
-
if (!request.sessionToken || !validateSession(request.sessionToken)) {
|
|
135
|
+
// Session token validation: skip for trusted localhost requests
|
|
136
|
+
if (!request.localhost && (!request.sessionToken || !validateSession(request.sessionToken))) {
|
|
136
137
|
return { error: "Unauthorized" };
|
|
137
138
|
}
|
|
138
139
|
switch (request.method) {
|
|
@@ -447,7 +448,14 @@ export function createRpcHandler(config, nc) {
|
|
|
447
448
|
if (!status) {
|
|
448
449
|
return { task_id: params.id, error: "No status found" };
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
const pending = getPending(params.id);
|
|
452
|
+
return {
|
|
453
|
+
task_id: params.id,
|
|
454
|
+
...status,
|
|
455
|
+
...(pending?.type === "confirmation" ? { pending_confirmation: true } : {}),
|
|
456
|
+
...(pending?.type === "permission" ? { pending_permission: pending.params } : {}),
|
|
457
|
+
...(pending?.type === "input" ? { pending_input: pending.params } : {}),
|
|
458
|
+
};
|
|
451
459
|
}
|
|
452
460
|
case "task.result": {
|
|
453
461
|
const params = request.params;
|
|
@@ -494,14 +502,13 @@ export function createRpcHandler(config, nc) {
|
|
|
494
502
|
}
|
|
495
503
|
case "task.user_input": {
|
|
496
504
|
const params = request.params;
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
if (!currentStatus?.pending_confirmation && !currentStatus?.pending_permission?.length && !currentStatus?.pending_input?.length) {
|
|
505
|
+
const pending = getPending(params.id);
|
|
506
|
+
if (!pending) {
|
|
500
507
|
return { ok: false, error: "not pending" };
|
|
501
508
|
}
|
|
502
|
-
|
|
509
|
+
const resolved = resolvePending(params.id, params.value);
|
|
503
510
|
console.log(`[task.user_input] ${params.id} → ${params.value}`);
|
|
504
|
-
return { ok:
|
|
511
|
+
return { ok: resolved };
|
|
505
512
|
}
|
|
506
513
|
case "taskrun.list": {
|
|
507
514
|
const params = request.params;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { type NatsConnection } from "nats";
|
|
1
2
|
import type { HostConfig, RpcMessage } from "../types.js";
|
|
2
3
|
export declare function detectLanIp(): string;
|
|
3
4
|
/**
|
|
4
|
-
* Start the HTTP transport:
|
|
5
|
+
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
6
|
+
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
5
7
|
*/
|
|
6
|
-
export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, pairingCode?: string, onReady?: () => void): Promise<void>;
|
|
8
|
+
export declare function startHttpTransport(config: HostConfig, handleRpc: (req: RpcMessage) => Promise<unknown>, port: number, nc: NatsConnection | undefined, pairingCode?: string, onReady?: () => void): Promise<void>;
|
|
7
9
|
//# sourceMappingURL=http-transport.d.ts.map
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import * as http from "node:http";
|
|
2
2
|
import * as os from "os";
|
|
3
|
+
import { StringCodec } from "nats";
|
|
3
4
|
import { validateSession, addSession } from "../session-store.js";
|
|
5
|
+
import { registerPending } from "../pending-requests.js";
|
|
6
|
+
import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
|
|
4
7
|
const PWA_ORIGIN = "https://app.palmier.me";
|
|
8
|
+
const assetCache = new Map();
|
|
9
|
+
/** Paths currently being fetched (dedup concurrent requests). */
|
|
10
|
+
const assetInflight = new Map();
|
|
5
11
|
const CONTENT_TYPES = {
|
|
6
12
|
".html": "text/html; charset=utf-8",
|
|
7
13
|
".js": "application/javascript",
|
|
@@ -14,6 +20,8 @@ const CONTENT_TYPES = {
|
|
|
14
20
|
".svg": "image/svg+xml",
|
|
15
21
|
};
|
|
16
22
|
function guessContentType(urlPath) {
|
|
23
|
+
if (urlPath === "/")
|
|
24
|
+
return "text/html; charset=utf-8";
|
|
17
25
|
const ext = urlPath.match(/\.[^.]+$/)?.[0] ?? "";
|
|
18
26
|
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
19
27
|
}
|
|
@@ -24,55 +32,39 @@ async function fetchBuffer(url) {
|
|
|
24
32
|
return Buffer.from(await res.arrayBuffer());
|
|
25
33
|
}
|
|
26
34
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
35
|
+
* Fetch a PWA asset on-the-fly, caching in memory.
|
|
36
|
+
* Returns null if the asset cannot be fetched.
|
|
29
37
|
*/
|
|
30
|
-
async function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const refRegex = /(?:src|href)="([^"]+)"/g;
|
|
40
|
-
const htmlRefs = new Set();
|
|
41
|
-
let match;
|
|
42
|
-
while ((match = refRegex.exec(htmlStr)) !== null) {
|
|
43
|
-
const ref = match[1];
|
|
44
|
-
if (ref.startsWith("/") && !ref.startsWith("//") && !SKIP.has(ref)) {
|
|
45
|
-
htmlRefs.add(ref);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// 3. Fetch all HTML-referenced assets
|
|
49
|
-
for (const ref of htmlRefs) {
|
|
38
|
+
async function getAsset(urlPath) {
|
|
39
|
+
const cached = assetCache.get(urlPath);
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
// Dedup concurrent requests for the same path
|
|
43
|
+
const inflight = assetInflight.get(urlPath);
|
|
44
|
+
if (inflight)
|
|
45
|
+
return inflight;
|
|
46
|
+
const promise = (async () => {
|
|
50
47
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const urlRegex = /url\(["']?([^"')]+)["']?\)/g;
|
|
57
|
-
let cssMatch;
|
|
58
|
-
while ((cssMatch = urlRegex.exec(cssStr)) !== null) {
|
|
59
|
-
let fontRef = cssMatch[1];
|
|
60
|
-
if (fontRef.startsWith("data:"))
|
|
61
|
-
continue;
|
|
62
|
-
// Resolve relative URLs against the CSS file's directory
|
|
63
|
-
if (!fontRef.startsWith("/")) {
|
|
64
|
-
const cssDir = ref.substring(0, ref.lastIndexOf("/") + 1);
|
|
65
|
-
fontRef = cssDir + fontRef;
|
|
66
|
-
}
|
|
67
|
-
htmlRefs.add(fontRef);
|
|
68
|
-
}
|
|
48
|
+
let data = await fetchBuffer(`${PWA_ORIGIN}${urlPath}`);
|
|
49
|
+
// Inject LAN mode marker into index HTML so the PWA can detect it's served by palmier
|
|
50
|
+
if (urlPath === "/") {
|
|
51
|
+
const html = data.toString("utf-8").replace("</head>", "<script>window.__PALMIER_SERVE__=true</script></head>");
|
|
52
|
+
data = Buffer.from(html, "utf-8");
|
|
69
53
|
}
|
|
54
|
+
const asset = { data, contentType: guessContentType(urlPath) };
|
|
55
|
+
assetCache.set(urlPath, asset);
|
|
56
|
+
return asset;
|
|
70
57
|
}
|
|
71
58
|
catch (err) {
|
|
72
|
-
console.warn(`[pwa] Failed to fetch ${
|
|
59
|
+
console.warn(`[pwa] Failed to fetch ${urlPath}: ${err}`);
|
|
60
|
+
return null;
|
|
73
61
|
}
|
|
74
|
-
|
|
75
|
-
|
|
62
|
+
finally {
|
|
63
|
+
assetInflight.delete(urlPath);
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
assetInflight.set(urlPath, promise);
|
|
67
|
+
return promise;
|
|
76
68
|
}
|
|
77
69
|
const pendingPairs = new Map();
|
|
78
70
|
export function detectLanIp() {
|
|
@@ -87,22 +79,18 @@ export function detectLanIp() {
|
|
|
87
79
|
return "127.0.0.1";
|
|
88
80
|
}
|
|
89
81
|
/**
|
|
90
|
-
* Start the HTTP transport:
|
|
82
|
+
* Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
|
|
83
|
+
* localhost-only agent endpoints (notify, request-input, confirmation, permission).
|
|
91
84
|
*/
|
|
92
|
-
export async function startHttpTransport(config, handleRpc, port, pairingCode, onReady) {
|
|
93
|
-
// Download PWA assets into memory before starting the server
|
|
94
|
-
console.log("[http] Downloading PWA assets...");
|
|
95
|
-
const pwaAssets = await downloadPwaAssets();
|
|
96
|
-
console.log(`[http] Cached ${pwaAssets.size} PWA assets in memory.`);
|
|
85
|
+
export async function startHttpTransport(config, handleRpc, port, nc, pairingCode, onReady) {
|
|
97
86
|
const sseClients = new Set();
|
|
98
|
-
|
|
87
|
+
const lanEnabled = config.lanEnabled ?? false;
|
|
88
|
+
const bindAddress = lanEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
89
|
+
// If a pairing code is provided, pre-register it
|
|
99
90
|
if (pairingCode) {
|
|
100
|
-
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
91
|
+
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
101
92
|
const timer = setTimeout(() => { pendingPairs.delete(pairingCode); }, EXPIRY_MS);
|
|
102
|
-
pendingPairs.set(pairingCode, {
|
|
103
|
-
resolve: () => { },
|
|
104
|
-
timer,
|
|
105
|
-
});
|
|
93
|
+
pendingPairs.set(pairingCode, { resolve: () => { }, timer });
|
|
106
94
|
}
|
|
107
95
|
function broadcastSseEvent(data) {
|
|
108
96
|
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
@@ -114,8 +102,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
114
102
|
const auth = req.headers.authorization;
|
|
115
103
|
if (!auth || !auth.startsWith("Bearer "))
|
|
116
104
|
return false;
|
|
117
|
-
|
|
118
|
-
return validateSession(token);
|
|
105
|
+
return validateSession(auth.slice(7));
|
|
119
106
|
}
|
|
120
107
|
function extractSessionToken(req) {
|
|
121
108
|
const auth = req.headers.authorization;
|
|
@@ -139,11 +126,22 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
139
126
|
const addr = req.socket.remoteAddress;
|
|
140
127
|
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
141
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Publish an event via NATS and SSE.
|
|
131
|
+
*/
|
|
132
|
+
async function publishEvent(taskId, payload) {
|
|
133
|
+
const sc = StringCodec();
|
|
134
|
+
const subject = `host-event.${config.hostId}.${taskId}`;
|
|
135
|
+
if (nc) {
|
|
136
|
+
nc.publish(subject, sc.encode(JSON.stringify(payload)));
|
|
137
|
+
}
|
|
138
|
+
broadcastSseEvent({ task_id: taskId, ...payload });
|
|
139
|
+
}
|
|
142
140
|
const server = http.createServer(async (req, res) => {
|
|
143
141
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
144
142
|
const pathname = url.pathname;
|
|
145
|
-
//
|
|
146
|
-
if (req.method === "POST" && pathname === "/
|
|
143
|
+
// ── Localhost-only endpoints (no auth) ─────────────────────────────
|
|
144
|
+
if (req.method === "POST" && pathname === "/event") {
|
|
147
145
|
if (!isLocalhost(req)) {
|
|
148
146
|
sendJson(res, 403, { error: "localhost only" });
|
|
149
147
|
return;
|
|
@@ -159,9 +157,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
159
157
|
}
|
|
160
158
|
return;
|
|
161
159
|
}
|
|
162
|
-
|
|
163
|
-
// The pair CLI posts here and blocks until paired or expired.
|
|
164
|
-
if (req.method === "POST" && pathname === "/internal/pair-register") {
|
|
160
|
+
if (req.method === "POST" && pathname === "/pair-register") {
|
|
165
161
|
if (!isLocalhost(req)) {
|
|
166
162
|
sendJson(res, 403, { error: "localhost only" });
|
|
167
163
|
return;
|
|
@@ -183,7 +179,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
183
179
|
resolve({ paired: false });
|
|
184
180
|
}, expiryMs ?? 5 * 60 * 1000);
|
|
185
181
|
pendingPairs.set(code, { resolve, timer });
|
|
186
|
-
// Clean up if the CLI disconnects early
|
|
187
182
|
req.on("close", () => {
|
|
188
183
|
if (pendingPairs.has(code)) {
|
|
189
184
|
clearTimeout(timer);
|
|
@@ -198,7 +193,156 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
198
193
|
}
|
|
199
194
|
return;
|
|
200
195
|
}
|
|
201
|
-
//
|
|
196
|
+
// ── GET /notify — send push notification via NATS ──────────────────
|
|
197
|
+
if (req.method === "GET" && pathname === "/notify") {
|
|
198
|
+
if (!isLocalhost(req)) {
|
|
199
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!nc) {
|
|
203
|
+
sendJson(res, 503, { error: "NATS not connected — push notifications require server mode" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const title = url.searchParams.get("title");
|
|
208
|
+
const notifBody = url.searchParams.get("body");
|
|
209
|
+
if (!title || !notifBody) {
|
|
210
|
+
sendJson(res, 400, { error: "title and body query params are required" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const sc = StringCodec();
|
|
214
|
+
const payload = { hostId: config.hostId, title, body: notifBody };
|
|
215
|
+
const subject = `host.${config.hostId}.push.send`;
|
|
216
|
+
const reply = await nc.request(subject, sc.encode(JSON.stringify(payload)), { timeout: 15_000 });
|
|
217
|
+
const result = JSON.parse(sc.decode(reply.data));
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
sendJson(res, 200, { ok: true });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
sendJson(res, 502, { error: result.error ?? "Push notification failed" });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
sendJson(res, 500, { error: `Failed to send notification: ${err}` });
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// ── GET /request-input — held connection until user responds ────────
|
|
231
|
+
if (req.method === "GET" && pathname === "/request-input") {
|
|
232
|
+
if (!isLocalhost(req)) {
|
|
233
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const taskId = url.searchParams.get("taskId");
|
|
238
|
+
const runId = url.searchParams.get("runId");
|
|
239
|
+
const descriptions = url.searchParams.getAll("descriptions");
|
|
240
|
+
if (!taskId || !descriptions.length) {
|
|
241
|
+
sendJson(res, 400, { error: "taskId and descriptions query params are required" });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const taskDir = getTaskDir(config.projectRoot, taskId);
|
|
245
|
+
const task = parseTaskFile(taskDir);
|
|
246
|
+
await publishEvent(taskId, {
|
|
247
|
+
event_type: "input-request",
|
|
248
|
+
host_id: config.hostId,
|
|
249
|
+
input_descriptions: descriptions,
|
|
250
|
+
name: task.frontmatter.name,
|
|
251
|
+
});
|
|
252
|
+
const response = await registerPending(taskId, "input", descriptions);
|
|
253
|
+
if (response.length === 1 && response[0] === "aborted") {
|
|
254
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
|
|
255
|
+
if (runId) {
|
|
256
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
|
|
257
|
+
}
|
|
258
|
+
sendJson(res, 200, { aborted: true });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
|
|
262
|
+
if (runId) {
|
|
263
|
+
const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
|
|
264
|
+
appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
|
|
265
|
+
}
|
|
266
|
+
sendJson(res, 200, { values: response });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
sendJson(res, 500, { error: String(err) });
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// ── GET /request-confirmation — held connection ─────────────────────
|
|
275
|
+
if (req.method === "GET" && pathname === "/request-confirmation") {
|
|
276
|
+
if (!isLocalhost(req)) {
|
|
277
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const taskId = url.searchParams.get("taskId");
|
|
282
|
+
if (!taskId) {
|
|
283
|
+
sendJson(res, 400, { error: "taskId query param is required" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await publishEvent(taskId, {
|
|
287
|
+
event_type: "confirm-request",
|
|
288
|
+
host_id: config.hostId,
|
|
289
|
+
});
|
|
290
|
+
const response = await registerPending(taskId, "confirmation");
|
|
291
|
+
const confirmed = response[0] === "confirmed";
|
|
292
|
+
await publishEvent(taskId, {
|
|
293
|
+
event_type: "confirm-resolved",
|
|
294
|
+
host_id: config.hostId,
|
|
295
|
+
status: confirmed ? "confirmed" : "aborted",
|
|
296
|
+
});
|
|
297
|
+
sendJson(res, 200, { confirmed });
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
sendJson(res, 500, { error: String(err) });
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// ── GET /request-permission — held connection ───────────────────────
|
|
305
|
+
if (req.method === "GET" && pathname === "/request-permission") {
|
|
306
|
+
if (!isLocalhost(req)) {
|
|
307
|
+
sendJson(res, 403, { error: "localhost only" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const taskId = url.searchParams.get("taskId");
|
|
312
|
+
const taskName = url.searchParams.get("taskName");
|
|
313
|
+
const permissionsRaw = url.searchParams.get("permissions");
|
|
314
|
+
let permissions = [];
|
|
315
|
+
if (permissionsRaw) {
|
|
316
|
+
try {
|
|
317
|
+
permissions = JSON.parse(permissionsRaw);
|
|
318
|
+
}
|
|
319
|
+
catch { /* ignore */ }
|
|
320
|
+
}
|
|
321
|
+
if (!taskId || !permissions.length) {
|
|
322
|
+
sendJson(res, 400, { error: "taskId and permissions query params are required" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await publishEvent(taskId, {
|
|
326
|
+
event_type: "permission-request",
|
|
327
|
+
host_id: config.hostId,
|
|
328
|
+
required_permissions: permissions,
|
|
329
|
+
name: taskName,
|
|
330
|
+
});
|
|
331
|
+
const response = await registerPending(taskId, "permission", permissions);
|
|
332
|
+
const status = response[0];
|
|
333
|
+
await publishEvent(taskId, {
|
|
334
|
+
event_type: "permission-resolved",
|
|
335
|
+
host_id: config.hostId,
|
|
336
|
+
status,
|
|
337
|
+
});
|
|
338
|
+
sendJson(res, 200, { response: status });
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
sendJson(res, 500, { error: String(err) });
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// ── Public pair endpoint — no auth, PWA posts OTP code here ────────
|
|
202
346
|
if (req.method === "POST" && pathname === "/pair") {
|
|
203
347
|
try {
|
|
204
348
|
const body = await readBody(req);
|
|
@@ -212,7 +356,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
212
356
|
sendJson(res, 401, { error: "Invalid code" });
|
|
213
357
|
return;
|
|
214
358
|
}
|
|
215
|
-
// Create session and build response
|
|
216
359
|
const session = addSession(label);
|
|
217
360
|
const ip = detectLanIp();
|
|
218
361
|
const response = {
|
|
@@ -220,7 +363,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
220
363
|
sessionToken: session.token,
|
|
221
364
|
directUrl: `http://${ip}:${port}`,
|
|
222
365
|
};
|
|
223
|
-
// Resolve the long-poll and clean up
|
|
224
366
|
clearTimeout(pending.timer);
|
|
225
367
|
pendingPairs.delete(code);
|
|
226
368
|
pending.resolve({ paired: true });
|
|
@@ -231,22 +373,31 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
231
373
|
}
|
|
232
374
|
return;
|
|
233
375
|
}
|
|
234
|
-
//
|
|
376
|
+
// ── PWA assets (on-the-fly, cached) ────────────────────────────────
|
|
377
|
+
// Skip service worker and manifest — they require HTTPS which LAN mode doesn't use
|
|
378
|
+
const SKIP = new Set(["/registerSW.js", "/service-worker.js", "/manifest.webmanifest"]);
|
|
235
379
|
const isApiRoute = pathname === "/events" || pathname.startsWith("/rpc/");
|
|
236
380
|
if (!isApiRoute) {
|
|
237
|
-
|
|
238
|
-
|
|
381
|
+
if (SKIP.has(pathname)) {
|
|
382
|
+
sendJson(res, 404, { error: "Not found" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Try exact path, then fall back to index.html (SPA routing)
|
|
386
|
+
let asset = await getAsset(pathname);
|
|
387
|
+
if (!asset && pathname !== "/") {
|
|
388
|
+
asset = await getAsset("/");
|
|
389
|
+
}
|
|
239
390
|
if (asset) {
|
|
240
391
|
res.writeHead(200, { "Content-Type": asset.contentType });
|
|
241
392
|
res.end(asset.data);
|
|
242
393
|
}
|
|
243
394
|
else {
|
|
244
|
-
sendJson(res,
|
|
395
|
+
sendJson(res, 502, { error: "Failed to fetch PWA assets" });
|
|
245
396
|
}
|
|
246
397
|
return;
|
|
247
398
|
}
|
|
248
|
-
// API endpoints require auth
|
|
249
|
-
if (!checkAuth(req)) {
|
|
399
|
+
// ── API endpoints require auth (localhost is trusted) ───────────────
|
|
400
|
+
if (!isLocalhost(req) && !checkAuth(req)) {
|
|
250
401
|
sendJson(res, 401, { error: "Unauthorized" });
|
|
251
402
|
return;
|
|
252
403
|
}
|
|
@@ -258,7 +409,6 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
258
409
|
Connection: "keep-alive",
|
|
259
410
|
});
|
|
260
411
|
res.write(":ok\n\n");
|
|
261
|
-
// Send heartbeat every 5 seconds
|
|
262
412
|
const heartbeat = setInterval(() => {
|
|
263
413
|
res.write("data: {\"heartbeat\":true}\n\n");
|
|
264
414
|
}, 5000);
|
|
@@ -290,7 +440,7 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
290
440
|
const sessionToken = extractSessionToken(req);
|
|
291
441
|
console.log(`[http] RPC: ${method}`);
|
|
292
442
|
try {
|
|
293
|
-
const response = await handleRpc({ method, params, sessionToken });
|
|
443
|
+
const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
|
|
294
444
|
console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
|
|
295
445
|
sendJson(res, 200, response);
|
|
296
446
|
}
|
|
@@ -303,10 +453,9 @@ export async function startHttpTransport(config, handleRpc, port, pairingCode, o
|
|
|
303
453
|
sendJson(res, 404, { error: "Not found" });
|
|
304
454
|
});
|
|
305
455
|
return new Promise((resolve, reject) => {
|
|
306
|
-
server.listen(port, () => {
|
|
307
|
-
console.log(`[http] Listening on
|
|
456
|
+
server.listen(port, bindAddress, () => {
|
|
457
|
+
console.log(`[http] Listening on ${bindAddress}:${port}`);
|
|
308
458
|
onReady?.();
|
|
309
|
-
// Graceful shutdown
|
|
310
459
|
const shutdown = () => {
|
|
311
460
|
console.log("[http] Shutting down...");
|
|
312
461
|
for (const client of sseClients) {
|