@makerstufferupper/flypi 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +64 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +310 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +38 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- package/src/shared/views.ts +31 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wiki (docs/memory.md Phase 6): a **render-only projection** of the `active` rows.
|
|
3
|
+
*
|
|
4
|
+
* It exists so a human can read what the store has learned, and so duplication is *visible* — two near
|
|
5
|
+
* copies of a fact sit next to each other on one page in a way they never are in a search result. It is not
|
|
6
|
+
* a second source of truth and never becomes one: recall keeps reading `memories`, nothing reads these
|
|
7
|
+
* tables back as a fact, and the whole thing is rebuildable from scratch at any time. There is no editing,
|
|
8
|
+
* no managed blocks and no human-note region — a view has nothing editable, and that is a decision, not a
|
|
9
|
+
* deferral.
|
|
10
|
+
*
|
|
11
|
+
* A page is *a query plus a template*: one page per `kind` (the `topic_key`), sections clustered by the
|
|
12
|
+
* row's first tag, and the provenance spine (`#id` citations) emitted by **code**. The model never decides
|
|
13
|
+
* which claims back a section, which is what makes the spine byte-identical for the same active set — and
|
|
14
|
+
* in this build the model writes no wiki prose at all, so the whole render is deterministic and costs
|
|
15
|
+
* nothing. `wiki_pages.model` is null for that reason, and is where a future prose pass would name itself.
|
|
16
|
+
*
|
|
17
|
+
* It runs as a job after a consolidation sweep (src/daemon/index.ts), not on every write.
|
|
18
|
+
*/
|
|
19
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import type { MemoryRow, MemoryStore } from "./store.ts";
|
|
21
|
+
|
|
22
|
+
export const WIKI_JOB = "wiki.render";
|
|
23
|
+
|
|
24
|
+
export interface WikiSection {
|
|
25
|
+
heading: string;
|
|
26
|
+
body: string;
|
|
27
|
+
claim_ids: number[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WikiPage {
|
|
31
|
+
slug: string;
|
|
32
|
+
title: string;
|
|
33
|
+
topic_key: string;
|
|
34
|
+
body: string;
|
|
35
|
+
claim_ids: number[];
|
|
36
|
+
model: string | null;
|
|
37
|
+
generated_at: string;
|
|
38
|
+
sections: WikiSection[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `kind` is free-form text, so the page key is the kind made into a slug. Two kinds that slug the same
|
|
42
|
+
* way are one page — which is also what makes the primary key impossible to collide. */
|
|
43
|
+
const slugOf = (kind: string): string => kind.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "untitled";
|
|
44
|
+
|
|
45
|
+
/** The heading a claim files under: its first tag, or `general` for an untagged row. */
|
|
46
|
+
const headingOf = (row: MemoryRow): string => row.tags[0] ?? "general";
|
|
47
|
+
|
|
48
|
+
/** One claim, cited by its row id: the spine a reader can follow back to the memory itself. */
|
|
49
|
+
const claim = (row: MemoryRow): string => `[#${row.id}] ${row.body}`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rebuild every page from the active rows. Drop-then-insert inside one transaction, so a render that throws
|
|
53
|
+
* halfway leaves the previous pages rather than half a wiki.
|
|
54
|
+
*/
|
|
55
|
+
export function renderWiki(store: MemoryStore, db: DatabaseSync): { pages: number; sections: number } {
|
|
56
|
+
const generatedAt = new Date().toISOString();
|
|
57
|
+
const byTopic = new Map<string, { title: string; rows: MemoryRow[] }>();
|
|
58
|
+
for (const row of store.activeMemories()) {
|
|
59
|
+
const slug = slugOf(row.kind);
|
|
60
|
+
const page = byTopic.get(slug);
|
|
61
|
+
// The title is the kind as it was actually written, taken from the oldest row on the page: `kind` is
|
|
62
|
+
// free-form, and "Deploy targets" and "deploy targets" are one page whose title should not depend on
|
|
63
|
+
// which row happened to be inserted first.
|
|
64
|
+
if (!page) byTopic.set(slug, { title: row.kind, rows: [row] });
|
|
65
|
+
else page.rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
db.exec("BEGIN");
|
|
69
|
+
try {
|
|
70
|
+
db.prepare("DELETE FROM wiki_sections").run();
|
|
71
|
+
db.prepare("DELETE FROM wiki_pages").run();
|
|
72
|
+
const pages = db.prepare("INSERT INTO wiki_pages (slug, title, topic_key, body, claim_ids, model, generated_at) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
73
|
+
const sections = db.prepare("INSERT INTO wiki_sections (slug, heading, body, claim_ids, generated_at) VALUES (?, ?, ?, ?, ?)");
|
|
74
|
+
let pageRows = 0;
|
|
75
|
+
let sectionRows = 0;
|
|
76
|
+
for (const [slug, page] of [...byTopic.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
77
|
+
const byHeading = new Map<string, MemoryRow[]>();
|
|
78
|
+
for (const row of page.rows) {
|
|
79
|
+
const heading = headingOf(row);
|
|
80
|
+
byHeading.set(heading, [...(byHeading.get(heading) ?? []), row]);
|
|
81
|
+
}
|
|
82
|
+
const rendered = [...byHeading.entries()]
|
|
83
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
84
|
+
.map(([heading, rows]) => {
|
|
85
|
+
const ordered = rows.sort((a, b) => a.id - b.id);
|
|
86
|
+
return { heading, body: ordered.map(claim).join("\n"), claim_ids: ordered.map((row) => row.id) };
|
|
87
|
+
});
|
|
88
|
+
const body = [`# ${page.title}`, "", ...rendered.flatMap((section) => [`## ${section.heading}`, "", section.body, ""])].join("\n").trimEnd();
|
|
89
|
+
const claimIds = rendered.flatMap((section) => section.claim_ids);
|
|
90
|
+
pages.run(slug, page.title, page.rows[0]!.kind, body, JSON.stringify(claimIds), null, generatedAt);
|
|
91
|
+
for (const section of rendered) {
|
|
92
|
+
sections.run(slug, section.heading, section.body, JSON.stringify(section.claim_ids), generatedAt);
|
|
93
|
+
sectionRows += 1;
|
|
94
|
+
}
|
|
95
|
+
pageRows += 1;
|
|
96
|
+
}
|
|
97
|
+
db.exec("COMMIT");
|
|
98
|
+
return { pages: pageRows, sections: sectionRows };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
db.exec("ROLLBACK");
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What the memory browser shows: every page with its sections, spine included. */
|
|
106
|
+
export function readWiki(db: DatabaseSync): WikiPage[] {
|
|
107
|
+
const pages = db.prepare("SELECT slug, title, topic_key, body, claim_ids, model, generated_at FROM wiki_pages ORDER BY slug").all() as unknown as {
|
|
108
|
+
slug: string;
|
|
109
|
+
title: string;
|
|
110
|
+
topic_key: string;
|
|
111
|
+
body: string;
|
|
112
|
+
claim_ids: string;
|
|
113
|
+
model: string | null;
|
|
114
|
+
generated_at: string;
|
|
115
|
+
}[];
|
|
116
|
+
const sections = db.prepare("SELECT slug, heading, body, claim_ids, generated_at FROM wiki_sections ORDER BY slug, heading, id").all() as unknown as {
|
|
117
|
+
slug: string;
|
|
118
|
+
heading: string;
|
|
119
|
+
body: string;
|
|
120
|
+
claim_ids: string;
|
|
121
|
+
generated_at: string;
|
|
122
|
+
}[];
|
|
123
|
+
const bySlug = new Map<string, WikiSection[]>();
|
|
124
|
+
for (const section of sections) {
|
|
125
|
+
bySlug.set(section.slug, [
|
|
126
|
+
...(bySlug.get(section.slug) ?? []),
|
|
127
|
+
{ heading: section.heading, body: section.body, claim_ids: JSON.parse(section.claim_ids) as number[] },
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
return pages.map((page) => ({
|
|
131
|
+
slug: page.slug,
|
|
132
|
+
title: page.title,
|
|
133
|
+
topic_key: page.topic_key,
|
|
134
|
+
body: page.body,
|
|
135
|
+
claim_ids: JSON.parse(page.claim_ids) as number[],
|
|
136
|
+
model: page.model,
|
|
137
|
+
generated_at: page.generated_at,
|
|
138
|
+
sections: bySlug.get(page.slug) ?? [],
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flypi secret …` (docs/secrets.md Phase 5.5).
|
|
3
|
+
*
|
|
4
|
+
* Two jobs, and they are not the same one:
|
|
5
|
+
*
|
|
6
|
+
* - `print NAME` is the command a config file references — `"apiKey": "!flypi secret print NAME"` in
|
|
7
|
+
* auth.json, or an `env`/`headers` value in mcp.json (pi runs a leading `!` command and uses its stdout,
|
|
8
|
+
* docs/models.md). That is why this exists as a process rather than only as a UI form: it has to answer in
|
|
9
|
+
* any shell, with nothing in the environment, and print the value and nothing else.
|
|
10
|
+
* - `list`, `get`, `put`, `rotate`, `delete`, `import-ssh` are the ones a person types.
|
|
11
|
+
*
|
|
12
|
+
* Reads open the memory database read-only, so a second process can answer while the daemon holds the write
|
|
13
|
+
* lock (risk 15). Writes are short transactions: the plan's rule is that the CLI is never a second long-lived
|
|
14
|
+
* writer, and a put that returns in one statement does not become one.
|
|
15
|
+
*/
|
|
16
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import type { FlypiPaths } from "../agent/paths.ts";
|
|
21
|
+
import { flypiPaths } from "../agent/paths.ts";
|
|
22
|
+
import { openMemoryDb, openMemoryDbReadOnly } from "../memory/db.ts";
|
|
23
|
+
import { secretFilePath } from "./config.ts";
|
|
24
|
+
import { closeSecretSource } from "./redact.ts";
|
|
25
|
+
import { keyfileProvider, type SecretKind } from "./crypto.ts";
|
|
26
|
+
import { SecretStore, type SecretRow } from "./vault.ts";
|
|
27
|
+
|
|
28
|
+
const USAGE = `usage: flypi secret list | get NAME | print NAME | put NAME [value] [--kind K] [--meta k=v]
|
|
29
|
+
flypi secret rotate NAME [value] | delete NAME | undelete NAME | path NAME
|
|
30
|
+
flypi secret import-ssh ~/.ssh/id_ed25519.pub | key | rotate-key
|
|
31
|
+
a value left out of put/rotate is read from stdin, which keeps it out of the process list`;
|
|
32
|
+
|
|
33
|
+
const READS = ["list", "get", "print", "key"];
|
|
34
|
+
const WRITES = ["put", "rotate", "delete", "undelete", "path", "import-ssh", "rotate-key"];
|
|
35
|
+
|
|
36
|
+
export interface SecretCliOptions {
|
|
37
|
+
/** Defaults to the checkout the command was run from, like every other flypi entrypoint. */
|
|
38
|
+
root?: string;
|
|
39
|
+
out?: (line: string) => void;
|
|
40
|
+
err?: (line: string) => void;
|
|
41
|
+
/** Only tests pass these; a real run opens the databases itself. */
|
|
42
|
+
db?: DatabaseSync;
|
|
43
|
+
stdin?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns the exit code, and never calls `process.exit`, so a test can assert on it. */
|
|
47
|
+
export function runSecretCli(argv: string[], options: SecretCliOptions = {}): number {
|
|
48
|
+
const paths = flypiPaths(options.root ?? process.cwd());
|
|
49
|
+
const say = options.out ?? ((line: string) => console.log(line));
|
|
50
|
+
const fail = options.err ?? ((line: string) => console.error(line));
|
|
51
|
+
const args = argv;
|
|
52
|
+
const command = args[0] ?? "list";
|
|
53
|
+
const reading = READS.includes(command);
|
|
54
|
+
if (!reading && !WRITES.includes(command)) {
|
|
55
|
+
fail(USAGE);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let db: DatabaseSync;
|
|
60
|
+
try {
|
|
61
|
+
db = options.db ?? (reading ? openMemoryDbReadOnly(paths.memoryDbPath) : openMemoryDb(paths.memoryDbPath));
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
fail(`flypi secret: ${(cause as Error).message}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
// A read command opened the database read-only, so it must know that: `print` still answers, it just
|
|
67
|
+
// cannot record the use while another process owns the writer.
|
|
68
|
+
const store = new SecretStore(db, keyfileProvider(paths.secretKeyPath), reading && options.db === undefined);
|
|
69
|
+
try {
|
|
70
|
+
const run = reading ? (a: string[]) => read(a, store, paths, say, fail) : (a: string[]) => write(a, store, paths, say, fail, options.stdin);
|
|
71
|
+
return run(args);
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
// One place, so a bad name or a key the provider does not hold is a sentence, not a stack trace.
|
|
74
|
+
fail(`flypi secret: ${(cause as Error).message}`);
|
|
75
|
+
return 1;
|
|
76
|
+
} finally {
|
|
77
|
+
if (!options.db) db.close();
|
|
78
|
+
// A CLI run is short-lived, but the redaction source is process-wide: leave it where we found it.
|
|
79
|
+
closeSecretSource();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function read(args: string[], store: SecretStore, paths: FlypiPaths, say: (line: string) => void, fail: (line: string) => void): number {
|
|
84
|
+
const [command, ...rest] = args;
|
|
85
|
+
if (command === "print") {
|
|
86
|
+
const name = rest[0];
|
|
87
|
+
if (!name) {
|
|
88
|
+
fail("flypi secret print NAME");
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const value = store.value(name, { actor: "cli", action: "revealed" });
|
|
92
|
+
if (value === undefined) {
|
|
93
|
+
fail(`no secret named "${name}"`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
// The value and nothing else, because a config file's API key is this process's stdout.
|
|
97
|
+
say(value);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
if (command === "get") {
|
|
101
|
+
const name = rest[0];
|
|
102
|
+
if (!name) {
|
|
103
|
+
fail("flypi secret get NAME");
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
const row = store.row(name);
|
|
107
|
+
if (!row) {
|
|
108
|
+
fail(`no secret named "${name}"`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
say(`name ${row.name}`);
|
|
112
|
+
say(`kind ${row.kind}`);
|
|
113
|
+
say(`mask ${row.mask}`);
|
|
114
|
+
say(`key v${row.key_version}`);
|
|
115
|
+
say(`created ${row.created_at}${row.updated_at ? ` (updated ${row.updated_at})` : ""}`);
|
|
116
|
+
say(`used ${row.use_count} time(s)${row.last_used_at ? `, last ${row.last_used_at}` : ""}`);
|
|
117
|
+
if (row.status === "deleted") say(`status deleted (flypi secret undelete ${row.name} brings it back)`);
|
|
118
|
+
for (const [key, value] of Object.entries(row.meta)) say(`meta ${key}=${value}`);
|
|
119
|
+
for (const use of store.uses(row.name)) say(`history ${use.created_at} ${use.actor} ${use.action}${use.reason ? ` (${use.reason})` : ""}`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (command === "key") {
|
|
123
|
+
const drift = store.keyDrift();
|
|
124
|
+
say(`key provider ${drift.keyProvider}, current v${drift.current}`);
|
|
125
|
+
say(`rows ${drift.rows}, ${drift.stale} sealed under an older key`);
|
|
126
|
+
if (drift.stale > 0) say(" `flypi secret rotate-key` re-seals them");
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
const rows = store.list(rest[0] ?? "", true);
|
|
130
|
+
if (rows.length === 0) {
|
|
131
|
+
say("flypi secret: the vault is empty. `flypi secret put NAME` stores one, or the web UI's Secrets tab.");
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
say(`${"name".padEnd(22)} ${"kind".padEnd(16)} ${"mask".padEnd(28)} used status`);
|
|
135
|
+
for (const row of rows) say(listLine(row));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function write(args: string[], store: SecretStore, paths: FlypiPaths, say: (line: string) => void, fail: (line: string) => void, stdin?: string): number {
|
|
140
|
+
const [command, name, ...rest] = args;
|
|
141
|
+
if (!name && command !== "import-ssh" && command !== "rotate-key") {
|
|
142
|
+
fail(`flypi secret ${command} NAME`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
const kind = flag(args, "--kind") as SecretKind | undefined;
|
|
146
|
+
if (command === "put" || command === "rotate") {
|
|
147
|
+
const value = valueFrom(rest, stdin, fail);
|
|
148
|
+
if (value === undefined) return 1;
|
|
149
|
+
if (command === "put") {
|
|
150
|
+
const result = store.put({ name: name!, value, kind, meta: metaFrom(args), actor: "cli" });
|
|
151
|
+
if (result.status === "exists") say(`flypi secret: already stored as "${result.name}"`);
|
|
152
|
+
else if (result.status === "name taken") say(`flypi secret: "${name}" is taken — \`flypi secret rotate ${name}\` replaces its value`);
|
|
153
|
+
else say(`flypi secret: stored ${name} (#${result.id})`);
|
|
154
|
+
return result.status === "stored" ? 0 : 1;
|
|
155
|
+
}
|
|
156
|
+
const result = store.rotate({ name: name!, value, kind, actor: "cli" });
|
|
157
|
+
say(result.status === "rotated" ? `flypi secret: rotated ${name}` : `flypi secret: no secret named "${name}"`);
|
|
158
|
+
return result.status === "rotated" ? 0 : 1;
|
|
159
|
+
}
|
|
160
|
+
if (command === "delete" || command === "undelete") {
|
|
161
|
+
const result = command === "delete" ? store.remove(name!, { actor: "cli" }) : store.restore(name!, { actor: "cli" });
|
|
162
|
+
const done = result.status === "deleted" || result.status === "restored";
|
|
163
|
+
say(done ? `flypi secret: ${result.status} ${name}${command === "delete" ? ` (\`flypi secret undelete ${name}\` brings it back)` : ""}` : `flypi secret: no secret named "${name}"`);
|
|
164
|
+
return done ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
if (command === "path") {
|
|
167
|
+
// A path, for the tools that only take one: ssh, gpg. The plaintext goes in a 0400 file under
|
|
168
|
+
// .flypi/secrets/ — never the environment, which every child process would inherit (risk 13).
|
|
169
|
+
const value = store.value(name!, { actor: "cli", action: "used" });
|
|
170
|
+
if (value === undefined) {
|
|
171
|
+
fail(`no secret named "${name}"`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
mkdirSync(paths.secretsDir, { recursive: true });
|
|
175
|
+
const file = secretFilePath(paths, name!);
|
|
176
|
+
writeFileSync(file, value, { mode: 0o400 });
|
|
177
|
+
chmodSync(file, 0o400);
|
|
178
|
+
say(file);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (command === "import-ssh") {
|
|
182
|
+
// The paths come after the subcommand, so the first one landed in `name` above.
|
|
183
|
+
const files = name ? [name, ...rest] : [];
|
|
184
|
+
if (files.length === 0) {
|
|
185
|
+
fail("flypi secret import-ssh ~/.ssh/id_ed25519.pub [more files…] — a public key is safe to store; a private one is your call");
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
let stored = 0;
|
|
189
|
+
for (const target of files) {
|
|
190
|
+
const file = target.startsWith("~") ? join(homedir(), target.slice(1)) : target;
|
|
191
|
+
let text: string;
|
|
192
|
+
try {
|
|
193
|
+
text = readFileSync(file, "utf8");
|
|
194
|
+
} catch {
|
|
195
|
+
fail(`cannot read ${file}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Named after the file, because that is the name a user would write in a config file, and the kind
|
|
199
|
+
// follows from the extension: `.pub` is public, anything else is a private key.
|
|
200
|
+
const base = basename(file);
|
|
201
|
+
const keyKind: SecretKind = base.endsWith(".pub") ? "ssh_public_key" : "ssh_private_key";
|
|
202
|
+
const result = store.put({ name: `ssh_${base}`, value: text.trim(), kind: keyKind, meta: { source: file }, actor: "cli" });
|
|
203
|
+
if (result.status === "stored") stored += 1;
|
|
204
|
+
say(result.status === "stored" ? `flypi secret: stored ssh_${base} (#${result.id}) as ${keyKind}` : `flypi secret: already stored as "${result.name}"`);
|
|
205
|
+
}
|
|
206
|
+
return stored > 0 ? 0 : 1;
|
|
207
|
+
}
|
|
208
|
+
const rotated = store.rotateKey("cli");
|
|
209
|
+
const reencrypted = store.reencrypt();
|
|
210
|
+
say(`flypi secret: new key v${rotated.version}; re-sealed ${reencrypted.rows} row(s)`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** stdin when no value was typed on the command line — argv is visible in `ps`, a credential should not be. */
|
|
215
|
+
function valueFrom(rest: string[], stdin: string | undefined, fail: (line: string) => void): string | undefined {
|
|
216
|
+
// Only a leading argument that is not a flag is the value. `put NAME --kind password` used to store the
|
|
217
|
+
// literal "--kind". A value that starts with a dash belongs on stdin, as the usage says.
|
|
218
|
+
const first = rest[0];
|
|
219
|
+
if (first !== undefined && !first.startsWith("-")) return first;
|
|
220
|
+
const piped = stdin?.trim();
|
|
221
|
+
if (piped) return piped;
|
|
222
|
+
fail('no value. Pass one as an argument, or pipe it: printf %s "$TOKEN" | flypi secret put NAME');
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function metaFrom(args: string[]): Record<string, string> {
|
|
227
|
+
const meta: Record<string, string> = {};
|
|
228
|
+
for (const pair of valuesOf(args, "--meta")) {
|
|
229
|
+
const at = pair.indexOf("=");
|
|
230
|
+
if (at > 0) meta[pair.slice(0, at)] = pair.slice(at + 1);
|
|
231
|
+
}
|
|
232
|
+
return meta;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const flag = (args: string[], name: string): string | undefined => valuesOf(args, name)[0];
|
|
236
|
+
|
|
237
|
+
function valuesOf(args: string[], name: string): string[] {
|
|
238
|
+
const out: string[] = [];
|
|
239
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
240
|
+
const value = args[i + 1];
|
|
241
|
+
if (args[i] === name && value !== undefined) out.push(value);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function listLine(row: SecretRow): string {
|
|
247
|
+
return `${row.name.padEnd(22)} ${row.kind.padEnd(16)} ${row.mask.padEnd(28)} ${String(row.use_count).padEnd(5)} ${row.status}`;
|
|
248
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `secrets` in `.flypi/agent/settings.json` (docs/secrets.md Phase 5.5), and the reference syntax the
|
|
3
|
+
* config files use to point at a vault entry.
|
|
4
|
+
*
|
|
5
|
+
* Only the options this code acts on are here. `redact` is not one of them: redaction is the point of the
|
|
6
|
+
* feature, so it is always on. `shellEnv` is not either, for the opposite reason — a vault value never
|
|
7
|
+
* enters a process environment, because the environment is inherited by every child and lands in crash
|
|
8
|
+
* reports (risk 13). What is left is on/off, which key provider to use, and whether `secret_get` asks
|
|
9
|
+
* first.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { FlypiPaths } from "../agent/paths.ts";
|
|
14
|
+
import { keyfileProvider, type KeyProvider } from "./crypto.ts";
|
|
15
|
+
|
|
16
|
+
export interface SecretsConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** The seam, not a menu: `keyfile` is the only provider implemented, and a name it does not know is an error the user should see. */
|
|
19
|
+
keyProvider: string;
|
|
20
|
+
keyfile: string;
|
|
21
|
+
/** `secret_get` is held until the user says yes — the permission gate's mechanism, the vault's decision. */
|
|
22
|
+
askBeforeUse: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function secretsConfig(paths: FlypiPaths): SecretsConfig {
|
|
26
|
+
let settings: unknown;
|
|
27
|
+
try {
|
|
28
|
+
settings = JSON.parse(readFileSync(paths.settingsPath, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
settings = {}; // no settings file yet: the defaults have to work
|
|
31
|
+
}
|
|
32
|
+
return parseSecretsConfig(settings, paths);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseSecretsConfig(settings: unknown, paths: FlypiPaths): SecretsConfig {
|
|
36
|
+
const raw = (settings as { secrets?: Record<string, unknown> } | undefined)?.secrets;
|
|
37
|
+
return {
|
|
38
|
+
enabled: raw?.enabled !== false,
|
|
39
|
+
keyProvider: typeof raw?.keyProvider === "string" && raw.keyProvider.length > 0 ? raw.keyProvider : "keyfile",
|
|
40
|
+
keyfile: typeof raw?.keyfile === "string" && raw.keyfile.length > 0 ? raw.keyfile : paths.secretKeyPath,
|
|
41
|
+
askBeforeUse: raw?.askBeforeUse !== false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The provider the settings name. "keyfile" is the only one, and saying so beats inventing a plugin point
|
|
47
|
+
* Phase 5.5 does not need: another provider (a macOS keychain, say) would appear here and in `keyProvider`
|
|
48
|
+
* above together, and nothing else.
|
|
49
|
+
*/
|
|
50
|
+
export function keyProviderFor(config: SecretsConfig): KeyProvider {
|
|
51
|
+
if (config.keyProvider !== "keyfile") {
|
|
52
|
+
throw new Error(`secrets.keyProvider is "${config.keyProvider}" and no such provider exists — the only one is "keyfile"`);
|
|
53
|
+
}
|
|
54
|
+
return keyfileProvider(config.keyfile);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* pi's own reference syntax, used here rather than invented: a leading `!` in an `apiKey`, `headers` or `env`
|
|
59
|
+
* value is a command whose stdout becomes the value (docs/models.md, docs/mcp.md). flypi's half of that is a
|
|
60
|
+
* command that prints a vault value, so a config file can hold a reference and still be portable, shareable
|
|
61
|
+
* and free of secrets. Anything that ends in `secret print NAME` counts, because that is how the command looks
|
|
62
|
+
* both as `!flypi secret print NAME` and as `!node /path/to/flypi/scripts/flypi.mjs secret print NAME` —
|
|
63
|
+
* there is no `flypi` on PATH until Phase 7 packages one.
|
|
64
|
+
*/
|
|
65
|
+
export const SECRET_REF = /!\S.*\ssecret\s+print\s+([A-Za-z0-9][A-Za-z0-9._-]*)\s*$/;
|
|
66
|
+
|
|
67
|
+
/** The vault name a config value points at, if it points at one. */
|
|
68
|
+
export function secretRefName(value: unknown): string | undefined {
|
|
69
|
+
if (typeof value !== "string") return undefined;
|
|
70
|
+
return value.match(SECRET_REF)?.[1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One config entry that points at a vault entry. This is the "used by" half of the UI (docs/secrets.md). */
|
|
74
|
+
export interface SecretReference {
|
|
75
|
+
file: string;
|
|
76
|
+
/** Where in that file, so a user can fix the reference rather than guess: `mcpServers.github.headers.Authorization`. */
|
|
77
|
+
path: string;
|
|
78
|
+
name: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The references in the three files pi reads for credentials. Read defensively: a file that does not parse is
|
|
83
|
+
* reported by the settings panel already, and a references panel that throws would be worse than one that
|
|
84
|
+
* shows less.
|
|
85
|
+
*/
|
|
86
|
+
export function secretReferences(paths: FlypiPaths): SecretReference[] {
|
|
87
|
+
const found: SecretReference[] = [];
|
|
88
|
+
for (const [file, label] of [
|
|
89
|
+
[paths.authPath, "auth.json"],
|
|
90
|
+
[paths.mcpPath, "mcp.json"],
|
|
91
|
+
[paths.modelsPath, "models.json"],
|
|
92
|
+
] as const) {
|
|
93
|
+
let raw: unknown;
|
|
94
|
+
try {
|
|
95
|
+
raw = JSON.parse(readFileSync(file, "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
walk(raw, label, (path, value) => {
|
|
100
|
+
const name = secretRefName(value);
|
|
101
|
+
if (name === undefined) return;
|
|
102
|
+
found.push({ file: label, path, name });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function walk(value: unknown, path: string, visit: (path: string, value: unknown) => void): void {
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
visit(path, value);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
value.forEach((item, index) => walk(item, `${path}[${index}]`, visit));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (typeof value === "object" && value !== null) {
|
|
118
|
+
for (const [key, item] of Object.entries(value)) walk(item, `${path}.${key}`, visit);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A vault name, and the name `secret_file` will use as a file. One rule for both, because a name that is not
|
|
124
|
+
* a safe file name cannot be written for ssh to read, and a name with a space in it cannot be written in a
|
|
125
|
+
* config file's `!flypi secret print NAME`.
|
|
126
|
+
*/
|
|
127
|
+
export const SECRET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
128
|
+
|
|
129
|
+
/** Where `secret_file` puts a plaintext a command needs a path to. `mode` is the caller's, 0400. */
|
|
130
|
+
export function secretFilePath(paths: FlypiPaths, name: string): string {
|
|
131
|
+
if (!SECRET_NAME.test(name)) throw new Error(`"${name}" is not a usable secret name: letters, digits, dot, dash and underscore`);
|
|
132
|
+
return join(paths.secretsDir, name);
|
|
133
|
+
}
|