@inetafrica/open-claudia 3.0.44 → 3.1.0
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/CHANGELOG.md +37 -0
- package/README.md +9 -5
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +118 -1
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +48 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
package/test-recall-graph.js
CHANGED
|
@@ -7,6 +7,9 @@ const path = require("path");
|
|
|
7
7
|
|
|
8
8
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-graph-"));
|
|
9
9
|
process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
|
|
10
|
+
// Pin the read-time decay half-life so assertions are deterministic (env pin
|
|
11
|
+
// beats any tuning file the dream may have written).
|
|
12
|
+
process.env.RECALL_DECAY_HALF_LIFE_DAYS = "30";
|
|
10
13
|
|
|
11
14
|
const graph = require("./core/recall/graph");
|
|
12
15
|
|
|
@@ -51,18 +54,33 @@ for (const [, v] of act) assert.ok(v.activation > 0 && v.hop >= 1, "activation h
|
|
|
51
54
|
// seeds with no edges produce nothing
|
|
52
55
|
assert.strictEqual(graph.expand([{ id: "pack:orphanseed", score: 5 }]).size, 0);
|
|
53
56
|
|
|
54
|
-
// --- decay (never below structural floor) ---
|
|
55
|
-
graph.addEdge("pack:d", "pack:e", "parent"); // base weight 3, no last_reinforced →
|
|
56
|
-
graph.reinforce("pack:d", "pack:f", 10);
|
|
57
|
-
// backdate the reinforced edge well past the half-life
|
|
57
|
+
// --- decay-on-read (never below structural floor; reads never rewrite storage) ---
|
|
58
|
+
graph.addEdge("pack:d", "pack:e", "parent"); // base weight 3, no last_reinforced → never decays
|
|
59
|
+
graph.reinforce("pack:d", "pack:f", 10); // stored 11, stamped now
|
|
58
60
|
const db = graph.openDb();
|
|
59
|
-
const
|
|
60
|
-
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(
|
|
61
|
-
graph.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
const oneHL = new Date(Date.now() - 30 * 86400000).toISOString(); // exactly one pinned half-life
|
|
62
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "pack:d", "pack:f");
|
|
63
|
+
const dfEff = graph.neighbors("pack:d").find((n) => n.node === "pack:f").weight;
|
|
64
|
+
assert.ok(dfEff > 5.3 && dfEff < 5.7, `one half-life on read ≈ 5.5 (${dfEff})`);
|
|
65
|
+
const deEff = graph.neighbors("pack:d").find((n) => n.node === "pack:e").weight;
|
|
66
|
+
assert.strictEqual(deEff, 3, "structural edge with no reinforcement never decays");
|
|
67
|
+
// far past the half-life the effective weight clamps at the type's base
|
|
68
|
+
const longAgo = new Date(Date.now() - 400 * 86400000).toISOString();
|
|
69
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "pack:d", "pack:f");
|
|
70
|
+
const floored = graph.neighbors("pack:d").find((n) => n.node === "pack:f").weight;
|
|
71
|
+
assert.strictEqual(floored, 1, "effective weight clamps at the related floor");
|
|
72
|
+
// storage untouched by all those reads
|
|
73
|
+
assert.strictEqual(graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f").weight, 11,
|
|
74
|
+
"reads never rewrite the stored weight");
|
|
75
|
+
|
|
76
|
+
// REGRESSION (P1 compounding decay): the nightly weight-rewrite path is gone.
|
|
77
|
+
assert.strictEqual(graph.decay, undefined, "decay() rewrite removed — decay is read-time only");
|
|
78
|
+
|
|
79
|
+
// reinforcement settles accrued decay, THEN bumps — never bumps the stale stored value
|
|
80
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "pack:d", "pack:f");
|
|
81
|
+
graph.reinforce("pack:d", "pack:f", 1); // effective ≈ 5.5 + 1
|
|
82
|
+
const settledDF = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f").weight;
|
|
83
|
+
assert.ok(settledDF > 6.3 && settledDF < 6.7, `settle-then-bump ≈ 6.5, not 12 (${settledDF})`);
|
|
66
84
|
|
|
67
85
|
// --- structural sync from a stubbed corpus ---
|
|
68
86
|
const packsStub = {
|
|
@@ -88,7 +106,44 @@ const pruned = graph.pruneOrphans(packsStub, entStub);
|
|
|
88
106
|
assert.ok(pruned >= 1, "orphan edge pruned");
|
|
89
107
|
assert.ok(!graph.allEdges().some((e) => e.src === "pack:ghost"), "ghost edge gone");
|
|
90
108
|
|
|
109
|
+
// --- REGRESSION (P1): repeated nightly tend() must leave stored weights
|
|
110
|
+
// bit-identical — the old decay() rewrite shrank them again every night
|
|
111
|
+
// (compounding: full age reapplied to an already-decayed weight). ---
|
|
112
|
+
graph.reinforce("pack:kazee-mobile", "pack:kazee", 9); // stored 10, stamped now
|
|
113
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=? AND type='related'")
|
|
114
|
+
.run(oneHL, "pack:kazee-mobile", "pack:kazee");
|
|
115
|
+
const kmStored = () => graph.allEdges()
|
|
116
|
+
.find((e) => e.src === "pack:kazee-mobile" && e.dst === "pack:kazee" && e.type === "related").weight;
|
|
117
|
+
const before3 = kmStored();
|
|
118
|
+
for (let i = 0; i < 3; i++) graph.tend(packsStub, entStub);
|
|
119
|
+
assert.strictEqual(kmStored(), before3, "three nightly tends never rewrite a reinforced weight");
|
|
120
|
+
const kmEff = graph.neighbors("pack:kazee-mobile")
|
|
121
|
+
.find((n) => n.node === "pack:kazee" && n.type === "related").weight;
|
|
122
|
+
assert.ok(kmEff > 4.7 && kmEff < 5.3, `effective after 3 tends is still one half-life ≈ 5 (${kmEff})`);
|
|
123
|
+
|
|
91
124
|
// parseLinks
|
|
92
125
|
assert.deepStrictEqual(graph.parseLinks("see [[Foo-Bar]] and [[baz]]"), ["foo-bar", "baz"]);
|
|
93
126
|
|
|
127
|
+
// --- v1 migration: a legacy DB (pre decay-on-read) gets its clock restarted on
|
|
128
|
+
// first open — stored weights are treated as current, nothing else changes. ---
|
|
129
|
+
graph._resetForTest();
|
|
130
|
+
process.env.RECALL_GRAPH_DB = path.join(tmp, "legacy.db");
|
|
131
|
+
{
|
|
132
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
133
|
+
const raw = new DatabaseSync(process.env.RECALL_GRAPH_DB);
|
|
134
|
+
raw.exec(`CREATE TABLE edges (src TEXT NOT NULL, dst TEXT NOT NULL, type TEXT NOT NULL,
|
|
135
|
+
weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (src, dst, type))`);
|
|
136
|
+
raw.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
|
|
137
|
+
.run("pack:l1", "pack:l2", "related", 4, new Date(Date.now() - 200 * 86400000).toISOString(), new Date().toISOString());
|
|
138
|
+
raw.close();
|
|
139
|
+
}
|
|
140
|
+
delete require.cache[require.resolve("./core/recall/graph")];
|
|
141
|
+
const graph2 = require("./core/recall/graph");
|
|
142
|
+
const legacy = graph2.allEdges().find((e) => e.src === "pack:l1");
|
|
143
|
+
assert.ok(Date.parse(legacy.last_reinforced) > Date.now() - 60000, "migration restamps legacy reinforced rows");
|
|
144
|
+
assert.strictEqual(legacy.weight, 4, "migration leaves the stored weight untouched");
|
|
145
|
+
const legacyEff = graph2.neighbors("pack:l1").find((n) => n.node === "pack:l2").weight;
|
|
146
|
+
assert.ok(legacyEff > 3.9, `restamped legacy row reads at ~full stored weight (${legacyEff})`);
|
|
147
|
+
graph2._resetForTest();
|
|
148
|
+
|
|
94
149
|
console.log("recall graph OK");
|
package/test-tool-graph.js
CHANGED
|
@@ -53,25 +53,35 @@ graph.addFollow("ensure-src", "ensure-dst"); // weight 1, last_reinforced=null
|
|
|
53
53
|
const ensured = graph.followers("ensure-src");
|
|
54
54
|
assert.strictEqual(ensured[0].weight, 1, "ensure edge weighs 1");
|
|
55
55
|
|
|
56
|
-
// ── decay: backdate a reinforced edge far into the past
|
|
56
|
+
// ── read-time decay + prune: backdate a reinforced edge far into the past —
|
|
57
|
+
// reads hide it immediately, prune() physically deletes it (no floor) ──
|
|
57
58
|
const db = graph.openDb();
|
|
58
59
|
const longAgo = new Date(Date.now() - 365 * 86400000).toISOString();
|
|
59
60
|
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "auth", "list");
|
|
60
|
-
|
|
61
|
+
assert.ok(!graph.followers("auth").some((r) => r.name === "list"), "faded edge invisible to reads before any prune");
|
|
62
|
+
let res = graph.prune({ halfLifeDays: 1, pruneBelow: 0.15 });
|
|
61
63
|
assert.ok(res.pruned >= 1, "a year-old edge under the floor is pruned");
|
|
62
|
-
assert.ok(!graph.
|
|
64
|
+
assert.ok(!graph.allEdges().some((e) => e.src === "auth" && e.dst === "list"), "pruned edge physically gone");
|
|
63
65
|
|
|
64
|
-
// the ensure edge (last_reinforced=null)
|
|
65
|
-
assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives
|
|
66
|
+
// the ensure edge (last_reinforced=null) neither fades on read nor prunes
|
|
67
|
+
assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives prune");
|
|
66
68
|
|
|
67
|
-
// ── decay that reduces but keeps an edge
|
|
69
|
+
// ── read-time decay that reduces but keeps an edge; storage never rewritten ──
|
|
68
70
|
graph.addFollow("warm-a", "warm-b", { bump: 10 });
|
|
69
|
-
const
|
|
70
|
-
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(
|
|
71
|
-
graph.decay({ halfLifeDays: 30, pruneBelow: 0.15 }); // one half-life → ~5
|
|
71
|
+
const oneHL = new Date(Date.now() - 45 * 86400000).toISOString(); // one default half-life
|
|
72
|
+
db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "warm-a", "warm-b");
|
|
72
73
|
const warm = graph.followers("warm-a")[0];
|
|
73
74
|
assert.ok(warm && warm.name === "warm-b", "warm edge survives a single half-life");
|
|
74
|
-
assert.ok(warm.weight
|
|
75
|
+
assert.ok(warm.weight > 4.7 && warm.weight < 5.3, `read-time weight ≈ half of stored (${warm.weight})`);
|
|
76
|
+
assert.strictEqual(graph.allEdges().find((e) => e.src === "warm-a").weight, 10, "reads never rewrite the stored weight");
|
|
77
|
+
|
|
78
|
+
// ── settle-then-bump: reinforcing a decayed edge bumps its EFFECTIVE weight ──
|
|
79
|
+
graph.addFollow("warm-a", "warm-b", { bump: 1 }); // ≈ 5 + 1
|
|
80
|
+
const settled = graph.allEdges().find((e) => e.src === "warm-a" && e.dst === "warm-b").weight;
|
|
81
|
+
assert.ok(settled > 5.7 && settled < 6.3, `settle-then-bump ≈ 6, not 11 (${settled})`);
|
|
82
|
+
|
|
83
|
+
// REGRESSION (P1 compounding decay): the nightly weight-rewrite path is gone.
|
|
84
|
+
assert.strictEqual(graph.decay, undefined, "decay() rewrite removed — prune() only deletes");
|
|
75
85
|
|
|
76
86
|
// ── removeEdge ──
|
|
77
87
|
assert.strictEqual(graph.removeEdge("warm-a", "warm-b"), true, "removeEdge succeeds");
|
|
@@ -84,10 +94,11 @@ const removed = graph.pruneOrphans(fakeLib);
|
|
|
84
94
|
assert.ok(removed >= 1, "edge pointing at an unregistered tool ('dead') is pruned");
|
|
85
95
|
assert.ok(!graph.followers("live").length, "live→dead removed because dead is not a live tool");
|
|
86
96
|
|
|
87
|
-
// ── tend() is the nightly entry:
|
|
97
|
+
// ── tend() is the nightly entry: prunes faded + orphaned edges, returns stats ──
|
|
88
98
|
const t = graph.tend(fakeLib);
|
|
89
99
|
assert.ok(typeof t.edges === "number" && typeof t.nodes === "number", "tend returns stats");
|
|
90
|
-
assert.ok(typeof t.pruned === "number"
|
|
100
|
+
assert.ok(typeof t.pruned === "number", "tend returns prune count");
|
|
101
|
+
assert.strictEqual(t.decayed, undefined, "tend has no decay pass (decay is read-time)");
|
|
91
102
|
|
|
92
103
|
// ── stats / allEdges sanity ──
|
|
93
104
|
const s = graph.stats();
|
|
@@ -111,6 +122,16 @@ assert.strictEqual(runs[0].name, "spaces", "first run is spaces");
|
|
|
111
122
|
assert.strictEqual(runs[0].shape, "spaces docs --task-id <id> <path>", "first run shaped");
|
|
112
123
|
assert.strictEqual(runs[1].name, "other", "second run is other (args did not bleed across &&)");
|
|
113
124
|
assert.strictEqual(graph.parseToolRuns("ls -la").length, 0, "non-tool command yields no runs");
|
|
125
|
+
// B17: every shell composition operator splits — the single pipe was missed,
|
|
126
|
+
// so piped compositions taught no follows-edge.
|
|
127
|
+
const piped = graph.parseToolRuns("open-claudia tool run kticket list | open-claudia tool run report weekly");
|
|
128
|
+
assert.deepStrictEqual(piped.map((r) => r.name), ["kticket", "report"], "single-pipe composition parses both runs");
|
|
129
|
+
assert.strictEqual(piped[0].shape, "kticket list", "piped first run's args don't bleed into the second");
|
|
130
|
+
const bg = graph.parseToolRuns("open-claudia tool run a1 go & open-claudia tool run b2 go");
|
|
131
|
+
assert.deepStrictEqual(bg.map((r) => r.name), ["a1", "b2"], "background & splits too");
|
|
132
|
+
const quotedPipe = graph.parseToolRuns('open-claudia tool run kchat send "a | b" && open-claudia tool run other list');
|
|
133
|
+
assert.deepStrictEqual(quotedPipe.map((r) => r.name), ["kchat", "other"], "a pipe inside a quoted argument does not split");
|
|
134
|
+
assert.strictEqual(quotedPipe[0].shape, "kchat send <text>", "quoted argument still masks to <text>");
|
|
114
135
|
|
|
115
136
|
// ── recordPackTool: insert, then bump accumulates; packTools surfaces strongest ──
|
|
116
137
|
graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces docs --task-id <id> <path>", bump: 1 });
|
|
@@ -132,13 +153,15 @@ assert.ok(graph.toolPacks("spaces").some((u) => u.pack === "san-marco-agreements
|
|
|
132
153
|
assert.strictEqual(graph.stats().packToolEdges, graph.allPackToolEdges().length, "stats packToolEdges matches allPackToolEdges");
|
|
133
154
|
assert.ok(graph.stats().packToolEdges >= 2, "at least two pack↔tool edges present");
|
|
134
155
|
|
|
135
|
-
// ──
|
|
156
|
+
// ── prune: a year-old pack↔tool edge fades on read and is physically deleted ──
|
|
136
157
|
const ptDb = graph.openDb();
|
|
137
158
|
ptDb.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE pack=? AND tool=?")
|
|
138
159
|
.run(new Date(Date.now() - 365 * 86400000).toISOString(), "san-marco-agreements", "spaces");
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
assert.
|
|
160
|
+
assert.strictEqual(graph.packTools("san-marco-agreements").length, 0, "faded pack↔tool edges invisible to reads");
|
|
161
|
+
const pruneRes = graph.prune({ halfLifeDays: 1, pruneBelow: 0.15 });
|
|
162
|
+
assert.ok(pruneRes.pruned >= 1, "stale pack↔tool edge pruned");
|
|
163
|
+
assert.strictEqual(graph.allPackToolEdges().filter((e) => e.pack === "san-marco-agreements").length, 0,
|
|
164
|
+
"pruned pack↔tool rows physically gone");
|
|
142
165
|
|
|
143
166
|
// ── pruneOrphans: drops edges for a removed tool and (with packsLib) a removed pack ──
|
|
144
167
|
graph.recordPackTool("live-pack", "live-tool", { command: "live-tool go", bump: 1 });
|
|
@@ -152,5 +175,33 @@ assert.ok(graph.packTools("live-pack").some((u) => u.tool === "live-tool"), "liv
|
|
|
152
175
|
assert.ok(!graph.packTools("live-pack").some((u) => u.tool === "ghost-tool"), "missing-tool edge gone");
|
|
153
176
|
assert.strictEqual(graph.packTools("ghost-pack").length, 0, "missing-pack edge gone");
|
|
154
177
|
|
|
178
|
+
// ── v1 migration: legacy DBs (pre decay-on-read) get their clocks restarted on
|
|
179
|
+
// first open — stored weights are treated as current in both tables ──
|
|
155
180
|
graph._resetForTest();
|
|
181
|
+
process.env.TOOL_GRAPH_DB = path.join(tmp, "legacy.db");
|
|
182
|
+
{
|
|
183
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
184
|
+
const raw = new DatabaseSync(process.env.TOOL_GRAPH_DB);
|
|
185
|
+
const old = new Date(Date.now() - 200 * 86400000).toISOString();
|
|
186
|
+
raw.exec(`CREATE TABLE tool_edges (src TEXT NOT NULL, dst TEXT NOT NULL,
|
|
187
|
+
weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (src, dst))`);
|
|
188
|
+
raw.exec(`CREATE TABLE pack_tool_edges (pack TEXT NOT NULL, tool TEXT NOT NULL,
|
|
189
|
+
command TEXT NOT NULL DEFAULT '', intent TEXT NOT NULL DEFAULT '',
|
|
190
|
+
weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (pack, tool, command))`);
|
|
191
|
+
raw.prepare("INSERT INTO tool_edges (src, dst, weight, last_reinforced, created) VALUES (?,?,?,?,?)")
|
|
192
|
+
.run("l-a", "l-b", 3, old, old);
|
|
193
|
+
raw.prepare("INSERT INTO pack_tool_edges (pack, tool, command, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
|
|
194
|
+
.run("l-pack", "l-tool", "l-tool go", 2, old, old);
|
|
195
|
+
raw.close();
|
|
196
|
+
}
|
|
197
|
+
delete require.cache[require.resolve("./core/tool-graph")];
|
|
198
|
+
const graph2 = require("./core/tool-graph");
|
|
199
|
+
const lEdge = graph2.allEdges().find((e) => e.src === "l-a");
|
|
200
|
+
const lPt = graph2.allPackToolEdges().find((e) => e.pack === "l-pack");
|
|
201
|
+
assert.ok(Date.parse(lEdge.last_reinforced) > Date.now() - 60000, "migration restamps legacy tool edges");
|
|
202
|
+
assert.ok(Date.parse(lPt.last_reinforced) > Date.now() - 60000, "migration restamps legacy pack↔tool edges");
|
|
203
|
+
assert.strictEqual(lEdge.weight, 3, "stored weight untouched by migration");
|
|
204
|
+
assert.strictEqual(graph2.followers("l-a")[0].weight, 3, "restamped edge reads at full stored weight");
|
|
205
|
+
graph2._resetForTest();
|
|
206
|
+
|
|
156
207
|
console.log("tool-graph OK");
|
package/test-tool-manifest.js
CHANGED
|
@@ -106,5 +106,37 @@ const approvals = require("./core/approvals");
|
|
|
106
106
|
approvals.decide(rec3.id, "denied", "c1");
|
|
107
107
|
assert.strictEqual(await approvals.waitForDecision(rec3.id, { timeoutMs: 5000, pollMs: 50 }), "denied");
|
|
108
108
|
|
|
109
|
+
// ── B10: missing keyring creds are preflighted BEFORE the one-shot approval
|
|
110
|
+
// token is consumed. The old order burned the human's decision on a run that
|
|
111
|
+
// could never start; the same token must survive and redeem after the key
|
|
112
|
+
// is set, with the byte-identical command.
|
|
113
|
+
const toolCli = require("./bin/tool");
|
|
114
|
+
const b10src = path.join(tmp, "vaulted.sh");
|
|
115
|
+
fs.writeFileSync(b10src, "#!/usr/bin/env bash\necho ran-vaulted\n");
|
|
116
|
+
tools.addTool(b10src, { name: "vaulted", description: "needs a cred", risk: "destructive", requires: ["vaulted_api_key"] });
|
|
117
|
+
const b10cmd = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "vaulted", "go"]);
|
|
118
|
+
const b10rec = approvals.create({ tool: "vaulted", tier: "destructive", command: b10cmd, channelId: "c1", adapter: "tg" });
|
|
119
|
+
approvals.decide(b10rec.id, "approved", "c1");
|
|
120
|
+
const errs = [];
|
|
121
|
+
const realErr = console.error;
|
|
122
|
+
console.error = (...a) => errs.push(a.join(" "));
|
|
123
|
+
try {
|
|
124
|
+
process.exitCode = 0;
|
|
125
|
+
await toolCli.run(["run", "vaulted", "go", "--approval", b10rec.id]);
|
|
126
|
+
assert.strictEqual(process.exitCode, 1, "missing creds refuse the run with exit 1, not an approval error");
|
|
127
|
+
assert.ok(errs.some((l) => /missing keyring keys vaulted_api_key/.test(l)), "failure names the missing key");
|
|
128
|
+
assert.ok(errs.some((l) => /NOT consumed/.test(l)), "agent is told the token survived");
|
|
129
|
+
assert.strictEqual(approvals.read(b10rec.id).consumedAt, undefined, "the approval token is NOT burned by the creds failure");
|
|
130
|
+
|
|
131
|
+
fs.writeFileSync(process.env.KEYRING_FILE, JSON.stringify({ vaulted_api_key: "k" }));
|
|
132
|
+
process.exitCode = 0;
|
|
133
|
+
await toolCli.run(["run", "vaulted", "go", "--approval", b10rec.id]);
|
|
134
|
+
assert.strictEqual(process.exitCode, 0, "the same token redeems once the key exists");
|
|
135
|
+
assert.ok(approvals.read(b10rec.id).consumedAt, "token consumed exactly by the real run");
|
|
136
|
+
} finally {
|
|
137
|
+
console.error = realErr;
|
|
138
|
+
process.exitCode = 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
109
141
|
console.log("tool manifest OK");
|
|
110
142
|
})().catch((e) => { console.error(e); process.exit(1); });
|
package/test-tools.js
CHANGED
|
@@ -490,4 +490,35 @@ assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
|
|
|
490
490
|
assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
|
|
491
491
|
assert.ok(!fs.existsSync(path.join(process.env.TOOLS_DIR, "hello")), "removed tool's directory is gone");
|
|
492
492
|
|
|
493
|
+
// ── keyring durability (B6/B13): atomic save + .bak fallback on corruption ──
|
|
494
|
+
// A half-written keyring must not silently strip every tool of credentials:
|
|
495
|
+
// save() snapshots the last-good file, load() falls back to it and says so
|
|
496
|
+
// loudly on stderr (once per process) instead of returning {} in silence.
|
|
497
|
+
const keyring = require("./core/keyring");
|
|
498
|
+
const KR = process.env.KEYRING_FILE;
|
|
499
|
+
fs.rmSync(KR, { force: true });
|
|
500
|
+
fs.rmSync(KR + ".bak", { force: true });
|
|
501
|
+
keyring.set("kr_a", "1");
|
|
502
|
+
assert.ok(!fs.existsSync(KR + ".bak"), "first save has no previous version to snapshot");
|
|
503
|
+
keyring.set("kr_b", "2");
|
|
504
|
+
assert.deepStrictEqual(JSON.parse(fs.readFileSync(KR + ".bak", "utf-8")), { kr_a: "1" },
|
|
505
|
+
"second save snapshots the previous good version to .bak");
|
|
506
|
+
assert.strictEqual(fs.statSync(KR).mode & 0o777, 0o600, "keyring file is 0600 from birth");
|
|
507
|
+
const krErrs = [];
|
|
508
|
+
const realErr = console.error;
|
|
509
|
+
console.error = (...a) => krErrs.push(a.join(" "));
|
|
510
|
+
try {
|
|
511
|
+
fs.writeFileSync(KR, "{truncated-by-crash"); // simulate a torn write
|
|
512
|
+
assert.deepStrictEqual(keyring.all(), { kr_a: "1" }, "corrupt primary falls back to .bak snapshot");
|
|
513
|
+
assert.strictEqual(keyring.get("kr_a"), "1", "get() reads through the fallback");
|
|
514
|
+
keyring.all();
|
|
515
|
+
assert.strictEqual(krErrs.filter((l) => /corrupt/.test(l)).length, 1, "corruption is warned exactly once per process");
|
|
516
|
+
} finally {
|
|
517
|
+
console.error = realErr;
|
|
518
|
+
}
|
|
519
|
+
keyring.set("kr_c", "3"); // next save repairs the primary from the fallback view
|
|
520
|
+
assert.deepStrictEqual(JSON.parse(fs.readFileSync(KR, "utf-8")), { kr_a: "1", kr_c: "3" },
|
|
521
|
+
"save after corruption rewrites a valid primary from the recovered data");
|
|
522
|
+
assert.ok(!fs.readdirSync(tmp).some((f) => f.endsWith(".tmp")), "no stray temp files left behind by atomic writes");
|
|
523
|
+
|
|
493
524
|
console.log("tools OK");
|