palmier 0.9.36 → 1.0.1
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 +24 -0
- package/dist/commands/passwords.d.ts +3 -0
- package/dist/commands/passwords.js +25 -0
- package/dist/index.js +22 -0
- package/dist/mcp-tools.js +68 -1
- package/dist/password-store.d.ts +13 -0
- package/dist/password-store.js +96 -0
- package/dist/pending-requests.d.ts +2 -0
- package/dist/playwright-fill.d.ts +10 -0
- package/dist/playwright-fill.js +41 -0
- package/dist/pwa/assets/{index-B1wpqQXD.js → index-C4CD4xh2.js} +37 -37
- package/dist/pwa/assets/index-DfwUct4A.css +1 -0
- package/dist/pwa/assets/{web-BICLWfuY.js → web-CqKL3jGH.js} +1 -1
- package/dist/pwa/assets/{web-Ct4onsL_.js → web-DNTmjXM5.js} +1 -1
- package/dist/pwa/assets/{web-DZ51_ut2.js → web-ZugiRoEF.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/package.json +1 -1
- package/dist/pwa/assets/index-BJxu7oNc.css +0 -1
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ Palmier exposes an [MCP](https://modelcontextprotocol.io) server at `http://loca
|
|
|
57
57
|
| `notify` | Send a push notification to the user's device |
|
|
58
58
|
| `request-input` | Request input from the user (blocks until response) |
|
|
59
59
|
| `request-confirmation` | Request confirmation from the user (blocks until response) |
|
|
60
|
+
| `fill-password` | Fill a saved password into the active browser session, prompting the user if unknown (the password is never revealed to the agent) |
|
|
60
61
|
| `device-geolocation` | Get GPS location of the user's mobile device |
|
|
61
62
|
| `read-contacts` | Read the contact list from the user's device |
|
|
62
63
|
| `create-contact` | Create a new contact on the user's device |
|
|
@@ -153,6 +154,26 @@ palmier clients revoke-all
|
|
|
153
154
|
|
|
154
155
|
Revoking the linked device also clears the host's linked-device record; device capabilities stop working until another paired device is linked from its drawer.
|
|
155
156
|
|
|
157
|
+
### Saved Passwords
|
|
158
|
+
|
|
159
|
+
Palmier can store website passwords like a browser's password manager so agents can sign in to sites without ever seeing the credentials. When an agent driving a browser (via the `playwright-cli` skill) reaches a login form, it calls the `fill-password` tool with the page URL, the username, and the password field's element ref. Palmier matches the saved password by **origin** and fills it directly into the live browser session. If no password is saved for that `(origin, username)`, Palmier prompts you in the app with a masked password dialog, stores what you enter, and then fills it. The plaintext password is never returned to the agent and is never written to task history.
|
|
160
|
+
|
|
161
|
+
Passwords are encrypted at rest with AES-256-GCM under a host-local key, both stored in `~/.config/palmier/` (`passwords.enc` and `password-key`). Manage them from the host:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# List saved passwords (origin and username only — never the password)
|
|
165
|
+
palmier passwords list
|
|
166
|
+
|
|
167
|
+
# Delete a specific saved password
|
|
168
|
+
palmier passwords delete https://example.com alice
|
|
169
|
+
|
|
170
|
+
# Delete every saved password for an origin
|
|
171
|
+
palmier passwords delete https://example.com
|
|
172
|
+
|
|
173
|
+
# Delete all saved passwords
|
|
174
|
+
palmier passwords clear
|
|
175
|
+
```
|
|
176
|
+
|
|
156
177
|
### The `init` Command
|
|
157
178
|
|
|
158
179
|
The wizard:
|
|
@@ -198,6 +219,9 @@ The default network interface is detected once during `palmier init` and saved t
|
|
|
198
219
|
| `palmier clients list` | List active client tokens |
|
|
199
220
|
| `palmier clients revoke <token>` | Revoke a specific client token |
|
|
200
221
|
| `palmier clients revoke-all` | Revoke all client tokens |
|
|
222
|
+
| `palmier passwords list` | List saved passwords (origin and username only) |
|
|
223
|
+
| `palmier passwords delete <origin> [username]` | Delete a saved password (omit username to delete all for the origin) |
|
|
224
|
+
| `palmier passwords clear` | Delete all saved passwords |
|
|
201
225
|
| `palmier info` | Show host connection info (address, mode) |
|
|
202
226
|
| `palmier serve` | Run the persistent RPC handler (default command) |
|
|
203
227
|
| `palmier restart` | Restart the palmier serve daemon |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { listPasswords, deletePassword, clearPasswords, normalizeOrigin } from "../password-store.js";
|
|
2
|
+
export async function passwordsListCommand() {
|
|
3
|
+
const entries = listPasswords();
|
|
4
|
+
if (entries.length === 0) {
|
|
5
|
+
console.log("No saved passwords.");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
console.log(`${entries.length} saved password(s):\n`);
|
|
9
|
+
for (const e of entries) {
|
|
10
|
+
console.log(` ${e.origin} ${e.username}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function passwordsDeleteCommand(origin, username) {
|
|
14
|
+
if (deletePassword(normalizeOrigin(origin), username)) {
|
|
15
|
+
console.log("Password deleted.");
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.error("No matching saved password.");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function passwordsClearCommand() {
|
|
23
|
+
const count = clearPasswords();
|
|
24
|
+
console.log(`Cleared ${count} saved password(s).`);
|
|
25
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { serveCommand } from "./commands/serve.js";
|
|
|
11
11
|
import { pairCommand } from "./commands/pair.js";
|
|
12
12
|
import { restartCommand } from "./commands/restart.js";
|
|
13
13
|
import { clientsListCommand, clientsRevokeCommand, clientsRevokeAllCommand } from "./commands/clients.js";
|
|
14
|
+
import { passwordsListCommand, passwordsDeleteCommand, passwordsClearCommand } from "./commands/passwords.js";
|
|
14
15
|
import { agentsCommand } from "./commands/agents.js";
|
|
15
16
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
16
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -77,6 +78,27 @@ clientsCmd
|
|
|
77
78
|
.action(async () => {
|
|
78
79
|
await clientsRevokeAllCommand();
|
|
79
80
|
});
|
|
81
|
+
const passwordsCmd = program
|
|
82
|
+
.command("passwords")
|
|
83
|
+
.description("Manage saved passwords");
|
|
84
|
+
passwordsCmd
|
|
85
|
+
.command("list")
|
|
86
|
+
.description("List saved passwords (origin and username only)")
|
|
87
|
+
.action(async () => {
|
|
88
|
+
await passwordsListCommand();
|
|
89
|
+
});
|
|
90
|
+
passwordsCmd
|
|
91
|
+
.command("delete <origin> [username]")
|
|
92
|
+
.description("Delete a saved password (omit username to delete all for the origin)")
|
|
93
|
+
.action(async (origin, username) => {
|
|
94
|
+
await passwordsDeleteCommand(origin, username);
|
|
95
|
+
});
|
|
96
|
+
passwordsCmd
|
|
97
|
+
.command("clear")
|
|
98
|
+
.description("Delete all saved passwords")
|
|
99
|
+
.action(async () => {
|
|
100
|
+
await passwordsClearCommand();
|
|
101
|
+
});
|
|
80
102
|
program
|
|
81
103
|
.command("agents")
|
|
82
104
|
.description("List, install, and uninstall agent CLIs")
|
package/dist/mcp-tools.js
CHANGED
|
@@ -5,6 +5,8 @@ import { getLinkedDevice } from "./linked-device.js";
|
|
|
5
5
|
import { getNotifications, onNotificationsChanged } from "./notification-store.js";
|
|
6
6
|
import { getSmsMessages, onSmsChanged } from "./sms-store.js";
|
|
7
7
|
import { getTaskDir, readHistory, spliceUserMessage } from "./task.js";
|
|
8
|
+
import { lookupPassword, savePassword, normalizeOrigin } from "./password-store.js";
|
|
9
|
+
import { fillPasswordInBrowser } from "./playwright-fill.js";
|
|
8
10
|
export class ToolError extends Error {
|
|
9
11
|
statusCode;
|
|
10
12
|
constructor(message, statusCode = 500) {
|
|
@@ -177,6 +179,71 @@ const requestConfirmationTool = {
|
|
|
177
179
|
return { confirmed };
|
|
178
180
|
},
|
|
179
181
|
};
|
|
182
|
+
const fillPasswordTool = {
|
|
183
|
+
name: "fill-password",
|
|
184
|
+
description: [
|
|
185
|
+
"Fill the user's saved password into a password field in the active playwright-cli browser session.",
|
|
186
|
+
"Use this instead of typing a password yourself — the password is never revealed to you.",
|
|
187
|
+
"Provide the page URL, the username/login identifier, and the playwright-cli ref of the password field (and the session name if you opened a named session).",
|
|
188
|
+
"If a password for this (site, username) is already saved it is filled directly; otherwise the user is prompted to enter it, it is saved, then filled.",
|
|
189
|
+
'Response: `{"ok": true}` on success, or `{"aborted": true}` if the user declines to provide the password.',
|
|
190
|
+
],
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
url: { type: "string", description: "URL of the page with the login form" },
|
|
195
|
+
username: { type: "string", description: "Username / login identifier for this site" },
|
|
196
|
+
ref: { type: "string", description: 'playwright-cli ref of the password field (e.g. "e5")' },
|
|
197
|
+
session: { type: "string", description: "playwright-cli session name (omit for the default session)" },
|
|
198
|
+
},
|
|
199
|
+
required: ["url", "username", "ref"],
|
|
200
|
+
},
|
|
201
|
+
async handler(args, ctx) {
|
|
202
|
+
const { url, username, ref, session } = args;
|
|
203
|
+
if (!url || !username || !ref)
|
|
204
|
+
throw new ToolError("url, username, and ref are required", 400);
|
|
205
|
+
const origin = normalizeOrigin(url);
|
|
206
|
+
let password = lookupPassword(origin, username);
|
|
207
|
+
if (password === undefined) {
|
|
208
|
+
const question = `Password for ${username} on ${origin}`;
|
|
209
|
+
const description = `Enter the password to sign in as ${username} on ${origin}.`;
|
|
210
|
+
const pendingPromise = registerPending(ctx.sessionId, "input", [question], {
|
|
211
|
+
session_id: ctx.sessionId,
|
|
212
|
+
session_name: ctx.agentName,
|
|
213
|
+
description,
|
|
214
|
+
input_questions: [question],
|
|
215
|
+
secret: true,
|
|
216
|
+
});
|
|
217
|
+
await ctx.publishEvent("_input", {
|
|
218
|
+
event_type: "input-request",
|
|
219
|
+
host_id: ctx.config.hostId,
|
|
220
|
+
session_id: ctx.sessionId,
|
|
221
|
+
session_name: ctx.agentName,
|
|
222
|
+
description,
|
|
223
|
+
input_questions: [question],
|
|
224
|
+
secret: true,
|
|
225
|
+
});
|
|
226
|
+
const response = await pendingPromise;
|
|
227
|
+
const aborted = response.length === 1 && response[0] === "aborted";
|
|
228
|
+
await ctx.publishEvent("_input", {
|
|
229
|
+
event_type: "input-resolved", host_id: ctx.config.hostId,
|
|
230
|
+
session_id: ctx.sessionId, status: aborted ? "aborted" : "provided",
|
|
231
|
+
});
|
|
232
|
+
// Never record the secret to the task run file (unlike request-input).
|
|
233
|
+
if (aborted)
|
|
234
|
+
return { aborted: true };
|
|
235
|
+
password = response[0];
|
|
236
|
+
savePassword(origin, username, password);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
await fillPasswordInBrowser(ref, password, session);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
throw new ToolError(`Failed to fill password: ${err instanceof Error ? err.message : String(err)}`, 502);
|
|
243
|
+
}
|
|
244
|
+
return { ok: true };
|
|
245
|
+
},
|
|
246
|
+
};
|
|
180
247
|
const deviceGeolocationTool = {
|
|
181
248
|
name: "device-geolocation",
|
|
182
249
|
description: [
|
|
@@ -677,7 +744,7 @@ const sendEmailTool = {
|
|
|
677
744
|
return result;
|
|
678
745
|
},
|
|
679
746
|
};
|
|
680
|
-
export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
|
|
747
|
+
export const agentTools = [notifyTool, requestInputTool, requestConfirmationTool, fillPasswordTool, deviceGeolocationTool, readContactsTool, createContactTool, readCalendarTool, createCalendarEventTool, sendSmsTool, sendEmailTool, sendAlarmTool, readBatteryTool, setRingerModeTool];
|
|
681
748
|
export const agentToolMap = new Map(agentTools.map((t) => [t.name, t]));
|
|
682
749
|
const deviceNotificationsResource = {
|
|
683
750
|
uri: "notifications://device",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A stored credential without its secret — safe to display and log. */
|
|
2
|
+
export interface PasswordIdentity {
|
|
3
|
+
origin: string;
|
|
4
|
+
username: string;
|
|
5
|
+
}
|
|
6
|
+
/** Collapse a login URL to its origin so all paths on a site share one entry. */
|
|
7
|
+
export declare function normalizeOrigin(url: string): string;
|
|
8
|
+
export declare function lookupPassword(origin: string, username: string): string | undefined;
|
|
9
|
+
export declare function savePassword(origin: string, username: string, password: string): void;
|
|
10
|
+
export declare function listPasswords(): PasswordIdentity[];
|
|
11
|
+
/** Delete one (origin, username) entry, or every entry for an origin when username is omitted. */
|
|
12
|
+
export declare function deletePassword(origin: string, username?: string): boolean;
|
|
13
|
+
export declare function clearPasswords(): number;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
4
|
+
import { CONFIG_DIR } from "./config.js";
|
|
5
|
+
const PASSWORDS_FILE = path.join(CONFIG_DIR, "passwords.enc");
|
|
6
|
+
const KEY_FILE = path.join(CONFIG_DIR, "password-key");
|
|
7
|
+
const ALGORITHM = "aes-256-gcm";
|
|
8
|
+
const KEY_LENGTH = 32;
|
|
9
|
+
const IV_LENGTH = 12;
|
|
10
|
+
const TAG_LENGTH = 16;
|
|
11
|
+
function getOrCreateKey() {
|
|
12
|
+
try {
|
|
13
|
+
const existing = fs.readFileSync(KEY_FILE);
|
|
14
|
+
if (existing.length === KEY_LENGTH)
|
|
15
|
+
return existing;
|
|
16
|
+
}
|
|
17
|
+
catch { /* fall through to create */ }
|
|
18
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
|
+
const key = randomBytes(KEY_LENGTH);
|
|
20
|
+
fs.writeFileSync(KEY_FILE, key, { mode: 0o600 });
|
|
21
|
+
return key;
|
|
22
|
+
}
|
|
23
|
+
function encrypt(plaintext) {
|
|
24
|
+
const iv = randomBytes(IV_LENGTH);
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, getOrCreateKey(), iv);
|
|
26
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
27
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
28
|
+
}
|
|
29
|
+
function decrypt(blob) {
|
|
30
|
+
const iv = blob.subarray(0, IV_LENGTH);
|
|
31
|
+
const tag = blob.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
32
|
+
const ciphertext = blob.subarray(IV_LENGTH + TAG_LENGTH);
|
|
33
|
+
const decipher = createDecipheriv(ALGORITHM, getOrCreateKey(), iv);
|
|
34
|
+
decipher.setAuthTag(tag);
|
|
35
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
|
|
36
|
+
}
|
|
37
|
+
function readFile() {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(PASSWORDS_FILE))
|
|
40
|
+
return [];
|
|
41
|
+
const blob = fs.readFileSync(PASSWORDS_FILE);
|
|
42
|
+
if (blob.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
return JSON.parse(decrypt(blob));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeFile(entries) {
|
|
51
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
|
+
fs.writeFileSync(PASSWORDS_FILE, encrypt(JSON.stringify(entries)), { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
/** Collapse a login URL to its origin so all paths on a site share one entry. */
|
|
55
|
+
export function normalizeOrigin(url) {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(url).origin;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(`https://${url}`).origin;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return url;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function lookupPassword(origin, username) {
|
|
69
|
+
return readFile().find((e) => e.origin === origin && e.username === username)?.password;
|
|
70
|
+
}
|
|
71
|
+
export function savePassword(origin, username, password) {
|
|
72
|
+
const entries = readFile();
|
|
73
|
+
const existing = entries.find((e) => e.origin === origin && e.username === username);
|
|
74
|
+
if (existing)
|
|
75
|
+
existing.password = password;
|
|
76
|
+
else
|
|
77
|
+
entries.push({ origin, username, password });
|
|
78
|
+
writeFile(entries);
|
|
79
|
+
}
|
|
80
|
+
export function listPasswords() {
|
|
81
|
+
return readFile().map(({ origin, username }) => ({ origin, username }));
|
|
82
|
+
}
|
|
83
|
+
/** Delete one (origin, username) entry, or every entry for an origin when username is omitted. */
|
|
84
|
+
export function deletePassword(origin, username) {
|
|
85
|
+
const entries = readFile();
|
|
86
|
+
const remaining = entries.filter((e) => username === undefined ? e.origin !== origin : !(e.origin === origin && e.username === username));
|
|
87
|
+
if (remaining.length === entries.length)
|
|
88
|
+
return false;
|
|
89
|
+
writeFile(remaining);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
export function clearPasswords() {
|
|
93
|
+
const entries = readFile();
|
|
94
|
+
writeFile([]);
|
|
95
|
+
return entries.length;
|
|
96
|
+
}
|
|
@@ -7,6 +7,8 @@ export interface PendingRequestMeta {
|
|
|
7
7
|
session_name?: string;
|
|
8
8
|
description?: string;
|
|
9
9
|
input_questions?: string[];
|
|
10
|
+
/** Render the input field(s) masked (password entry). */
|
|
11
|
+
secret?: boolean;
|
|
10
12
|
}
|
|
11
13
|
export interface PendingRequest {
|
|
12
14
|
type: "confirmation" | "permission" | "input";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill `password` into the `ref` element of the agent's live playwright-cli
|
|
3
|
+
* browser session. playwright-cli keeps a persistent server-side browser keyed
|
|
4
|
+
* by session name, so this lands on the same page the agent is driving.
|
|
5
|
+
*
|
|
6
|
+
* The password is passed as an argv element (no shell, so no history/injection),
|
|
7
|
+
* which leaves it briefly visible to other processes of the same user;
|
|
8
|
+
* playwright-cli's `fill` exposes no stdin/env input for the value.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fillPasswordInBrowser(ref: string, password: string, session?: string): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawnCommand } from "./spawn-command.js";
|
|
2
|
+
let resolved;
|
|
3
|
+
/**
|
|
4
|
+
* Prefer the global `playwright-cli`; fall back to the local `npx` shim when it
|
|
5
|
+
* isn't on PATH. Probed once and cached for the daemon's lifetime.
|
|
6
|
+
*/
|
|
7
|
+
async function resolvePlaywrightCli() {
|
|
8
|
+
if (resolved)
|
|
9
|
+
return resolved;
|
|
10
|
+
try {
|
|
11
|
+
await spawnCommand("playwright-cli", ["--version"], { cwd: process.cwd() });
|
|
12
|
+
resolved = { command: "playwright-cli", prefixArgs: [] };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
resolved = { command: "npx", prefixArgs: ["--no-install", "playwright-cli"] };
|
|
16
|
+
}
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Fill `password` into the `ref` element of the agent's live playwright-cli
|
|
21
|
+
* browser session. playwright-cli keeps a persistent server-side browser keyed
|
|
22
|
+
* by session name, so this lands on the same page the agent is driving.
|
|
23
|
+
*
|
|
24
|
+
* The password is passed as an argv element (no shell, so no history/injection),
|
|
25
|
+
* which leaves it briefly visible to other processes of the same user;
|
|
26
|
+
* playwright-cli's `fill` exposes no stdin/env input for the value.
|
|
27
|
+
*/
|
|
28
|
+
export async function fillPasswordInBrowser(ref, password, session) {
|
|
29
|
+
const { command, prefixArgs } = await resolvePlaywrightCli();
|
|
30
|
+
const args = [
|
|
31
|
+
...prefixArgs,
|
|
32
|
+
...(session ? [`-s=${session}`] : []),
|
|
33
|
+
"fill",
|
|
34
|
+
ref,
|
|
35
|
+
password,
|
|
36
|
+
];
|
|
37
|
+
const { exitCode, output } = await spawnCommand(command, args, { cwd: process.cwd(), resolveOnFailure: true });
|
|
38
|
+
if (exitCode !== 0) {
|
|
39
|
+
throw new Error(output.trim() || `playwright-cli fill exited with code ${exitCode}`);
|
|
40
|
+
}
|
|
41
|
+
}
|