friend-assistant 0.1.14 → 0.1.16
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 +3 -0
- package/dist/BUILD.json +2 -2
- package/dist/friend.mjs +132 -7
- package/dist/web/assets/{index-BtrPzwzP.js → index-BmlBBnge.js} +28 -28
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,8 @@ Every command starts with `friend`. If you run Friend from the source code, writ
|
|
|
85
85
|
|
|
86
86
|
| Command | What it does | Use it when |
|
|
87
87
|
|---|---|---|
|
|
88
|
+
| `friend password` | Sets a new sign-in password (asks twice, nothing is shown while you type). The old one is not needed, because you are at this PC, so it is also the way back in if you forgot it. Every browser and phone is signed out. | You forgot the password, or want a new one. From the screen: Esc → Settings → Security. |
|
|
89
|
+
| `friend pin` | Sets a new PIN, the number Friend asks for before risky actions. | You forgot the PIN, or want a new one. |
|
|
88
90
|
| `friend daemon start` | Runs Friend in the background, with no terminal window needed. Prints the address, keeps a log in the data folder (`friend.log`). | You want Friend always available, even after you close the terminal. |
|
|
89
91
|
| `friend daemon stop` | Stops the background Friend and the speech engine with it. | Shutting it down. |
|
|
90
92
|
| `friend daemon restart` | Stops it and starts it again. | After changing a setting such as `friend lan on` or a different model. |
|
|
@@ -129,6 +131,7 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
|
|
|
129
131
|
| Key | What it is |
|
|
130
132
|
|---|---|
|
|
131
133
|
| `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription), `ollama` (this PC) or `openai` (any OpenAI-compatible server, such as OmniRoute). |
|
|
134
|
+
| `approvals` | How much Friend asks before acting: `manual` (asks every time, the default), `edits` (file work and the sandboxed code runner go ahead) or `auto` (commands and tools go ahead too). Risky commands (they need your PIN), the camera, adding connectors or plugins, and Friend's own data folder always ask, in every mode. The AI cannot change this. Easier: Esc → Settings → Approvals. |
|
|
132
135
|
| `ownerName` | Your name, shown on the profile badge (a shield in the corner) while you are signed in; it shows whoever Friend just recognized when they speak. Empty shows "Owner". |
|
|
133
136
|
| `apiKey`, `model` | The Anthropic API key and the Claude model used with it. |
|
|
134
137
|
| `claudeCode.model`, `claudeCode.command`, `claudeCode.fallback` | The subscription route: model (`sonnet`, `haiku`, `opus`), the command that starts Claude Code, and `ollama` or `none` for what to use when Claude is unavailable. |
|
package/dist/BUILD.json
CHANGED
package/dist/friend.mjs
CHANGED
|
@@ -375,11 +375,21 @@ function matcherMatches(m, args) {
|
|
|
375
375
|
// ../server/src/policy/engine.ts
|
|
376
376
|
var ASK_EVERY_TIME = /* @__PURE__ */ new Set(["camera.snap"]);
|
|
377
377
|
var MEMBER_TOOLS = /* @__PURE__ */ new Set(["weather.get", "stage.scene", "stage.show", "stage.update", "stage.hide", "stage.presence", "stage.mood"]);
|
|
378
|
+
var EDIT_TOOLS = /* @__PURE__ */ new Set(["fs.list", "fs.read", "fs.write", "code.run"]);
|
|
379
|
+
var isAdminTool = (tool) => tool.startsWith("connectors.") || tool.startsWith("plugins.");
|
|
378
380
|
var PolicyEngine = class {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
+
constructor(db, config) {
|
|
382
|
+
this.config = config;
|
|
381
383
|
this.rules = new RuleStore(db);
|
|
382
384
|
}
|
|
385
|
+
config;
|
|
386
|
+
rules;
|
|
387
|
+
/** Whether the approval mode lets this tool go ahead without a card. Risky (T3) actions never get here. */
|
|
388
|
+
modeAllows(tool, protectedPath) {
|
|
389
|
+
if (protectedPath || isAdminTool(tool)) return false;
|
|
390
|
+
const mode = this.config.approvals;
|
|
391
|
+
return mode === "auto" || mode === "edits" && EDIT_TOOLS.has(tool);
|
|
392
|
+
}
|
|
383
393
|
decide(input) {
|
|
384
394
|
if (input.trust === "guest") return input.tool === "stage.presence" ? "allow" : "deny";
|
|
385
395
|
if (input.trust === "member") return MEMBER_TOOLS.has(input.tool) ? "allow" : "deny";
|
|
@@ -391,7 +401,8 @@ var PolicyEngine = class {
|
|
|
391
401
|
return "allow";
|
|
392
402
|
case "T2":
|
|
393
403
|
if (ASK_EVERY_TIME.has(input.tool)) return "ask";
|
|
394
|
-
|
|
404
|
+
if (this.rules.matches(input.tool, input.args)) return "allow";
|
|
405
|
+
return this.modeAllows(input.tool, input.protectedPath) ? "allow" : "ask";
|
|
395
406
|
case "T3":
|
|
396
407
|
return "ask_with_pin";
|
|
397
408
|
}
|
|
@@ -1904,7 +1915,8 @@ var Agent = class {
|
|
|
1904
1915
|
if (call.name === "shell.run") tier = classifyShell(String(args.command), d.config.destructivePatterns);
|
|
1905
1916
|
const p = pathArg(call.name, args);
|
|
1906
1917
|
const insideRoots = p === void 0 ? true : lexicalInsideRoots(p, d.config.roots);
|
|
1907
|
-
const
|
|
1918
|
+
const protectedPath = p !== void 0 && lexicalInsideRoots(p, [d.config.dataDir]);
|
|
1919
|
+
const decision = d.policy.decide({ tool: call.name, baseTier: tier, args, trust: ctx.trust, insideRoots, protectedPath });
|
|
1908
1920
|
let approvedOutside = false;
|
|
1909
1921
|
const device = def.target === "device" ? d.devices.targetName() : null;
|
|
1910
1922
|
if (decision === "deny") {
|
|
@@ -3031,7 +3043,7 @@ async function verifySecret(secret, stored) {
|
|
|
3031
3043
|
const actual = await scryptAsync(secret, Buffer.from(saltHex, "hex"));
|
|
3032
3044
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
3033
3045
|
}
|
|
3034
|
-
var Auth = class {
|
|
3046
|
+
var Auth = class _Auth {
|
|
3035
3047
|
constructor(db, audit, clock = Date.now) {
|
|
3036
3048
|
this.db = db;
|
|
3037
3049
|
this.audit = audit;
|
|
@@ -3116,6 +3128,48 @@ var Auth = class {
|
|
|
3116
3128
|
else this.audit.log("owner", "pin.fail", { ip });
|
|
3117
3129
|
return ok;
|
|
3118
3130
|
}
|
|
3131
|
+
/** Checks a new password and/or PIN the same way setup does. Returns the problem, or null when both are fine. */
|
|
3132
|
+
static checkNewSecrets(input) {
|
|
3133
|
+
if (input.newPassword === void 0 && input.newPin === void 0) return "Give a new password or a new PIN.";
|
|
3134
|
+
if (input.newPin !== void 0 && !/^\d{6,}$/.test(input.newPin)) return "PIN must be at least 6 digits";
|
|
3135
|
+
if (input.newPassword !== void 0 && input.newPassword.length < 8) return "Password must be at least 8 characters";
|
|
3136
|
+
return null;
|
|
3137
|
+
}
|
|
3138
|
+
async store(input) {
|
|
3139
|
+
if (input.newPassword !== void 0) this.db.prepare("update owner set password_hash = ? where id = 1").run(await hashSecret(input.newPassword));
|
|
3140
|
+
if (input.newPin !== void 0) this.db.prepare("update owner set pin_hash = ? where id = 1").run(await hashSecret(input.newPin));
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Changes the password and/or PIN of a signed-in owner who proves it with the current password. Wrong guesses count toward
|
|
3144
|
+
* the same sign-in lock. A new password signs every other browser out (the one in `keepToken` stays signed in).
|
|
3145
|
+
*/
|
|
3146
|
+
async changeSecrets(input, ip, keepToken) {
|
|
3147
|
+
const problem = _Auth.checkNewSecrets(input);
|
|
3148
|
+
if (problem) throw new Error(problem);
|
|
3149
|
+
const succeeded = this.begin("login", ip);
|
|
3150
|
+
const row = this.db.prepare("select password_hash h from owner where id = 1").get();
|
|
3151
|
+
const ok = row ? await verifySecret(input.current, row.h) : false;
|
|
3152
|
+
if (!ok) {
|
|
3153
|
+
this.audit.log("owner", "secrets.fail", { ip });
|
|
3154
|
+
throw new Error("Invalid credentials");
|
|
3155
|
+
}
|
|
3156
|
+
succeeded();
|
|
3157
|
+
await this.store(input);
|
|
3158
|
+
if (input.newPassword !== void 0) {
|
|
3159
|
+
if (keepToken) this.db.prepare("update sessions set revoked = 1 where token_hash != ?").run(sha256(keepToken));
|
|
3160
|
+
else this.db.prepare("update sessions set revoked = 1").run();
|
|
3161
|
+
}
|
|
3162
|
+
this.audit.log("owner", "secrets.changed", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
|
|
3163
|
+
}
|
|
3164
|
+
/** For `friend password` / `friend pin` run on this PC: no current password needed (being on the PC is the proof). Signs everyone out. */
|
|
3165
|
+
async resetSecrets(input) {
|
|
3166
|
+
if (!this.hasOwner()) throw new Error("There is no owner yet. Run: friend setup");
|
|
3167
|
+
const problem = _Auth.checkNewSecrets(input);
|
|
3168
|
+
if (problem) throw new Error(problem);
|
|
3169
|
+
await this.store(input);
|
|
3170
|
+
if (input.newPassword !== void 0) this.db.prepare("update sessions set revoked = 1").run();
|
|
3171
|
+
this.audit.log("system", "secrets.reset", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
|
|
3172
|
+
}
|
|
3119
3173
|
createPairCode() {
|
|
3120
3174
|
const code = randomBytes(5).toString("hex").toUpperCase();
|
|
3121
3175
|
this.db.prepare("insert into pair_codes (code_hash, expires_at) values (?, ?)").run(sha256(code), this.clock() + PAIR_TTL_MS);
|
|
@@ -3638,6 +3692,7 @@ var defaults = (dataDir) => ({
|
|
|
3638
3692
|
view: "companion",
|
|
3639
3693
|
look: { ...DEFAULT_LOOK },
|
|
3640
3694
|
ownerName: "",
|
|
3695
|
+
approvals: "manual",
|
|
3641
3696
|
dataDir
|
|
3642
3697
|
});
|
|
3643
3698
|
function configPath(dataDir) {
|
|
@@ -3675,6 +3730,7 @@ function loadConfig(dataDir = defaultDataDir()) {
|
|
|
3675
3730
|
view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
|
|
3676
3731
|
look: sanitizeLook(stored.look),
|
|
3677
3732
|
ownerName: typeof stored.ownerName === "string" ? stored.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "",
|
|
3733
|
+
approvals: stored.approvals === "edits" || stored.approvals === "auto" ? stored.approvals : "manual",
|
|
3678
3734
|
dataDir
|
|
3679
3735
|
};
|
|
3680
3736
|
}
|
|
@@ -4078,6 +4134,30 @@ function createGateway(deps) {
|
|
|
4078
4134
|
reply.clearCookie(COOKIE_NAME, { path: "/" });
|
|
4079
4135
|
return { ok: true };
|
|
4080
4136
|
});
|
|
4137
|
+
app.post("/api/account/secret", async (req, reply) => {
|
|
4138
|
+
reply.header("cache-control", "no-store");
|
|
4139
|
+
const token = req.cookies[COOKIE_NAME];
|
|
4140
|
+
if (!token || !auth.resolveSession(token)) return reply.code(401).send({ error: "Please sign in again." });
|
|
4141
|
+
const body = z10.object({ current: z10.string().min(1).max(1024), newPassword: z10.string().max(1024).optional(), newPin: z10.string().max(128).optional() }).safeParse(req.body);
|
|
4142
|
+
if (!body.success) return reply.code(400).send({ error: "Invalid input" });
|
|
4143
|
+
const { current, newPassword, newPin } = body.data;
|
|
4144
|
+
try {
|
|
4145
|
+
await auth.changeSecrets({ current, newPassword, newPin }, req.ip, token);
|
|
4146
|
+
return { ok: true, signedOutOthers: newPassword !== void 0 };
|
|
4147
|
+
} catch (e) {
|
|
4148
|
+
if (e instanceof LockoutError) {
|
|
4149
|
+
const seconds = Math.max(1, Math.ceil(e.retryAfterMs / 1e3));
|
|
4150
|
+
return reply.code(429).header("retry-after", seconds).send({ error: `Too many wrong passwords. Try again in ${waitText(seconds)}.`, retryAfterSeconds: seconds });
|
|
4151
|
+
}
|
|
4152
|
+
if (e.message === "Invalid credentials") {
|
|
4153
|
+
const state = auth.loginState(req.ip);
|
|
4154
|
+
const seconds = Math.ceil(state.retryAfterMs / 1e3);
|
|
4155
|
+
if (seconds > 0) return reply.code(401).send({ error: `Wrong password. Locked for ${waitText(seconds)}.`, triesLeft: 0, retryAfterSeconds: seconds });
|
|
4156
|
+
return reply.code(401).send({ error: "The current password is wrong.", triesLeft: state.triesLeft });
|
|
4157
|
+
}
|
|
4158
|
+
return reply.code(400).send({ error: e.message });
|
|
4159
|
+
}
|
|
4160
|
+
});
|
|
4081
4161
|
const pairLimiter = new RateLimiter(Date.now);
|
|
4082
4162
|
app.post("/api/pair", async (req, reply) => {
|
|
4083
4163
|
const body = z10.object({ code: z10.string().min(1).max(64), name: z10.string().min(1).max(64) }).safeParse(req.body);
|
|
@@ -5375,6 +5455,8 @@ var SettingsPatch = z11.object({
|
|
|
5375
5455
|
view: ViewPreset,
|
|
5376
5456
|
/** What the profile badge calls the owner. Empty clears it. */
|
|
5377
5457
|
ownerName: z11.string().trim().max(40),
|
|
5458
|
+
/** How much Friend asks before acting. Loosening it is a change that needs the PIN. */
|
|
5459
|
+
approvals: z11.enum(["manual", "edits", "auto"]),
|
|
5378
5460
|
look: z11.object({
|
|
5379
5461
|
style: PresenceStyle,
|
|
5380
5462
|
accent: z11.string().regex(/^#[0-9a-fA-F]{6}$/, "A colour like #3fd8ff"),
|
|
@@ -5424,6 +5506,7 @@ function settingsView(c) {
|
|
|
5424
5506
|
apiKeySet: !!c.apiKey,
|
|
5425
5507
|
view: c.view,
|
|
5426
5508
|
ownerName: c.ownerName,
|
|
5509
|
+
approvals: c.approvals,
|
|
5427
5510
|
look: { ...c.look },
|
|
5428
5511
|
ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
|
|
5429
5512
|
openai: { url: c.openai.url, model: c.openai.model, vision: !!c.openai.vision, apiKeySet: !!c.openai.apiKey },
|
|
@@ -5447,6 +5530,7 @@ function refuseForAgent(raw) {
|
|
|
5447
5530
|
const r = raw && typeof raw === "object" ? raw : {};
|
|
5448
5531
|
const cc = r.claudeCode && typeof r.claudeCode === "object" ? r.claudeCode : {};
|
|
5449
5532
|
const oa = r.openai && typeof r.openai === "object" ? r.openai : {};
|
|
5533
|
+
if ("approvals" in r) throw new SettingsError("How much Friend asks before acting can only be changed by the owner on the settings screen.");
|
|
5450
5534
|
if ("apiKey" in r || "apiKey" in oa) throw new SettingsError("The API key can only be changed by the owner on the settings screen.");
|
|
5451
5535
|
if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
|
|
5452
5536
|
}
|
|
@@ -5499,6 +5583,7 @@ function applySettings(config, raw) {
|
|
|
5499
5583
|
const restart = Object.keys(p.voice ?? {}).filter((k) => RESTART_VOICE.includes(k) && p.voice[k] !== config.voice[k]).map((k) => `voice.${k}`);
|
|
5500
5584
|
config.provider = next.provider;
|
|
5501
5585
|
if (p.view) config.view = p.view;
|
|
5586
|
+
if (p.approvals) config.approvals = p.approvals;
|
|
5502
5587
|
if (p.ownerName !== void 0) config.ownerName = p.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
5503
5588
|
if (p.look) config.look = sanitizeLook({ ...config.look, ...p.look });
|
|
5504
5589
|
if (p.model) config.model = p.model;
|
|
@@ -7338,6 +7423,10 @@ var PARSERS = {
|
|
|
7338
7423
|
if (name.length > 40) throw new Error("The name can be at most 40 characters");
|
|
7339
7424
|
return name;
|
|
7340
7425
|
},
|
|
7426
|
+
approvals: (v) => {
|
|
7427
|
+
if (v !== "manual" && v !== "edits" && v !== "auto") throw new Error("approvals must be manual, edits or auto");
|
|
7428
|
+
return v;
|
|
7429
|
+
},
|
|
7341
7430
|
https: bool,
|
|
7342
7431
|
port: (v) => {
|
|
7343
7432
|
const n = Number(v);
|
|
@@ -7419,6 +7508,7 @@ var KEY_HELP = {
|
|
|
7419
7508
|
port: "the web port (easier: friend lan port N)",
|
|
7420
7509
|
https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
|
|
7421
7510
|
publicUrl: "the address people use when Friend is behind your own domain, or empty",
|
|
7511
|
+
approvals: "how much Friend asks before acting: manual (asks every time), edits (file work and sandboxed code go ahead) or auto (everything goes ahead except risky commands, the camera and changes to connectors/plugins). Easier: Esc \u2192 Settings \u2192 Approvals",
|
|
7422
7512
|
ownerName: "your name, shown on the profile badge (the shield) in the corner of the screen; empty shows Owner",
|
|
7423
7513
|
roots: "folders Friend may read and write freely, separated by commas",
|
|
7424
7514
|
allowedOrigins: "extra web addresses allowed to connect, separated by commas",
|
|
@@ -9594,7 +9684,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
|
|
|
9594
9684
|
import crossSpawn5 from "cross-spawn";
|
|
9595
9685
|
var PACKAGE = "friend-assistant";
|
|
9596
9686
|
var real = {
|
|
9597
|
-
current: () => true ? "0.1.
|
|
9687
|
+
current: () => true ? "0.1.16" : "development",
|
|
9598
9688
|
latest: () => new Promise((resolve10) => {
|
|
9599
9689
|
const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
9600
9690
|
let out = "";
|
|
@@ -9772,6 +9862,35 @@ async function setupCommand3(io2) {
|
|
|
9772
9862
|
}
|
|
9773
9863
|
}
|
|
9774
9864
|
|
|
9865
|
+
// ../agent-cli/src/commands/secret.ts
|
|
9866
|
+
async function secretCommand(kind, io2) {
|
|
9867
|
+
const db = openDb(io2.dataDir);
|
|
9868
|
+
try {
|
|
9869
|
+
const auth = new Auth(db, new Audit(db));
|
|
9870
|
+
if (!auth.hasOwner()) {
|
|
9871
|
+
io2.err("There is no owner yet. Run: friend setup");
|
|
9872
|
+
return 1;
|
|
9873
|
+
}
|
|
9874
|
+
const label2 = kind === "password" ? "password" : "PIN";
|
|
9875
|
+
const first2 = await io2.ask(`New ${label2} (${kind === "password" ? "at least 8 characters" : "at least 6 digits"}): `, { hidden: true });
|
|
9876
|
+
const problem = Auth.checkNewSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
|
|
9877
|
+
if (problem) {
|
|
9878
|
+
io2.err(problem);
|
|
9879
|
+
return 1;
|
|
9880
|
+
}
|
|
9881
|
+
const again = await io2.ask(`Type the new ${label2} again: `, { hidden: true });
|
|
9882
|
+
if (again !== first2) {
|
|
9883
|
+
io2.err(`The two ${label2}s are not the same. Nothing was changed.`);
|
|
9884
|
+
return 1;
|
|
9885
|
+
}
|
|
9886
|
+
await auth.resetSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
|
|
9887
|
+
io2.out(kind === "password" ? "Password changed. Every browser and phone has been signed out; sign in again with the new password." : "PIN changed.");
|
|
9888
|
+
return 0;
|
|
9889
|
+
} finally {
|
|
9890
|
+
db.close();
|
|
9891
|
+
}
|
|
9892
|
+
}
|
|
9893
|
+
|
|
9775
9894
|
// ../agent-cli/src/commands/status.ts
|
|
9776
9895
|
async function statusCommand3(io2) {
|
|
9777
9896
|
const cfg = loadConfig(io2.dataDir);
|
|
@@ -9796,6 +9915,8 @@ var USAGE4 = `Usage: friend <command>
|
|
|
9796
9915
|
onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
|
|
9797
9916
|
doctor check what this PC has and what is missing (changes nothing)
|
|
9798
9917
|
setup create the owner account (password, PIN, assistant name)
|
|
9918
|
+
password change the sign-in password (signs every browser out)
|
|
9919
|
+
pin change the PIN for risky actions
|
|
9799
9920
|
start run the gateway and a local device node
|
|
9800
9921
|
daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
|
|
9801
9922
|
upgrade [--force] stop the background Friend, install the newest version, start it again
|
|
@@ -9821,6 +9942,10 @@ async function run(argv, io2) {
|
|
|
9821
9942
|
switch (cmd) {
|
|
9822
9943
|
case "setup":
|
|
9823
9944
|
return await setupCommand3(io2);
|
|
9945
|
+
case "password":
|
|
9946
|
+
return await secretCommand("password", io2);
|
|
9947
|
+
case "pin":
|
|
9948
|
+
return await secretCommand("pin", io2);
|
|
9824
9949
|
case "start":
|
|
9825
9950
|
return await startCommand(io2);
|
|
9826
9951
|
case "daemon":
|
|
@@ -9847,7 +9972,7 @@ async function run(argv, io2) {
|
|
|
9847
9972
|
case "version":
|
|
9848
9973
|
case "--version":
|
|
9849
9974
|
case "-v":
|
|
9850
|
-
io2.out(true ? "0.1.
|
|
9975
|
+
io2.out(true ? "0.1.16" : "development");
|
|
9851
9976
|
return 0;
|
|
9852
9977
|
case "lan":
|
|
9853
9978
|
return await lanCommand(rest, io2);
|