@inetafrica/open-claudia 3.4.13 → 3.4.15
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 +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +171 -84
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +58 -6
- package/core/system-prompt.js +9 -1
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +217 -9
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const assert = require("assert");
|
|
4
4
|
const { EventEmitter } = require("events");
|
|
5
|
-
const { SpeechStream, synthesizePcm } = require("./channels/kazee/call/speech-client");
|
|
5
|
+
const { SpeechStream, synthesizePcm, detectTurn, selectAcknowledgement } = require("./channels/kazee/call/speech-client");
|
|
6
6
|
class FakeWebSocket extends EventEmitter {
|
|
7
7
|
static OPEN = 1;
|
|
8
8
|
constructor(url, options) { super(); this.url = url; this.options = options; this.readyState = 1; this.sent = []; queueMicrotask(() => this.emit("open")); }
|
|
@@ -14,12 +14,32 @@ class FakeWebSocket extends EventEmitter {
|
|
|
14
14
|
const stream = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket, onTranscript: (t) => { transcript = t; } });
|
|
15
15
|
await stream.connect();
|
|
16
16
|
assert.strictEqual(stream.socket.url, "wss://speech.example/v1/audio/transcriptions/stream");
|
|
17
|
-
assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en" });
|
|
17
|
+
assert.deepStrictEqual(JSON.parse(stream.socket.sent[0]), { sample_rate: 16000, language: "en", protocol: 2 });
|
|
18
18
|
await stream.audio(Buffer.from([1, 2]));
|
|
19
19
|
await stream.commit();
|
|
20
20
|
assert(Buffer.isBuffer(stream.socket.sent[1]));
|
|
21
21
|
stream.socket.emit("message", Buffer.from(JSON.stringify({ type: "final", text: "hello" })));
|
|
22
22
|
assert.strictEqual(transcript, "hello");
|
|
23
|
+
const finals = [], partials = [];
|
|
24
|
+
const versioned = new SpeechStream({ baseUrl: "https://speech.example", token: "secret", WebSocketImpl: FakeWebSocket,
|
|
25
|
+
onTranscript: (text, meta) => finals.push({ text, meta }), onPartial: (text, meta) => partials.push({ text, meta }) });
|
|
26
|
+
await versioned.connect();
|
|
27
|
+
const oldSocket = versioned.socket;
|
|
28
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "ready", protocol: 2, segment_id: 1 })));
|
|
29
|
+
const first = versioned.commitTurn(), second = versioned.commitTurn();
|
|
30
|
+
await Promise.all([first.pending, second.pending]);
|
|
31
|
+
assert.deepStrictEqual(oldSocket.sent.slice(1).map(JSON.parse), [{ type: "commit", segment_id: 1 }, { type: "commit", segment_id: 2 }]);
|
|
32
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "final", segment_id: 1, text: "" })));
|
|
33
|
+
assert.strictEqual(finals.length, 1, "empty final is forwarded to clear the committed turn");
|
|
34
|
+
assert.strictEqual(finals[0].meta.segmentId, 1);
|
|
35
|
+
oldSocket.close();
|
|
36
|
+
assert.strictEqual(versioned.protocol, 1, "disconnect resets protocol before a new turn can commit");
|
|
37
|
+
await versioned.connect();
|
|
38
|
+
oldSocket.emit("message", Buffer.from(JSON.stringify({ type: "final", segment_id: 2, text: "stale" })));
|
|
39
|
+
assert.strictEqual(finals.length, 1, "an old socket cannot deliver into a new stream");
|
|
40
|
+
assert.strictEqual(versioned.segmentId, 1);
|
|
41
|
+
versioned.close();
|
|
42
|
+
await assert.rejects(versioned.audio(Buffer.alloc(2)), /closed/, "terminal close cannot reopen a speech connection");
|
|
23
43
|
let request;
|
|
24
44
|
const pcm = await synthesizePcm("Hello", { baseUrl: "https://speech.example", token: "secret", fetchImpl: async (url, options) => {
|
|
25
45
|
request = { url, options }; return {
|
|
@@ -31,6 +51,22 @@ class FakeWebSocket extends EventEmitter {
|
|
|
31
51
|
assert.strictEqual(JSON.parse(request.options.body).response_format, "pcm");
|
|
32
52
|
assert.strictEqual(pcm.sampleRate, 24000);
|
|
33
53
|
assert.deepStrictEqual([...pcm.pcm], [1, 2, 3]);
|
|
54
|
+
const controller = { baseUrl: "https://speech.example", token: "fixture", fetchImpl: async (_url, options) => {
|
|
55
|
+
assert.strictEqual(JSON.parse(options.body).state, "waiting");
|
|
56
|
+
return { ok: true, json: async () => ({ choice: "wait", text: "Invented answer must never be spoken" }) };
|
|
57
|
+
} };
|
|
58
|
+
assert.strictEqual(await selectAcknowledgement("A complete question", "waiting", controller), "One moment.");
|
|
59
|
+
assert.strictEqual(await selectAcknowledgement("hello", "working", { ...controller,
|
|
60
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ choice: "acknowledge", text: "Mhm." }) }),
|
|
61
|
+
}), "", "wrong-state cue is omitted");
|
|
62
|
+
await assert.rejects(detectTurn(Buffer.alloc(640), { ...controller,
|
|
63
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ complete: true, probability: 3 }) }),
|
|
64
|
+
}), /Invalid turn decision/);
|
|
65
|
+
const abort = new AbortController();
|
|
66
|
+
abort.abort();
|
|
67
|
+
await assert.rejects(synthesizePcm("cancelled", { baseUrl: "https://speech.example", token: "fixture", signal: abort.signal,
|
|
68
|
+
fetchImpl: async (_url, options) => { assert(options.signal.aborted); throw new Error("aborted"); },
|
|
69
|
+
}), /aborted/, "TTS request inherits call cancellation");
|
|
34
70
|
stream.close();
|
|
35
71
|
console.log("kazee call speech: ok");
|
|
36
72
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -85,6 +85,11 @@ assert.throws(
|
|
|
85
85
|
);
|
|
86
86
|
|
|
87
87
|
const base = baseAgentEnv(source);
|
|
88
|
+
for (const builder of [baseAgentEnv, claudeAgentEnv, codexAgentEnv]) {
|
|
89
|
+
assert.strictEqual(builder({ ...source, OC_CALL_SPEECH_TOKEN: "stale-call-capability",
|
|
90
|
+
AGENT_ENV_PASSTHROUGH: "OC_CALL_SPEECH_TOKEN" }).OC_CALL_SPEECH_TOKEN, undefined,
|
|
91
|
+
"a call speech capability never passes through a generic provider environment");
|
|
92
|
+
}
|
|
88
93
|
assert.strictEqual(base.PROJECT_REGION, "fixture-region", "explicit project passthrough survives");
|
|
89
94
|
assert.deepStrictEqual(presence(base), Object.fromEntries(baseline.environment.keys.map((key) => [key, false])));
|
|
90
95
|
|
package/test-provider-gateway.js
CHANGED
|
@@ -382,7 +382,7 @@ async function schedulerProbe() {
|
|
|
382
382
|
console.log("provider gateway scheduler OK");
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
function queueProbe() {
|
|
385
|
+
async function queueProbe() {
|
|
386
386
|
const projectA = path.join(process.env.WORKSPACE, "project-a");
|
|
387
387
|
const projectB = path.join(process.env.WORKSPACE, "project-b");
|
|
388
388
|
fs.mkdirSync(projectA, { recursive: true });
|
|
@@ -455,6 +455,20 @@ function queueProbe() {
|
|
|
455
455
|
assert.strictEqual(queued.runContext.project.dir, projectA);
|
|
456
456
|
assert.strictEqual(queued.runContext.providerSettings.model, "claude-fixture");
|
|
457
457
|
assert.strictEqual(queued.runContext.sessionId, "claude-queued-session");
|
|
458
|
+
const obsoleteCall = new AbortController();
|
|
459
|
+
store.adapter.liveCall = true;
|
|
460
|
+
store.adapter.callTurn = { signal: obsoleteCall.signal };
|
|
461
|
+
const callPending = runAgent("obsolete call question", projectA, null, { runId: "obsolete-call" });
|
|
462
|
+
assert.strictEqual(state.messageQueue.length, 2);
|
|
463
|
+
obsoleteCall.abort();
|
|
464
|
+
assert.strictEqual(state.messageQueue.length, 1, "superseded call entry is removed immediately, ordinary queued work is preserved");
|
|
465
|
+
assert.strictEqual((await callPending).status, "cancelled");
|
|
466
|
+
assert.strictEqual(state.messageQueue[0], queued);
|
|
467
|
+
const alreadyEnded = await runAgent("late call dispatch", projectA, null, { runId: "late-call" });
|
|
468
|
+
assert.strictEqual(alreadyEnded.status, "cancelled");
|
|
469
|
+
assert.strictEqual(state.messageQueue.length, 1, "an already-ended call never joins the queue");
|
|
470
|
+
delete store.adapter.callTurn;
|
|
471
|
+
delete store.adapter.liveCall;
|
|
458
472
|
console.log("provider gateway immutable queue OK");
|
|
459
473
|
}
|
|
460
474
|
|
|
@@ -464,9 +464,11 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
464
464
|
// tool_name "Bash" and the deny gate caught the probe, so the gate no longer
|
|
465
465
|
// requires unified_exec off. Push approved by Sumeet before release.
|
|
466
466
|
// 3.4.12: Sumeet approved the full calling rollout on 2026-09-05; preserve assigned media configuration across canary changes.
|
|
467
|
+
// 3.4.14: bot managed-call consumer (subscribe only to gateway-authenticated User publishers) — part of the Janus calling plan Sumeet approved on 2026-09-05.
|
|
467
468
|
// 3.4.13: Codex usage accounting fix — Sumeet approved the three-bug fix on 2026-09-05 ("Yes");
|
|
468
469
|
// prepared 2026-09-06 for his release go. Rollout-based Codex context, per-turn ledger, sub-agent + quota accounting.
|
|
469
|
-
|
|
470
|
+
// 3.4.15: conversation flow and checked sentence speech; authorized calling rollout, 2026-09-05/06.
|
|
471
|
+
assert.strictEqual(pkg.version, "3.4.15", "release version must remain unchanged without explicit approval");
|
|
470
472
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
471
473
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
472
474
|
}
|
|
@@ -30,7 +30,17 @@ async function probe() {
|
|
|
30
30
|
origin: { transport: "telegram", channelId: "parity-channel" },
|
|
31
31
|
},
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
const { chatContext } = require("./core/context");
|
|
34
|
+
const voiceBundle = await chatContext.run({ adapter: { type: "kazee", liveCall: true }, channelId: "parity-channel", canonicalUserId: state.userId }, () => buildTurnPrompt("Explain that beep.", {
|
|
35
|
+
provider: getProvider("codex"),
|
|
36
|
+
runContext: {
|
|
37
|
+
canonicalUserId: state.userId, project: { ...state.currentSession }, provider: "codex",
|
|
38
|
+
providerSettings: { ...state.settings }, sessionId: null, fresh: true, liveCall: true,
|
|
39
|
+
channelId: "parity-channel", origin: { transport: "kazee", channelId: "parity-channel" },
|
|
40
|
+
callHistory: [{ role: "user", text: "I heard a beep before you answered." }],
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ bundle, voiceBundle })}\n`);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
if (process.argv[2] === "--probe") {
|
|
@@ -77,6 +87,11 @@ if (process.argv[2] === "--probe") {
|
|
|
77
87
|
assert.ok(observed.bundle.dynamicContext.includes("## Project Transcript Memory"));
|
|
78
88
|
assert.ok(observed.bundle.dynamicContext.includes("Codex exposes its native exec tools"));
|
|
79
89
|
assert.ok(observed.bundle.recallMetadata);
|
|
90
|
+
assert.strictEqual(observed.voiceBundle.coreInstructions, observed.bundle.coreInstructions, "bounded call conversation keeps the complete core policy");
|
|
91
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("LIVE VOICE CALL"));
|
|
92
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("I heard a beep before you answered."));
|
|
93
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("Do not claim an earlier request was acted on without tool evidence."));
|
|
94
|
+
assert.strictEqual(observed.voiceBundle.userPrompt, "Explain that beep.");
|
|
80
95
|
assert.notStrictEqual(
|
|
81
96
|
`${observed.bundle.dynamicContext}\n\n${observed.bundle.userPrompt}`,
|
|
82
97
|
baseline.prompt.sentinel,
|
package/test-provider-runner.js
CHANGED
|
@@ -132,6 +132,40 @@ async function main() {
|
|
|
132
132
|
codex: path.join(root, "codex-capture.json"),
|
|
133
133
|
};
|
|
134
134
|
const providers = makeProviders(captures);
|
|
135
|
+
const voiceStore = { adapter: { liveCall: true, callHistory: [{ role: "user", text: "What about the beeping?" }] } };
|
|
136
|
+
mutable.activeSessions["project-a"].claude = "long-coding-session";
|
|
137
|
+
const voiceContext = captureRunContext({ state: mutable, store: voiceStore, prompt: "What does it mean?", cwd: projectA,
|
|
138
|
+
opts: { resumeSessionId: "long-coding-session", continueSession: true } });
|
|
139
|
+
assert.strictEqual(voiceContext.sessionId, null, "call must not resume the room's coding conversation");
|
|
140
|
+
assert.strictEqual(voiceContext.resumeSessionId, null);
|
|
141
|
+
assert.strictEqual(voiceContext.continueSession, false);
|
|
142
|
+
assert.strictEqual(voiceContext.fresh, true);
|
|
143
|
+
assert.strictEqual(voiceContext.attachSession, false, "call completion must not replace the coding session");
|
|
144
|
+
assert.strictEqual(voiceContext.skipAutoCompact, true, "call cannot compact the room's coding session");
|
|
145
|
+
voiceStore.adapter.callHistory[0].text = "later mutation";
|
|
146
|
+
assert.strictEqual(voiceContext.callHistory[0].text, "What about the beeping?", "queued history is immutable");
|
|
147
|
+
assert.ok(Object.isFrozen(voiceContext.callHistory[0]));
|
|
148
|
+
delete mutable.activeSessions["project-a"].claude;
|
|
149
|
+
|
|
150
|
+
let callObsolete = false;
|
|
151
|
+
let callBuilds = 0;
|
|
152
|
+
let callSpawns = 0;
|
|
153
|
+
const callGate = deferred();
|
|
154
|
+
const cancellableCall = createRunnerService({
|
|
155
|
+
getState: () => mutable,
|
|
156
|
+
getStore: () => voiceStore,
|
|
157
|
+
resolveProvider: id => providers[id],
|
|
158
|
+
isObsolete: () => callObsolete,
|
|
159
|
+
async buildTurnPrompt(prompt, opts) { callBuilds++; await callGate.promise; return simpleBundle(prompt, opts); },
|
|
160
|
+
spawnProcess() { callSpawns++; throw new Error("obsolete call must never spawn"); },
|
|
161
|
+
});
|
|
162
|
+
const preparingCall = cancellableCall.runAgent("old question", projectA, null);
|
|
163
|
+
callObsolete = true;
|
|
164
|
+
callGate.resolve();
|
|
165
|
+
assert.strictEqual((await preparingCall).status, "cancelled", "hangup during recall prevents provider execution");
|
|
166
|
+
assert.strictEqual(callSpawns, 0);
|
|
167
|
+
assert.strictEqual((await cancellableCall.runAgent("late dispatch", projectA, null)).status, "cancelled");
|
|
168
|
+
assert.strictEqual(callBuilds, 1, "obsolete call avoids even a prompt/recall build");
|
|
135
169
|
const persisted = [];
|
|
136
170
|
const delivered = [];
|
|
137
171
|
const service = createRunnerService({
|
package/test-tool-manifest.js
CHANGED
|
@@ -16,6 +16,9 @@ process.env.APPROVALS_DIR = path.join(tmp, "approvals");
|
|
|
16
16
|
const tools = require("./core/tools");
|
|
17
17
|
const repo = require("./core/tool-repo");
|
|
18
18
|
const approvals = require("./core/approvals");
|
|
19
|
+
// The runner may itself be an approved composing tool. These fixtures model
|
|
20
|
+
// top-level calls; keep process.env intact and inject each scenario explicitly.
|
|
21
|
+
const topLevelEnv = {};
|
|
19
22
|
|
|
20
23
|
(async () => {
|
|
21
24
|
// ── scaffold with --verbs → manifest header lines, dispatcher, TOOL.md block ──
|
|
@@ -39,32 +42,32 @@ const approvals = require("./core/approvals");
|
|
|
39
42
|
assert.ok(/list\)/.test(t.content) && /purge\)/.test(t.content), "bash dispatcher has a case per verb");
|
|
40
43
|
|
|
41
44
|
// ── per-verb gate: tier comes from the invoked verb, not the tool tier ──
|
|
42
|
-
assert.strictEqual(tools.runGate(t, ["list"]).ok, true, "read-only verb runs unflagged");
|
|
43
|
-
assert.strictEqual(tools.runGate(t, ["list"]).tier, "read-only");
|
|
44
|
-
assert.strictEqual(tools.runGate(t, ["restart", "n1"]).ok, false, "write verb demands --yes");
|
|
45
|
-
assert.strictEqual(tools.runGate(t, ["restart", "n1", "--yes"]).ok, true);
|
|
46
|
-
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes"]).ok, false, "--yes cannot approve a destructive verb");
|
|
47
|
-
const purgeGate = tools.runGate(t, ["purge", "n1"]);
|
|
45
|
+
assert.strictEqual(tools.runGate(t, ["list"], topLevelEnv).ok, true, "read-only verb runs unflagged");
|
|
46
|
+
assert.strictEqual(tools.runGate(t, ["list"], topLevelEnv).tier, "read-only");
|
|
47
|
+
assert.strictEqual(tools.runGate(t, ["restart", "n1"], topLevelEnv).ok, false, "write verb demands --yes");
|
|
48
|
+
assert.strictEqual(tools.runGate(t, ["restart", "n1", "--yes"], topLevelEnv).ok, true);
|
|
49
|
+
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes"], topLevelEnv).ok, false, "--yes cannot approve a destructive verb");
|
|
50
|
+
const purgeGate = tools.runGate(t, ["purge", "n1"], topLevelEnv);
|
|
48
51
|
assert.strictEqual(purgeGate.tier, "destructive", "failed gate reports the resolved tier for the approval fallback");
|
|
49
|
-
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"]).ok, true);
|
|
52
|
+
assert.strictEqual(tools.runGate(t, ["purge", "n1", "--yes-destructive"], topLevelEnv).ok, true);
|
|
50
53
|
|
|
51
54
|
// ── undeclared verb on a manifest tool is refused regardless of flags ──
|
|
52
|
-
const un = tools.runGate(t, ["obliterate", "--yes-destructive"]);
|
|
55
|
+
const un = tools.runGate(t, ["obliterate", "--yes-destructive"], topLevelEnv);
|
|
53
56
|
assert.strictEqual(un.ok, false, "undeclared verb refused");
|
|
54
57
|
assert.strictEqual(un.undeclared, true);
|
|
55
58
|
assert.ok(/no declared verb "obliterate"/.test(un.message));
|
|
56
59
|
|
|
57
60
|
// ── help / no verb = read-only ──
|
|
58
|
-
assert.strictEqual(tools.runGate(t, []).ok, true, "no verb (help) runs unflagged");
|
|
59
|
-
assert.strictEqual(tools.runGate(t, ["help"]).tier, "read-only");
|
|
61
|
+
assert.strictEqual(tools.runGate(t, [], topLevelEnv).ok, true, "no verb (help) runs unflagged");
|
|
62
|
+
assert.strictEqual(tools.runGate(t, ["help"], topLevelEnv).tier, "read-only");
|
|
60
63
|
|
|
61
64
|
// ── manifest-less tools keep the old whole-tool gate (backward compatible) ──
|
|
62
65
|
const legacySrc = path.join(tmp, "old.sh");
|
|
63
66
|
fs.writeFileSync(legacySrc, "#!/usr/bin/env bash\necho old\n");
|
|
64
67
|
const legacy = tools.addTool(legacySrc, { name: "old", description: "legacy", risk: "write" });
|
|
65
68
|
assert.strictEqual(legacy.manifest.length, 0);
|
|
66
|
-
assert.strictEqual(tools.runGate(legacy, ["anything"]).ok, false, "manifest-less write tool still needs --yes");
|
|
67
|
-
assert.strictEqual(tools.runGate(legacy, ["anything", "--yes"]).ok, true, "…and any verb is allowed with it");
|
|
69
|
+
assert.strictEqual(tools.runGate(legacy, ["anything"], topLevelEnv).ok, false, "manifest-less write tool still needs --yes");
|
|
70
|
+
assert.strictEqual(tools.runGate(legacy, ["anything", "--yes"], topLevelEnv).ok, true, "…and any verb is allowed with it");
|
|
68
71
|
|
|
69
72
|
// ── nested composition still gates on the inherited tier, per verb ──
|
|
70
73
|
const env = { OPEN_CLAUDIA_TOOL_STACK: "outer", OPEN_CLAUDIA_APPROVED_TIER: "write" };
|
|
@@ -119,6 +122,12 @@ const approvals = require("./core/approvals");
|
|
|
119
122
|
approvals.decide(b10rec.id, "approved", "c1");
|
|
120
123
|
const errs = [];
|
|
121
124
|
const realErr = console.error;
|
|
125
|
+
// This integration fixture redeems a top-level approval against the isolated
|
|
126
|
+
// tools/keyring above. A nested tool must refuse redemption, so temporarily
|
|
127
|
+
// model the top-level CLI environment and restore the launcher's context.
|
|
128
|
+
const approvalKeys = ["OPEN_CLAUDIA_TOOL_STACK", "OPEN_CLAUDIA_APPROVED_TIER"];
|
|
129
|
+
const savedApprovalEnv = Object.fromEntries(approvalKeys.map((key) => [key, process.env[key]]));
|
|
130
|
+
for (const key of approvalKeys) delete process.env[key];
|
|
122
131
|
console.error = (...a) => errs.push(a.join(" "));
|
|
123
132
|
try {
|
|
124
133
|
process.exitCode = 0;
|
|
@@ -134,6 +143,10 @@ const approvals = require("./core/approvals");
|
|
|
134
143
|
assert.strictEqual(process.exitCode, 0, "the same token redeems once the key exists");
|
|
135
144
|
assert.ok(approvals.read(b10rec.id).consumedAt, "token consumed exactly by the real run");
|
|
136
145
|
} finally {
|
|
146
|
+
for (const key of approvalKeys) {
|
|
147
|
+
if (savedApprovalEnv[key] === undefined) delete process.env[key];
|
|
148
|
+
else process.env[key] = savedApprovalEnv[key];
|
|
149
|
+
}
|
|
137
150
|
console.error = realErr;
|
|
138
151
|
process.exitCode = 0;
|
|
139
152
|
}
|
package/test-tools.js
CHANGED
|
@@ -250,14 +250,18 @@ assert.strictEqual(tools.findTool("hello").createdAt, bornHello, "createdAt is i
|
|
|
250
250
|
|
|
251
251
|
// ── runGate: read-only runs freely; write demands --yes; destructive demands
|
|
252
252
|
// --yes-destructive (a plain --yes must NOT unlock it); undeclared → write ──
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
assert.ok(tools.runGate({ name: "t", risk: "
|
|
258
|
-
|
|
253
|
+
// These fixtures model top-level invocations even when npm test is launched
|
|
254
|
+
// through an approved tool. Keep the real process approval environment intact;
|
|
255
|
+
// nested composition is exercised with explicit environments below.
|
|
256
|
+
const topLevelEnv = {};
|
|
257
|
+
assert.ok(tools.runGate({ name: "t", risk: "read-only" }, [], topLevelEnv).ok, "read-only runs without flags");
|
|
258
|
+
assert.ok(!tools.runGate({ name: "t", risk: "write" }, [], topLevelEnv).ok, "write without --yes is refused");
|
|
259
|
+
assert.ok(tools.runGate({ name: "t", risk: "write" }, ["--yes"], topLevelEnv).ok, "write with --yes runs");
|
|
260
|
+
assert.ok(!tools.runGate({ name: "t", risk: "destructive" }, ["--yes"], topLevelEnv).ok, "plain --yes does not unlock destructive");
|
|
261
|
+
assert.ok(tools.runGate({ name: "t", risk: "destructive" }, ["--yes-destructive"], topLevelEnv).ok, "destructive with --yes-destructive runs");
|
|
262
|
+
const undeclared = tools.runGate({ name: "t", risk: "" }, [], topLevelEnv);
|
|
259
263
|
assert.ok(!undeclared.ok && undeclared.tier === "write", "undeclared tier is treated as write (fail-safe)");
|
|
260
|
-
assert.ok(/destructive/i.test(tools.runGate({ name: "t", risk: "destructive" }, []).message), "destructive refusal spells out the warning");
|
|
264
|
+
assert.ok(/destructive/i.test(tools.runGate({ name: "t", risk: "destructive" }, [], topLevelEnv).message), "destructive refusal spells out the warning");
|
|
261
265
|
|
|
262
266
|
// ── scaffold: creates dir + stub + TOOL.md, registered and runnable ──
|
|
263
267
|
const sc = repo.scaffoldTool("kwidget", { description: "widget system CLI", risk: "read-only", pack: "widgets", usage: "kwidget <verb>" });
|