palmier 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/agents/agent.d.ts +3 -0
  2. package/dist/agents/agent.js +1 -1
  3. package/dist/agents/aider.d.ts +1 -0
  4. package/dist/agents/aider.js +1 -0
  5. package/dist/agents/claude.d.ts +1 -0
  6. package/dist/agents/claude.js +1 -0
  7. package/dist/agents/cline.d.ts +1 -0
  8. package/dist/agents/cline.js +1 -0
  9. package/dist/agents/codex.d.ts +1 -0
  10. package/dist/agents/codex.js +1 -0
  11. package/dist/agents/copilot.d.ts +1 -0
  12. package/dist/agents/copilot.js +1 -0
  13. package/dist/agents/cursor.d.ts +1 -0
  14. package/dist/agents/cursor.js +1 -0
  15. package/dist/agents/deepagents.d.ts +1 -0
  16. package/dist/agents/deepagents.js +1 -0
  17. package/dist/agents/droid.d.ts +1 -0
  18. package/dist/agents/droid.js +1 -0
  19. package/dist/agents/gemini.d.ts +1 -0
  20. package/dist/agents/gemini.js +1 -0
  21. package/dist/agents/goose.d.ts +1 -0
  22. package/dist/agents/goose.js +1 -0
  23. package/dist/agents/hermes.d.ts +1 -0
  24. package/dist/agents/hermes.js +1 -0
  25. package/dist/agents/kimi.d.ts +1 -0
  26. package/dist/agents/kimi.js +1 -0
  27. package/dist/agents/kiro.d.ts +1 -0
  28. package/dist/agents/kiro.js +1 -0
  29. package/dist/agents/openclaw.d.ts +1 -0
  30. package/dist/agents/openclaw.js +2 -2
  31. package/dist/agents/opencode.d.ts +1 -0
  32. package/dist/agents/opencode.js +1 -0
  33. package/dist/agents/qoder.d.ts +1 -0
  34. package/dist/agents/qoder.js +1 -0
  35. package/dist/agents/qwen.d.ts +1 -0
  36. package/dist/agents/qwen.js +1 -0
  37. package/dist/agents/shared-prompt.js +1 -1
  38. package/dist/commands/init.js +3 -2
  39. package/dist/commands/pair.js +3 -3
  40. package/dist/commands/run.js +4 -4
  41. package/dist/commands/serve.js +1 -1
  42. package/dist/config.js +2 -2
  43. package/dist/device-capabilities.d.ts +1 -1
  44. package/dist/events.js +1 -1
  45. package/dist/mcp-tools.js +79 -7
  46. package/dist/nats-client.d.ts +1 -1
  47. package/dist/nats-client.js +6 -3
  48. package/dist/pending-requests.d.ts +30 -8
  49. package/dist/pending-requests.js +28 -15
  50. package/dist/pwa/assets/index-8cTctVnD.js +120 -0
  51. package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
  52. package/dist/pwa/assets/{web-DnuoxUd4.js → web-BNr628AV.js} +1 -1
  53. package/dist/pwa/assets/{web-7raT3zOZ.js → web-DyQPewAi.js} +1 -1
  54. package/dist/pwa/index.html +2 -2
  55. package/dist/pwa/service-worker.js +1 -1
  56. package/dist/rpc-handler.js +12 -16
  57. package/dist/transports/http-transport.js +6 -3
  58. package/dist/types.d.ts +4 -1
  59. package/package.json +1 -1
  60. package/palmier-server/PRODUCTION.md +31 -28
  61. package/palmier-server/README.md +35 -5
  62. package/palmier-server/nats.conf +9 -5
  63. package/palmier-server/package.json +2 -1
  64. package/palmier-server/pnpm-lock.yaml +6 -0
  65. package/palmier-server/pwa/src/App.css +66 -0
  66. package/palmier-server/pwa/src/App.tsx +1 -0
  67. package/palmier-server/pwa/src/components/HostMenu.tsx +65 -2
  68. package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
  69. package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
  70. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  71. package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
  72. package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
  73. package/palmier-server/pwa/src/constants.ts +1 -1
  74. package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
  75. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  76. package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
  77. package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
  78. package/palmier-server/pwa/src/types.ts +1 -6
  79. package/palmier-server/server/package.json +3 -1
  80. package/palmier-server/server/src/index.ts +83 -2
  81. package/palmier-server/server/src/nats-jwt.ts +299 -0
  82. package/palmier-server/server/src/nats-setup.ts +48 -0
  83. package/palmier-server/server/src/nats.ts +12 -4
  84. package/palmier-server/server/src/routes/device.ts +24 -0
  85. package/palmier-server/server/src/routes/hosts.ts +13 -2
  86. package/palmier-server/spec.md +28 -14
  87. package/src/agents/agent.ts +5 -1
  88. package/src/agents/aider.ts +1 -0
  89. package/src/agents/claude.ts +1 -0
  90. package/src/agents/cline.ts +1 -0
  91. package/src/agents/codex.ts +1 -0
  92. package/src/agents/copilot.ts +1 -0
  93. package/src/agents/cursor.ts +1 -0
  94. package/src/agents/deepagents.ts +1 -0
  95. package/src/agents/droid.ts +1 -0
  96. package/src/agents/gemini.ts +1 -0
  97. package/src/agents/goose.ts +1 -0
  98. package/src/agents/hermes.ts +1 -0
  99. package/src/agents/kimi.ts +1 -0
  100. package/src/agents/kiro.ts +1 -0
  101. package/src/agents/openclaw.ts +2 -2
  102. package/src/agents/opencode.ts +1 -0
  103. package/src/agents/qoder.ts +1 -0
  104. package/src/agents/qwen.ts +1 -0
  105. package/src/agents/shared-prompt.ts +1 -1
  106. package/src/commands/init.ts +7 -5
  107. package/src/commands/pair.ts +3 -3
  108. package/src/commands/run.ts +4 -4
  109. package/src/commands/serve.ts +1 -1
  110. package/src/config.ts +2 -2
  111. package/src/device-capabilities.ts +1 -0
  112. package/src/events.ts +1 -1
  113. package/src/mcp-tools.ts +83 -7
  114. package/src/nats-client.ts +10 -3
  115. package/src/pending-requests.ts +47 -15
  116. package/src/rpc-handler.ts +13 -16
  117. package/src/transports/http-transport.ts +6 -3
  118. package/src/types.ts +4 -3
  119. package/test/agent-instructions.test.ts +10 -10
  120. package/test/pairing.test.ts +2 -2
  121. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  122. package/dist/pwa/assets/index-uSwkmHBs.js +0 -118
@@ -0,0 +1,299 @@
1
+ /**
2
+ * NATS JWT/NKey utilities for decentralized authentication.
3
+ *
4
+ * NATS supports a decentralized auth model where authorization is embedded in
5
+ * signed JWTs rather than managed in server config files. This means new hosts
6
+ * can be onboarded without restarting or reconfiguring the NATS server.
7
+ *
8
+ * Key hierarchy:
9
+ *
10
+ * Operator (root of trust)
11
+ * └─ signs Account JWT → embedded in NATS server config (one-time)
12
+ * └─ Account (runtime signing key, held by palmier-server)
13
+ * ├─ signs Host User JWTs → scoped to host.{id}.* subjects
14
+ * ├─ signs PWA User JWTs → scoped to RPC + pairing subjects
15
+ * └─ signs Server User JWT → full access for relaying
16
+ *
17
+ * JWT format (NATS-specific, not standard JWT):
18
+ * Header: {"typ":"JWT","alg":"ed25519-nkey"}
19
+ * Payload: NATS claims (issuer, subject, permissions)
20
+ * Signature: Ed25519(header.payload, signing_key)
21
+ *
22
+ * Subject permission model:
23
+ *
24
+ * | Role | Publish | Subscribe |
25
+ * |-------------------|--------------------------|------------------------|
26
+ * | Host (X) | host-event.X.>, host.X.> | host.X.>, pair.* |
27
+ * | PWA (pairing) | pair.* | (none) |
28
+ * | PWA (connected X) | host.X.rpc.> | host-event.X.> |
29
+ * | Server | > (full) | > (full) |
30
+ *
31
+ * Request/reply inbox subjects (_INBOX.>) are allowed automatically via the
32
+ * `resp` field in the JWT claims.
33
+ */
34
+
35
+ import { createOperator, createAccount, createUser, fromSeed, type KeyPair } from "nkeys.js";
36
+ import crypto from "crypto";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Base64url helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function base64urlEncode(data: Uint8Array): string {
43
+ return Buffer.from(data).toString("base64url");
44
+ }
45
+
46
+ function base64urlEncodeStr(str: string): string {
47
+ return Buffer.from(str, "utf-8").toString("base64url");
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // NKey seed ↔ string helpers
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function seedToString(seed: Uint8Array): string {
55
+ return new TextDecoder().decode(seed);
56
+ }
57
+
58
+ export function seedFromString(seed: string): Uint8Array {
59
+ return new TextEncoder().encode(seed);
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // JWT signing
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Sign a NATS JWT: base64url(header).base64url(claims).base64url(ed25519_sig). */
67
+ function signJwt(claims: Record<string, unknown>, signingKey: KeyPair): string {
68
+ const header = base64urlEncodeStr(JSON.stringify({ typ: "JWT", alg: "ed25519-nkey" }));
69
+ const payload = base64urlEncodeStr(JSON.stringify(claims));
70
+ const sigInput = new TextEncoder().encode(`${header}.${payload}`);
71
+ const signature = base64urlEncode(signingKey.sign(sigInput));
72
+ return `${header}.${payload}.${signature}`;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Account JWT
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Create an Operator JWT (self-signed). Used in the NATS server `operator` field. */
80
+ export function createOperatorJwt(operatorSeed: string): string {
81
+ const operatorKp = fromSeed(seedFromString(operatorSeed));
82
+ const claims = {
83
+ jti: crypto.randomUUID().replace(/-/g, ""),
84
+ iat: Math.floor(Date.now() / 1000),
85
+ iss: operatorKp.getPublicKey(),
86
+ name: "palmier-operator",
87
+ sub: operatorKp.getPublicKey(),
88
+ nats: {
89
+ type: "operator",
90
+ version: 2,
91
+ },
92
+ };
93
+ const jwt = signJwt(claims, operatorKp);
94
+ operatorKp.clear();
95
+ return jwt;
96
+ }
97
+
98
+ /** Create an Account JWT signed by the operator. Embedded in NATS server config. */
99
+ export function createAccountJwt(operatorSeed: string, accountPublicKey: string): string {
100
+ const operatorKp = fromSeed(seedFromString(operatorSeed));
101
+ const claims = {
102
+ jti: crypto.randomUUID().replace(/-/g, ""),
103
+ iat: Math.floor(Date.now() / 1000),
104
+ iss: operatorKp.getPublicKey(),
105
+ name: "palmier",
106
+ sub: accountPublicKey,
107
+ nats: {
108
+ limits: {
109
+ subs: -1,
110
+ data: -1,
111
+ payload: -1,
112
+ imports: -1,
113
+ exports: -1,
114
+ wildcards: true,
115
+ conn: -1,
116
+ leaf: -1,
117
+ },
118
+ type: "account",
119
+ version: 2,
120
+ },
121
+ };
122
+ const jwt = signJwt(claims, operatorKp);
123
+ operatorKp.clear();
124
+ return jwt;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // User JWT (generic)
129
+ // ---------------------------------------------------------------------------
130
+
131
+ interface UserPermissions {
132
+ pub: { allow: string[] };
133
+ sub: { allow: string[] };
134
+ }
135
+
136
+ /**
137
+ * Create a User JWT signed by the account key.
138
+ * _INBOX.> is added to subscribe permissions so request/reply works.
139
+ * Unlimited values (-1) for subs/data/payload mean no artificial caps.
140
+ */
141
+ function createUserJwt(
142
+ accountSeed: string,
143
+ userPublicKey: string,
144
+ name: string,
145
+ permissions: UserPermissions,
146
+ ): string {
147
+ const accountKp = fromSeed(seedFromString(accountSeed));
148
+ const claims = {
149
+ jti: crypto.randomUUID().replace(/-/g, ""),
150
+ iat: Math.floor(Date.now() / 1000),
151
+ iss: accountKp.getPublicKey(),
152
+ name,
153
+ sub: userPublicKey,
154
+ nats: {
155
+ pub: { allow: [...permissions.pub.allow, "_INBOX.>"] },
156
+ sub: { allow: [...permissions.sub.allow, "_INBOX.>"] },
157
+ subs: -1,
158
+ data: -1,
159
+ payload: -1,
160
+ type: "user",
161
+ version: 2,
162
+ },
163
+ };
164
+ const jwt = signJwt(claims, accountKp);
165
+ accountKp.clear();
166
+ return jwt;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Role-specific JWT generators
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export interface NatsCredentials {
174
+ jwt: string;
175
+ nkeySeed: string;
176
+ }
177
+
178
+ /**
179
+ * Create credentials for a host daemon, scoped to the host's own subjects.
180
+ *
181
+ * A host with id X can only:
182
+ * - Publish to: host-event.X.> (task events), host.X.> (FCM requests, push)
183
+ * - Subscribe to: host.X.> (RPC, device responses), pair.* (during pairing)
184
+ *
185
+ * It cannot access any other host's subjects.
186
+ */
187
+ export function createHostCredentials(accountSeed: string, hostId: string): NatsCredentials {
188
+ const userKp = createUser();
189
+ const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), `host-${hostId}`, {
190
+ pub: { allow: [`host-event.${hostId}.>`, `host.${hostId}.>`] },
191
+ sub: { allow: [`host.${hostId}.>`, "pair.*"] },
192
+ });
193
+ const nkeySeed = seedToString(userKp.getSeed());
194
+ userKp.clear();
195
+ return { jwt, nkeySeed };
196
+ }
197
+
198
+ /**
199
+ * Create pairing-only credentials for the PWA. Used during the pairing flow
200
+ * before the PWA knows which host it will connect to.
201
+ *
202
+ * Minimal permissions:
203
+ * - Publish to: pair.* (send pairing request)
204
+ * - Subscribe to: (none — reply inbox handled by `resp`)
205
+ */
206
+ export function createPairingCredentials(accountSeed: string): NatsCredentials {
207
+ const userKp = createUser();
208
+ const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), "pwa-pairing", {
209
+ pub: { allow: ["pair.*"] },
210
+ sub: { allow: [] },
211
+ });
212
+ const nkeySeed = seedToString(userKp.getSeed());
213
+ userKp.clear();
214
+ return { jwt, nkeySeed };
215
+ }
216
+
217
+ /**
218
+ * Create host-scoped credentials for a PWA client after pairing.
219
+ *
220
+ * Scoped to a single host:
221
+ * - Publish to: host.{hostId}.rpc.> (send RPC to paired host)
222
+ * - Subscribe to: host-event.{hostId}.> (receive task events from paired host)
223
+ *
224
+ * A PWA client cannot see events or send RPC to any other host.
225
+ */
226
+ export function createPwaCredentials(accountSeed: string, hostId: string): NatsCredentials {
227
+ const userKp = createUser();
228
+ const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), `pwa-${hostId}`, {
229
+ pub: { allow: [`host.${hostId}.rpc.>`] },
230
+ sub: { allow: [`host-event.${hostId}.>`] },
231
+ });
232
+ const nkeySeed = seedToString(userKp.getSeed());
233
+ userKp.clear();
234
+ return { jwt, nkeySeed };
235
+ }
236
+
237
+ /**
238
+ * Create credentials for the palmier server itself (full access for relaying).
239
+ */
240
+ export function createServerCredentials(accountSeed: string): NatsCredentials {
241
+ const userKp = createUser();
242
+ const jwt = createUserJwt(accountSeed, userKp.getPublicKey(), "server", {
243
+ pub: { allow: [">"] },
244
+ sub: { allow: [">"] },
245
+ });
246
+ const nkeySeed = seedToString(userKp.getSeed());
247
+ userKp.clear();
248
+ return { jwt, nkeySeed };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Setup: generate operator + account keys and NATS config
253
+ // ---------------------------------------------------------------------------
254
+
255
+ export interface NatsSetupResult {
256
+ operatorSeed: string;
257
+ operatorPublicKey: string;
258
+ accountSeed: string;
259
+ accountPublicKey: string;
260
+ operatorJwt: string;
261
+ accountJwt: string;
262
+ natsConfig: string;
263
+ }
264
+
265
+ /**
266
+ * Generate all keys and the NATS server config snippet for JWT auth.
267
+ * Run once during initial deployment, then store seeds as env vars.
268
+ *
269
+ * The `operator` field in nats.conf requires a full JWT (not a public key) —
270
+ * NATS treats non-JWT strings as file paths.
271
+ */
272
+ export function generateNatsSetup(): NatsSetupResult {
273
+ const operatorKp = createOperator();
274
+ const accountKp = createAccount();
275
+
276
+ const operatorSeed = seedToString(operatorKp.getSeed());
277
+ const operatorPublicKey = operatorKp.getPublicKey();
278
+ const accountSeed = seedToString(accountKp.getSeed());
279
+ const accountPublicKey = accountKp.getPublicKey();
280
+
281
+ const operatorJwt = createOperatorJwt(operatorSeed);
282
+ const accountJwt = createAccountJwt(operatorSeed, accountPublicKey);
283
+
284
+ const natsConfig = [
285
+ `# NATS JWT auth configuration`,
286
+ `# Generated by palmier nats-setup`,
287
+ ``,
288
+ `operator: ${operatorJwt}`,
289
+ `resolver: MEMORY`,
290
+ `resolver_preload: {`,
291
+ ` ${accountPublicKey}: ${accountJwt}`,
292
+ `}`,
293
+ ].join("\n");
294
+
295
+ operatorKp.clear();
296
+ accountKp.clear();
297
+
298
+ return { operatorSeed, operatorPublicKey, accountSeed, accountPublicKey, operatorJwt, accountJwt, natsConfig };
299
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One-time setup script for NATS JWT authentication.
3
+ *
4
+ * Run this once when deploying Palmier for the first time (or when rotating keys).
5
+ * It generates the cryptographic keys and config needed for NATS to enforce
6
+ * per-host subject isolation.
7
+ *
8
+ * Usage:
9
+ * cd server && pnpm nats-setup
10
+ *
11
+ * What it generates:
12
+ *
13
+ * Operator NKey pair
14
+ * └─ Signs the Account JWT (one-time). Store the seed securely as a backup —
15
+ * you only need it again if you regenerate the account JWT.
16
+ *
17
+ * Account NKey pair
18
+ * └─ Signs User JWTs at runtime. The server uses NATS_ACCOUNT_SEED to create
19
+ * scoped credentials for each host (during registration) and each PWA session.
20
+ *
21
+ * Account JWT
22
+ * └─ Embedded in the NATS server config (resolver_preload). Tells the NATS server
23
+ * to trust User JWTs signed by this account key.
24
+ *
25
+ * NATS config snippet
26
+ * └─ Drop-in replacement for the authorization block in nats.conf.
27
+ *
28
+ * See PRODUCTION.md for the full deployment flow.
29
+ */
30
+
31
+ import { generateNatsSetup } from "./nats-jwt.js";
32
+
33
+ const setup = generateNatsSetup();
34
+
35
+ console.log("=== NATS JWT Auth Setup ===\n");
36
+
37
+ console.log("NATS_ACCOUNT_SEED (add to server/.env):\n");
38
+ console.log(` ${setup.accountSeed}\n`);
39
+
40
+ console.log("nats.conf auth section (replace any existing auth block):\n");
41
+ console.log(setup.natsConfig);
42
+ console.log();
43
+
44
+ console.log("Keys (store securely, do not commit):\n");
45
+ console.log(` Operator seed: ${setup.operatorSeed}`);
46
+ console.log(` Operator public key: ${setup.operatorPublicKey}`);
47
+ console.log(` Account seed: ${setup.accountSeed}`);
48
+ console.log(` Account public key: ${setup.accountPublicKey}`);
@@ -1,4 +1,5 @@
1
- import { connect, NatsConnection } from "nats";
1
+ import { connect, jwtAuthenticator, NatsConnection } from "nats";
2
+ import { createServerCredentials, seedFromString } from "./nats-jwt.js";
2
3
 
3
4
  let nc: NatsConnection | null = null;
4
5
 
@@ -6,14 +7,21 @@ export async function connectNats(): Promise<NatsConnection> {
6
7
  if (nc) return nc;
7
8
 
8
9
  const url = process.env.NATS_URL || "nats://localhost:4222";
9
- const token = process.env.NATS_TOKEN;
10
+ const accountSeed = process.env.NATS_ACCOUNT_SEED;
11
+
12
+ if (!accountSeed) {
13
+ throw new Error("NATS_ACCOUNT_SEED is required for JWT auth");
14
+ }
15
+
16
+ // Generate server credentials with full access
17
+ const creds = createServerCredentials(accountSeed);
10
18
 
11
19
  nc = await connect({
12
20
  servers: url,
13
- ...(token ? { token } : {}),
21
+ authenticator: jwtAuthenticator(creds.jwt, seedFromString(creds.nkeySeed)),
14
22
  });
15
23
 
16
- console.log(`Connected to NATS at ${url}`);
24
+ console.log(`Connected to NATS at ${url} (JWT auth)`);
17
25
  return nc;
18
26
  }
19
27
 
@@ -197,4 +197,28 @@ router.post("/ringer-response", async (req: Request, res: Response) => {
197
197
  }
198
198
  });
199
199
 
200
+ // POST /api/device/email-response - Receive email response from Android, relay to host via NATS
201
+ router.post("/email-response", async (req: Request, res: Response) => {
202
+ try {
203
+ const { requestId, hostId, result } = req.body;
204
+
205
+ if (!requestId || !hostId) {
206
+ res.status(400).json({ error: "requestId and hostId are required" });
207
+ return;
208
+ }
209
+
210
+ const conn = await getNatsConnection();
211
+ const sc = StringCodec();
212
+ conn.publish(
213
+ `host.${hostId}.email.${requestId}`,
214
+ sc.encode(JSON.stringify(result)),
215
+ );
216
+
217
+ res.json({ ok: true });
218
+ } catch (err) {
219
+ console.error("Device email response relay error:", err);
220
+ res.status(500).json({ error: "Internal server error" });
221
+ }
222
+ });
223
+
200
224
  export default router;
@@ -1,6 +1,7 @@
1
1
  import { Router, Request, Response } from "express";
2
2
  import type { Router as RouterType } from "express";
3
3
  import { pool } from "../db.js";
4
+ import { createHostCredentials } from "../nats-jwt.js";
4
5
 
5
6
  const router: RouterType = Router();
6
7
 
@@ -9,6 +10,13 @@ const router: RouterType = Router();
9
10
  // so re-initializing a host doesn't create a duplicate entry.
10
11
  router.post("/register", async (req: Request, res: Response) => {
11
12
  try {
13
+ const accountSeed = process.env.NATS_ACCOUNT_SEED;
14
+ if (!accountSeed) {
15
+ console.error("NATS_ACCOUNT_SEED not configured");
16
+ res.status(500).json({ error: "Server NATS auth not configured" });
17
+ return;
18
+ }
19
+
12
20
  const requestedId: string | undefined = req.body?.hostId;
13
21
 
14
22
  let hostId: string;
@@ -26,15 +34,18 @@ router.post("/register", async (req: Request, res: Response) => {
26
34
  hostId = result.rows[0].id;
27
35
  }
28
36
 
37
+ // Generate per-host NATS credentials (scoped to this host's subjects)
38
+ const creds = createHostCredentials(accountSeed, hostId);
39
+
29
40
  const natsUrl = process.env.NATS_HOST_URL || process.env.NATS_URL || "nats://localhost:4222";
30
41
  const natsWsUrl = process.env.NATS_WS_URL || "";
31
- const natsToken = process.env.NATS_TOKEN || "";
32
42
 
33
43
  res.status(201).json({
34
44
  hostId,
35
45
  natsUrl,
36
46
  natsWsUrl,
37
- natsToken,
47
+ natsJwt: creds.jwt,
48
+ natsNkeySeed: creds.nkeySeed,
38
49
  });
39
50
  } catch (err) {
40
51
  console.error("Register host error:", err);
@@ -59,9 +59,9 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
59
59
  `palmier init` is an interactive wizard that:
60
60
 
61
61
  1. Detects installed agent CLIs.
62
- 2. Asks whether to enable LAN access and which HTTP port to use (default 9966).
62
+ 2. Asks whether to enable LAN access and which HTTP port to use (default 7256).
63
63
  3. Shows a summary of task storage directory, local access URL, LAN URL (if enabled), detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
64
- 4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsToken }`.
64
+ 4. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
65
65
  5. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, `lanEnabled`, NATS credentials).
66
66
  6. Installs a systemd user service (Linux) or Task Scheduler entry (Windows) and auto-enters pair mode.
67
67
 
@@ -116,7 +116,8 @@ The **RPC method is derived from the NATS subject**, not the message body. The h
116
116
 
117
117
  | Method | Params | Description |
118
118
  |---|---|---|
119
- | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. Returns `agents` array of detected CLIs, `host_platform`, and `version`. |
119
+ | `host.info` | *(none)* | Bootstrap metadata fetched once per connection. Returns `{ agents, version, host_platform, capability_tokens, pending_prompts }`. `pending_prompts` is an array of prompts already waiting when the PWA reconnects (each `{ key, type, params?, meta? }`), so modals can render without replaying events. |
120
+ | `task.list` | *(none)* | List all tasks with frontmatter, created_at, and current status. |
120
121
  | `task.get` | `id` | Get a single task with frontmatter and current status. |
121
122
  | `task.create` | `user_prompt`, `agent`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Create a new task with auto-generated name (30s timeout for prompts > 50 chars), install system timers if triggers present. |
122
123
  | `task.update` | `id`, `user_prompt?`, `agent?`, `triggers?`, `triggers_enabled?`, `requires_confirmation?`, `yolo_mode?`, `foreground_mode?`, `command?` | Update an existing task. Regenerates name if `user_prompt` or `agent` changed. Reinstall timers as needed. |
@@ -273,21 +274,32 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
273
274
 
274
275
  1. PWA loads. If no hosts are paired, it shows an empty state with a "Pair Host" button.
275
276
 
276
- 2. If hosts are paired, PWA fetches NATS credentials from `GET /api/config` (returns `{ natsWsUrl, natsToken }`) and connects to NATS via WebSocket.
277
+ 2. If hosts are paired, PWA fetches host-scoped NATS credentials from `GET /api/nats-credentials/<hostId>` (returns `{ natsWsUrl, natsJwt, natsNkeySeed }`) and connects to NATS via WebSocket using JWT auth. The credentials are scoped to the paired host's subjects only.
277
278
 
278
- 3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload.
279
+ 3. PWA sends a `task.list` request to `host.<host_id>.rpc.task.list` using NATS request-reply, including the `clientToken` in the payload. The PWA uses the response for bootstrap metadata (agents, host platform, version, capability tokens) that both the Sessions tab's composer and the Tasks tab depend on.
279
280
 
280
- 4. If the host responds, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level) and displays the task list. If the request fails with NATS 503 ("no responders"), the PWA shows an empty task list — this is not treated as an error.
281
+ 4. PWA lands on the Sessions tab and fetches run history via `taskrun.list`. If the host responds to `task.list`, it returns `{ tasks: [...] }` — an array of **flat task objects** (frontmatter fields spread to the top level) used by the Tasks tab. If the request fails with NATS 503 ("no responders"), the PWA shows an empty state — this is not treated as an error.
281
282
 
282
283
  5. PWA registers the service worker and subscribes the browser for Web Push notifications (via `pushManager.subscribe` with the server's VAPID public key). The push subscription is sent to `POST /api/push/subscribe` with the `hostId` so the server can relay notifications to the device.
283
284
 
284
285
  6. PWA discovers pending confirmations from the `task.list` RPC response — tasks with a pending confirmation, permission, or input request are shown as interactive modals. The PWA responds by calling the `task.user_input` RPC on the host, which resolves the in-memory pending request held by the serve daemon. The `run` process (blocked on an HTTP call to the serve daemon) receives the response and proceeds or exits accordingly.
285
286
 
286
- ### 4.2 Task Creation & Update
287
+ ### 4.2 UI Layout: Sessions & Tasks Tabs
287
288
 
288
- 1. User clicks the "Describe your new task..." placeholder in the task list view, which opens the task form directly.
289
+ The PWA has two tabs: **Sessions** (default, at `/`) and **Tasks** (secondary, at `/tasks`). Sessions is the primary workflow it lists all run history across tasks (a "session" is a single run) and includes a session composer at the top of the list.
289
290
 
290
- 2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Create" (or "Update" for existing tasks).
291
+ * **Session composer:** An inline textarea with an agent picker, a yolo-mode toggle, and a round play button. Entering text and clicking play dispatches `task.run_oneoff`, starting an immediate unsaved session. The composer never opens a dialog; typing is direct. When the textarea has content, navigating away (tab switch, host switch, browser reload, clicking a session row) triggers a confirmation dialog so the draft isn't lost silently.
292
+ * **Tasks tab:** Lists saved tasks (scheduled or reusable). The "Describe your new task..." card opens the full task form, which is used only to create/edit saved or scheduled tasks — it has no Run button (run one-offs via the session composer instead). The form's primary action is "Save" (no schedule) or "Schedule" (when triggers are configured).
293
+
294
+ Bootstrap data (agents, host version, host platform, capability tokens) is fetched once per connection at the Dashboard level via `host.info` — independent of which tab is active. `task.list` is called lazily on the Tasks tab mount; `taskrun.list` is called lazily on the Sessions tab mount. Neither list RPC carries bootstrap metadata.
295
+
296
+ Dashboard owns the always-on NATS event subscription and renders pending `confirm-request` / `permission-request` / `input-request` modals via React portal, so prompts surface regardless of which tab is active. Initial pending prompts (those already open when the PWA connects) are seeded from `host.info`'s `pending_prompts` field — each entry carries the display context (`description`, `agent_name`, `task_name`) needed to render the modal cold, since the task list is no longer available at bootstrap.
297
+
298
+ ### 4.3 Task Creation & Update
299
+
300
+ 1. User clicks the "Describe your new task..." placeholder in the Tasks tab, which opens the task form directly.
301
+
302
+ 2. User enters a prompt, selects an agent, configures triggers (UI translates human-readable times to cron formats) and confirmation settings, and clicks "Save" (no schedule) or "Schedule" (with triggers).
291
303
 
292
304
  3. PWA sends `task.create` (or `task.update`) via NATS request-reply to Host (45s timeout). For prompts > 50 chars, the host generates a concise task name by running the configured agent CLI in non-interactive mode (e.g., `claude -p "Generate a concise 3-6 word name for this task..."`). For shorter prompts, the prompt is used directly as the name.
293
305
 
@@ -299,16 +311,17 @@ The PWA connects to **one host at a time**. A host menu (hamburger drawer) lets
299
311
 
300
312
  7. **OS Integration:** Host translates triggers into a systemd user timer (`~/.config/systemd/user/palmier-task-<task-id>.timer` and `.service`). The `.service` runs `palmier run <task-id>`, which executes the task as a background process. Host runs `systemctl --user daemon-reload` and enables the timer.
301
313
 
302
- ### 4.3 On-Demand Execution
314
+ ### 4.4 On-Demand Execution
303
315
 
304
316
  Any task that is not currently running can be executed immediately:
305
317
 
306
- * **PWA:** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
318
+ * **PWA (saved task):** A "Run Now" button is shown on each task card when the task is not already running. Clicking it sends a `task.run` request via NATS request-reply to the Host, which starts execution via the system scheduler (`systemctl --user start` on Linux, `schtasks /run` on Windows).
319
+ * **PWA (one-off session):** The session composer on the Sessions tab sends `task.run_oneoff` with `{ user_prompt, agent, yolo_mode }`. The host creates an ephemeral task, runs it, and returns `{ task_id, run_id }`; the PWA navigates to the run detail view.
307
320
  * **CLI:** `palmier run <task-id>` executes the task directly (outside the system scheduler).
308
321
 
309
322
  Both paths follow the same execution loop described in §5.2 (including confirmation checks if configured). The system scheduler prevents concurrent runs of the same task — if the service/task is already active, the start command is a no-op.
310
323
 
311
- ### 4.4 Task Deletion
324
+ ### 4.5 Task Deletion
312
325
 
313
326
  1. PWA sends `task.delete` via NATS request-reply.
314
327
 
@@ -430,8 +443,9 @@ All endpoints are served over HTTPS. No user authentication is required — the
430
443
 
431
444
  | Method | Path | Description |
432
445
  |--------|------|-------------|
433
- | `POST` | `/api/hosts/register` | Register a new host. Returns `{ hostId, natsUrl, natsWsUrl, natsToken }`. Rate-limited by IP. |
434
- | `GET` | `/api/config` | Returns NATS WebSocket credentials for the PWA: `{ natsWsUrl, natsToken }`. |
446
+ | `POST` | `/api/hosts/register` | Register a new host. Returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }` with host-scoped NATS credentials. |
447
+ | `GET` | `/api/config` | Returns pairing-only NATS credentials: `{ natsWsUrl, natsJwt, natsNkeySeed }`. JWT can only publish to `pair.*`. |
448
+ | `GET` | `/api/nats-credentials/:hostId` | Returns host-scoped NATS credentials for PWA: `{ natsWsUrl, natsJwt, natsNkeySeed }`. JWT scoped to one host's RPC + events. |
435
449
  | `POST` | `/api/push/subscribe` | Register a push subscription. Body: `{ hostId, endpoint, keys: { p256dh, auth } }`. |
436
450
  | `DELETE` | `/api/push/subscribe` | Unregister a push subscription. Body: `{ hostId, endpoint }`. |
437
451
  | `GET` | `/api/push/vapid-key` | Returns the server's VAPID public key for push subscription. |
@@ -44,6 +44,9 @@ export interface AgentTool {
44
44
  * If false, the permissions section is omitted from agent instructions. */
45
45
  supportsPermissions: boolean;
46
46
 
47
+ /** Whether this agent supports yolo mode (auto-approve all tools). */
48
+ supportsYolo: boolean;
49
+
47
50
  /** Detect whether the agent CLI is available and perform any agent-specific
48
51
  * initialization. Returns true if the agent was detected and initialized successfully. */
49
52
  init(): Promise<boolean>;
@@ -93,6 +96,7 @@ export interface DetectedAgent {
93
96
  key: string;
94
97
  label: string;
95
98
  supportsPermissions: boolean;
99
+ supportsYolo: boolean;
96
100
  }
97
101
 
98
102
  export async function detectAgents(): Promise<DetectedAgent[]> {
@@ -100,7 +104,7 @@ export async function detectAgents(): Promise<DetectedAgent[]> {
100
104
  for (const [key, agent] of Object.entries(agentRegistry)) {
101
105
  const label = agentLabels[key] ?? key;
102
106
  const ok = await agent.init();
103
- if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions });
107
+ if (ok) detected.push({ key, label, supportsPermissions: agent.supportsPermissions, supportsYolo: agent.supportsYolo });
104
108
  }
105
109
  return detected;
106
110
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Aider implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "aider", args: ["--message", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class ClaudeAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "claude", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Cline implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "cline ", args: ["--yolo", "-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CodexAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "codex", args: ["exec", "--skip-git-repo-check", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class CopilotAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "copilot", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class Cursor implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "cursor", args: ["-p", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class DeepAgents implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "deepagents", args: ["--non-interactive", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class DroidAgent implements AgentTool {
8
8
  supportsPermissions = false;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "droid", args: ["exec", prompt] };
11
12
  }
@@ -6,6 +6,7 @@ import { SHELL } from "../platform/index.js";
6
6
 
7
7
  export class GeminiAgent implements AgentTool {
8
8
  supportsPermissions = true;
9
+ supportsYolo = true;
9
10
  getPromptCommandLine(prompt: string): CommandLine {
10
11
  return { command: "gemini", args: ["--prompt", prompt] };
11
12
  }