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.
- package/dist/agents/agent.d.ts +3 -0
- package/dist/agents/agent.js +1 -1
- package/dist/agents/aider.d.ts +1 -0
- package/dist/agents/aider.js +1 -0
- package/dist/agents/claude.d.ts +1 -0
- package/dist/agents/claude.js +1 -0
- package/dist/agents/cline.d.ts +1 -0
- package/dist/agents/cline.js +1 -0
- package/dist/agents/codex.d.ts +1 -0
- package/dist/agents/codex.js +1 -0
- package/dist/agents/copilot.d.ts +1 -0
- package/dist/agents/copilot.js +1 -0
- package/dist/agents/cursor.d.ts +1 -0
- package/dist/agents/cursor.js +1 -0
- package/dist/agents/deepagents.d.ts +1 -0
- package/dist/agents/deepagents.js +1 -0
- package/dist/agents/droid.d.ts +1 -0
- package/dist/agents/droid.js +1 -0
- package/dist/agents/gemini.d.ts +1 -0
- package/dist/agents/gemini.js +1 -0
- package/dist/agents/goose.d.ts +1 -0
- package/dist/agents/goose.js +1 -0
- package/dist/agents/hermes.d.ts +1 -0
- package/dist/agents/hermes.js +1 -0
- package/dist/agents/kimi.d.ts +1 -0
- package/dist/agents/kimi.js +1 -0
- package/dist/agents/kiro.d.ts +1 -0
- package/dist/agents/kiro.js +1 -0
- package/dist/agents/openclaw.d.ts +1 -0
- package/dist/agents/openclaw.js +2 -2
- package/dist/agents/opencode.d.ts +1 -0
- package/dist/agents/opencode.js +1 -0
- package/dist/agents/qoder.d.ts +1 -0
- package/dist/agents/qoder.js +1 -0
- package/dist/agents/qwen.d.ts +1 -0
- package/dist/agents/qwen.js +1 -0
- package/dist/agents/shared-prompt.js +1 -1
- package/dist/commands/init.js +3 -2
- package/dist/commands/pair.js +3 -3
- package/dist/commands/run.js +4 -4
- package/dist/commands/serve.js +1 -1
- package/dist/config.js +2 -2
- package/dist/device-capabilities.d.ts +1 -1
- package/dist/events.js +1 -1
- package/dist/mcp-tools.js +79 -7
- package/dist/nats-client.d.ts +1 -1
- package/dist/nats-client.js +6 -3
- package/dist/pending-requests.d.ts +30 -8
- package/dist/pending-requests.js +28 -15
- package/dist/pwa/assets/index-8cTctVnD.js +120 -0
- package/dist/pwa/assets/index-CSUkBBsQ.css +1 -0
- package/dist/pwa/assets/{web-DnuoxUd4.js → web-BNr628AV.js} +1 -1
- package/dist/pwa/assets/{web-7raT3zOZ.js → web-DyQPewAi.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +12 -16
- package/dist/transports/http-transport.js +6 -3
- package/dist/types.d.ts +4 -1
- package/package.json +1 -1
- package/palmier-server/PRODUCTION.md +31 -28
- package/palmier-server/README.md +35 -5
- package/palmier-server/nats.conf +9 -5
- package/palmier-server/package.json +2 -1
- package/palmier-server/pnpm-lock.yaml +6 -0
- package/palmier-server/pwa/src/App.css +66 -0
- package/palmier-server/pwa/src/App.tsx +1 -0
- package/palmier-server/pwa/src/components/HostMenu.tsx +65 -2
- package/palmier-server/pwa/src/components/RunsView.tsx +48 -22
- package/palmier-server/pwa/src/components/SessionComposer.tsx +137 -0
- package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
- package/palmier-server/pwa/src/components/TaskForm.tsx +11 -66
- package/palmier-server/pwa/src/components/TaskListView.tsx +17 -283
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +9 -5
- package/palmier-server/pwa/src/draftGuard.ts +24 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +335 -12
- package/palmier-server/pwa/src/pages/PairHost.tsx +6 -3
- package/palmier-server/pwa/src/types.ts +1 -6
- package/palmier-server/server/package.json +3 -1
- package/palmier-server/server/src/index.ts +83 -2
- package/palmier-server/server/src/nats-jwt.ts +299 -0
- package/palmier-server/server/src/nats-setup.ts +48 -0
- package/palmier-server/server/src/nats.ts +12 -4
- package/palmier-server/server/src/routes/device.ts +24 -0
- package/palmier-server/server/src/routes/hosts.ts +13 -2
- package/palmier-server/spec.md +28 -14
- package/src/agents/agent.ts +5 -1
- package/src/agents/aider.ts +1 -0
- package/src/agents/claude.ts +1 -0
- package/src/agents/cline.ts +1 -0
- package/src/agents/codex.ts +1 -0
- package/src/agents/copilot.ts +1 -0
- package/src/agents/cursor.ts +1 -0
- package/src/agents/deepagents.ts +1 -0
- package/src/agents/droid.ts +1 -0
- package/src/agents/gemini.ts +1 -0
- package/src/agents/goose.ts +1 -0
- package/src/agents/hermes.ts +1 -0
- package/src/agents/kimi.ts +1 -0
- package/src/agents/kiro.ts +1 -0
- package/src/agents/openclaw.ts +2 -2
- package/src/agents/opencode.ts +1 -0
- package/src/agents/qoder.ts +1 -0
- package/src/agents/qwen.ts +1 -0
- package/src/agents/shared-prompt.ts +1 -1
- package/src/commands/init.ts +7 -5
- package/src/commands/pair.ts +3 -3
- package/src/commands/run.ts +4 -4
- package/src/commands/serve.ts +1 -1
- package/src/config.ts +2 -2
- package/src/device-capabilities.ts +1 -0
- package/src/events.ts +1 -1
- package/src/mcp-tools.ts +83 -7
- package/src/nats-client.ts +10 -3
- package/src/pending-requests.ts +47 -15
- package/src/rpc-handler.ts +13 -16
- package/src/transports/http-transport.ts +6 -3
- package/src/types.ts +4 -3
- package/test/agent-instructions.test.ts +10 -10
- package/test/pairing.test.ts +2 -2
- package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
natsJwt: creds.jwt,
|
|
48
|
+
natsNkeySeed: creds.nkeySeed,
|
|
38
49
|
});
|
|
39
50
|
} catch (err) {
|
|
40
51
|
console.error("Register host error:", err);
|
package/palmier-server/spec.md
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
| `
|
|
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/
|
|
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
|
|
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
|
|
287
|
+
### 4.2 UI Layout: Sessions & Tasks Tabs
|
|
287
288
|
|
|
288
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
434
|
-
| `GET` | `/api/config` | Returns NATS
|
|
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. |
|
package/src/agents/agent.ts
CHANGED
|
@@ -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
|
}
|
package/src/agents/aider.ts
CHANGED
package/src/agents/claude.ts
CHANGED
package/src/agents/cline.ts
CHANGED
|
@@ -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
|
}
|
package/src/agents/codex.ts
CHANGED
|
@@ -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
|
}
|
package/src/agents/copilot.ts
CHANGED
|
@@ -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
|
}
|
package/src/agents/cursor.ts
CHANGED
package/src/agents/deepagents.ts
CHANGED
|
@@ -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
|
}
|
package/src/agents/droid.ts
CHANGED
package/src/agents/gemini.ts
CHANGED
|
@@ -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
|
}
|