@chibitek/chibi 0.1.155 → 0.1.158
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/dist/assets/{abnfDiagram-O67JEVCF-B8LdsCr1.js → abnfDiagram-O67JEVCF-B8-8zzMS.js} +1 -1
- package/dist/assets/{arc-DU5fSmKU.js → arc-CSHQ9Dx3.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-DdQSIFKv.js → architectureDiagram-NJMV4G6O-BCHM9rXW.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CaPcGYSS.js → blockDiagram-BEXU5L5S-B-5uxPFN.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-unt0ePgO.js → c4Diagram-YGBWAQC7-CuKNM_MR.js} +1 -1
- package/dist/assets/channel-bPN03jIt.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-Babg5Fzu.js → chunk-2Q5K7J3B-BNF02GM4.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-D1-dWVHo.js → chunk-3FUC2YCW-uEWgBd4M.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-DU7nSMbT.js → chunk-5VM5RSS4-eSZuXctY.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-QIjCJIhV.js → chunk-GTNCS2PH-CslS8nRS.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-pSByUE5R.js → chunk-GWA4HPMP-w71-MeKj.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-Dj2AaR1b.js → chunk-JWPE2WC7-Bjju7WGh.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-pZbN0-FP.js → chunk-NETBCI7D-HxrroD4M.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-Bqarwe4D.js → chunk-WEXAMYUT-D7Rz470x.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-WV8lm3aa.js → chunk-XXDRQBXY-BWKnAeyM.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BANBtky1.js → classDiagram-v2-NBCMYWYE-CeBDRHmP.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-CtrktgBC.js → cose-bilkent-JH36ORCC-BBbRKlvr.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-DnhVVfba.js → cynefin-OW5HDTMX-W02cnJ0B.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-B42SRR6x.js → cynefinDiagram-VND7K2PF-Cs47eaxd.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DM--vFMK.js → dagre-6A5THRUB-8mG18hzT.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-ZBupBrPU.js → diagram-22UHCM2B-DF4Qrw67.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-BPQzNIOd.js → diagram-3UASUU5V-3BAVoed1.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-5kO8vf8Z.js → diagram-ATOU4E4O-CKO3pECE.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-DtxogB86.js → diagram-CDSNMT55-CZhrGvur.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-Co9-nYw_.js → diagram-MLGK6HIB-C07l9pdQ.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-DWRuMh5y.js → diagram-MPIPVDR6-D3or4lkZ.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DU_nmoG9.js → ebnfDiagram-ZINNZB2B-DV2epXp_.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-Bcje7S94.js → elk-276RUBZZ-DlvEn4-1.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-9aCo_2CI.js → erDiagram-OPXOYQCR-CB6X7Wt5.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-D4FYFyc5.js → flowDiagram-KWPJA3E3-C3bqz4lC.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-WQHX5rbx.js → ganttDiagram-FUAMR5RP-yo_Evhab.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-DRyzBdiV.js → gitGraphDiagram-X574FWY7-Cq2oUlap.js} +1 -1
- package/dist/assets/{index-CG1OvRgO.js → index-CG4AFPb4.js} +1 -1
- package/dist/assets/{index-D_8SD_0K.js → index-CMcr5k3Z.js} +205 -200
- package/dist/assets/index-CZjRcUdn.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-D7SOmp4n.js → infoDiagram-VRGFBTTK-Y3ZrY7qL.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-CdxOnDVX.js → ishikawaDiagram-OU5B5YK6-BpOtSfyi.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-CH7477Oa.js → journeyDiagram-ZHPQQLJL-UQM727le.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-BdpVvkHd.js → kanban-definition-PNTS6WVX-C3s5oHpY.js} +1 -1
- package/dist/assets/{linear-DTGq6qn5.js → linear-hX6nFOQr.js} +1 -1
- package/dist/assets/{mermaid.core-Sn2sv0Ti.js → mermaid.core-DZObGLN7.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-koCmkYuq.js → mindmap-definition-NLK3R4M7-Br-i2E3P.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-C3sqhm8e.js → pegDiagram-GJSIUBJH-D5SERB-N.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BELBK122.js → pieDiagram-5QR66LMP-BPNf4J3D.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Cksi5LSm.js → quadrantDiagram-O4NWA36T-Bm_Wh7Jr.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-f1av_EZW.js → railroadDiagram-XR7U4H2S-D6bo0abD.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CW4O5nER.js → requirementDiagram-PLB6GJNP--1n5rkV7.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-Dz8ADHNY.js → sankeyDiagram-IPEJSGJF-Ct4tP2kK.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-Cc3HE0iZ.js → sequenceDiagram-PO4LG4MO-CCG7O5fR.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-gdaZNHnK.js → sizeCapture-INFHLROL-CJJJra9c.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK--u8LCnab.js → stateDiagram-v2-GCMORJYK-BMdRvVEX.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-4MAw4kdO.js → swimlanes-2SLR337P-B1wnogip.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-DWyjdGGR.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-CL89YkCT.js → timeline-definition-EJHVYXUP-l-3yhXUP.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-C92l7YQe.js → usecaseDiagram-POWQR4AR-DtwtCOoJ.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-17siYhSj.js → vennDiagram-UO4OBE2U-BZAEce1B.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-CNwa9VmN.js → wardleyDiagram-VNRHLVJA-Na_BCMMO.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-Dnu6Xzih.js → xychartDiagram-PMCCYNJV-BW9dlUfE.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +68 -14
- package/dist-server/index.js +5633 -4562
- package/dist-server/openmausbot.js +140 -81
- package/dist-server/pair-cli.js +140 -81
- package/dist-server/server/app-installers.js +107 -7
- package/dist-server/server/avatar-subscription.js +51 -0
- package/dist-server/server/cli-help.js +74 -0
- package/dist-server/server/cli.js +37 -78
- package/dist-server/server/colima-docker.js +136 -0
- package/dist-server/server/container-computer.js +31 -63
- package/dist-server/server/container-mcp.js +5 -0
- package/dist-server/server/device-routing.js +60 -0
- package/dist-server/server/drivers/claude.js +13 -11
- package/dist-server/server/drivers/local-inject.js +3 -4
- package/dist-server/server/engine-failover.js +22 -12
- package/dist-server/server/engine-tiers.js +5 -14
- package/dist-server/server/index.js +124 -120
- package/dist-server/server/personal-goal-runtime.js +86 -0
- package/dist-server/server/personal-goal-turn.js +125 -0
- package/dist-server/server/personal-goals.js +195 -0
- package/dist-server/server/port-in-use.js +21 -0
- package/dist-server/server/routes/local-models.js +3 -2
- package/dist-server/server/session-fallback.js +76 -0
- package/dist-server/server/skill-fetch.js +14 -0
- package/dist-server/server/vault/routes.js +43 -0
- package/dist-server/server/vault/runtime.js +48 -0
- package/dist-server/server/vault/store.js +177 -0
- package/dist-server/shared/desktop-private-port.js +1 -0
- package/dist-server/shared/engine-choices.js +15 -0
- package/dist-server/shared/engine-tiers.js +74 -2
- package/dist-server/shared/local-models.js +1 -3
- package/dist-server/shared/personal-goal.js +43 -0
- package/dist-server/shared/subscription-bots.js +3 -0
- package/dist-server/shared/vault.js +59 -0
- package/dist-server/vps-container-mcp.js +4 -2
- package/package.json +1 -1
- package/dist/assets/channel-GBaPOaz-.js +0 -1
- package/dist/assets/index-C8YP7Zxj.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CamSa0NE.js +0 -8
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { GOAL_HELP } from "../shared/personal-goal.js";
|
|
2
|
+
import { PersonalGoals } from "./personal-goals.js";
|
|
3
|
+
/** Serial continuation per goal; every provider turn still passes normal admission. */
|
|
4
|
+
export class PersonalGoalRuntime {
|
|
5
|
+
goals;
|
|
6
|
+
running = new Set();
|
|
7
|
+
scheduled = new Map();
|
|
8
|
+
host;
|
|
9
|
+
constructor(path, host) {
|
|
10
|
+
this.host = host;
|
|
11
|
+
this.goals = new PersonalGoals(path, (goal) => host.changed(goal));
|
|
12
|
+
}
|
|
13
|
+
async command(botId, threadId, command, trigger) {
|
|
14
|
+
if (command.action === "help")
|
|
15
|
+
return GOAL_HELP;
|
|
16
|
+
if (this.goals.loadError)
|
|
17
|
+
return this.goals.loadError;
|
|
18
|
+
let goal = this.goals.get(threadId);
|
|
19
|
+
if (command.action === "status")
|
|
20
|
+
return goal ? this.describe(goal) : "This conversation has no goal. " + GOAL_HELP;
|
|
21
|
+
if (command.action === "start" || command.action === "resume") {
|
|
22
|
+
if (this.host.busy(botId, threadId) || (goal && this.running.has(goal.id)))
|
|
23
|
+
throw new Error("This conversation is still working. Wait for its turn to settle.");
|
|
24
|
+
goal = command.action === "start" ? this.goals.start(botId, threadId, { ...command, ...(trigger?.kind === "user" || trigger?.kind === "owner" ? { trigger } : {}) }) : this.goals.resume(threadId, command);
|
|
25
|
+
// Persist the human command before provider setup reads the conversation.
|
|
26
|
+
this.running.add(goal.id);
|
|
27
|
+
this.scheduled.set(goal.id, setImmediate(() => { this.scheduled.delete(goal.id); void this.run(goal); }));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
goal = this.goals.control(threadId, command.action);
|
|
31
|
+
const pending = this.scheduled.get(goal.id);
|
|
32
|
+
if (pending) {
|
|
33
|
+
clearImmediate(pending);
|
|
34
|
+
this.scheduled.delete(goal.id);
|
|
35
|
+
this.running.delete(goal.id);
|
|
36
|
+
}
|
|
37
|
+
if (this.running.has(goal.id))
|
|
38
|
+
await this.host.interrupt(botId, threadId, goal.id);
|
|
39
|
+
}
|
|
40
|
+
return this.describe(goal);
|
|
41
|
+
}
|
|
42
|
+
describe(goal) {
|
|
43
|
+
return `${goal.status}: ${goal.objective}\n${goal.detail}\nTurns: ${goal.turnCount}/${goal.maxTurns}. Tokens: ${goal.tokensUsed}${goal.tokenBudget === undefined ? "" : `/${goal.tokenBudget}`}.`;
|
|
44
|
+
}
|
|
45
|
+
steer(threadId) {
|
|
46
|
+
const goal = this.goals.get(threadId);
|
|
47
|
+
if (goal?.status === "working") {
|
|
48
|
+
goal.status = "paused";
|
|
49
|
+
goal.detail = "You sent a message. Use /goal resume when ready to continue.";
|
|
50
|
+
this.goals.touch(goal);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async run(goal) {
|
|
54
|
+
this.running.add(goal.id);
|
|
55
|
+
try {
|
|
56
|
+
while (this.goals.begin(goal.id)) {
|
|
57
|
+
const result = await this.host.run(goal);
|
|
58
|
+
this.goals.settle(goal.id, result);
|
|
59
|
+
// Give queued human input priority before reserving another turn.
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
if (goal.status === "working") {
|
|
65
|
+
goal.status = "paused";
|
|
66
|
+
goal.detail = "The next turn could not start. Check the conversation and use /goal resume.";
|
|
67
|
+
this.goals.touch(goal);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
this.running.delete(goal.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function receiveGoalCommand(runtime, store, botId, threadId, command, text, sendId, sender, trigger) {
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = await runtime.command(botId, threadId, command, trigger);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 409 });
|
|
82
|
+
}
|
|
83
|
+
const message = store.appendMessage(threadId, { role: "user", kind: "text", text, sendId, sender });
|
|
84
|
+
store.appendMessage(threadId, { role: "bot", kind: "text", text: result });
|
|
85
|
+
return { ok: true, threadId, message, goalSummary: result };
|
|
86
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export function updatePersonalGoalReceipt(goal, store) {
|
|
2
|
+
if (!store.taskByThread(goal.botId, goal.threadId))
|
|
3
|
+
return;
|
|
4
|
+
const goalRun = { runId: goal.id, goal: goal.objective, status: goal.status,
|
|
5
|
+
coordinatorBotId: goal.botId, coordinatorName: store.bot(goal.botId)?.name ?? "Bot",
|
|
6
|
+
turnCount: goal.turnCount, maxTurns: goal.maxTurns, detail: `${goal.detail} Tokens: ${goal.tokensUsed}${goal.tokenBudget === undefined ? "" : `/${goal.tokenBudget}`}.`, startedAt: goal.createdAt };
|
|
7
|
+
if (goal.receiptMessageId)
|
|
8
|
+
store.patchMessage(goal.threadId, goal.receiptMessageId, { goalRun });
|
|
9
|
+
else
|
|
10
|
+
goal.receiptMessageId = store.appendMessage(goal.threadId, { role: "bot", kind: "goal.run", goalRun }).id;
|
|
11
|
+
}
|
|
12
|
+
export async function runPersonalGoalTurn(goal, host) {
|
|
13
|
+
const guard = { token: Symbol("personal-goal"), personalGoalId: goal.id, assistantItems: [], discard: false };
|
|
14
|
+
let ok = false, tools = 0, measured = false, completed = false;
|
|
15
|
+
let tokens;
|
|
16
|
+
const before = host.store.taskByThread(goal.botId, goal.threadId)?.usage;
|
|
17
|
+
const beforeTokens = (before?.input ?? 0) + (before?.output ?? 0);
|
|
18
|
+
host.addGuard(goal.threadId, guard);
|
|
19
|
+
const unsub = host.subscribe((event) => {
|
|
20
|
+
if (event.threadId !== goal.threadId || host.ignore(event))
|
|
21
|
+
return;
|
|
22
|
+
if (guard.turnId && event.turnId && guard.turnId !== event.turnId)
|
|
23
|
+
return;
|
|
24
|
+
if (event.type === "item.completed" && event.itemType === "tool")
|
|
25
|
+
tools++;
|
|
26
|
+
if (event.type === "thread.token-usage.updated")
|
|
27
|
+
measured = true;
|
|
28
|
+
if (event.type === "turn.completed") {
|
|
29
|
+
completed = true;
|
|
30
|
+
ok = event.ok;
|
|
31
|
+
measured ||= event.usage !== undefined;
|
|
32
|
+
const usage = host.store.taskByThread(goal.botId, goal.threadId)?.usage;
|
|
33
|
+
if (measured)
|
|
34
|
+
tokens = Math.max(0, (usage?.input ?? 0) + (usage?.output ?? 0) - beforeTokens);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
void host.start(goal.botId, goal.turnCount === 1 ? goal.objective : "Continue the active goal.", {
|
|
40
|
+
threadId: goal.threadId, cardContinuation: true, trigger: goal.trigger,
|
|
41
|
+
onTurnSettled: () => setImmediate(resolve), onDispatchError: reject,
|
|
42
|
+
}).catch(reject);
|
|
43
|
+
});
|
|
44
|
+
// The terminal event precedes digest/screen resource release. Never overlap those claims.
|
|
45
|
+
const deadline = Date.now() + 30_000;
|
|
46
|
+
while (host.busy(goal.botId, goal.threadId) && Date.now() < deadline)
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
48
|
+
if (host.busy(goal.botId, goal.threadId))
|
|
49
|
+
throw new Error("Turn is still settling");
|
|
50
|
+
return { ok, text: guard.assistantItems.join("\n"), tools, tokens };
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
unsub();
|
|
54
|
+
// Before a provider id exists, direct-turn admission owns late setup events.
|
|
55
|
+
// Keeping an unbound guard here would swallow an unrelated later turn.
|
|
56
|
+
if (completed || !guard.turnId)
|
|
57
|
+
host.removeGuard(goal.threadId, guard);
|
|
58
|
+
else {
|
|
59
|
+
guard.discard = true;
|
|
60
|
+
guard.cleanupTimer = setTimeout(() => host.removeGuard(goal.threadId, guard), 5 * 60_000);
|
|
61
|
+
guard.cleanupTimer.unref();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function addGoalGuard(groupGoalCoordinatorTurns, threadId, turn) {
|
|
66
|
+
const turns = groupGoalCoordinatorTurns.get(threadId) ?? new Set();
|
|
67
|
+
turns.add(turn);
|
|
68
|
+
groupGoalCoordinatorTurns.set(threadId, turns);
|
|
69
|
+
}
|
|
70
|
+
export function removeGoalGuard(groupGoalCoordinatorTurns, threadId, turn) {
|
|
71
|
+
if (turn.cleanupTimer)
|
|
72
|
+
clearTimeout(turn.cleanupTimer);
|
|
73
|
+
const turns = groupGoalCoordinatorTurns.get(threadId);
|
|
74
|
+
turns?.delete(turn);
|
|
75
|
+
if (turns?.size === 0)
|
|
76
|
+
groupGoalCoordinatorTurns.delete(threadId);
|
|
77
|
+
}
|
|
78
|
+
export function hasUnboundGoalGuard(groupGoalCoordinatorTurns, threadId) {
|
|
79
|
+
return [...(groupGoalCoordinatorTurns.get(threadId) ?? [])]
|
|
80
|
+
.some((turn) => turn.discard && !turn.turnId);
|
|
81
|
+
}
|
|
82
|
+
/** Match private coordinator output to one provider turn, never merely to a
|
|
83
|
+
* reusable room thread. Most adapters emit turn.started before sendTurn
|
|
84
|
+
* resolves, so the first stable event may bind an otherwise pending guard. */
|
|
85
|
+
export function goalGuardForEvent(groupGoalCoordinatorTurns, retireProviderTurn, event) {
|
|
86
|
+
const turns = groupGoalCoordinatorTurns.get(event.threadId);
|
|
87
|
+
if (!turns?.size)
|
|
88
|
+
return undefined;
|
|
89
|
+
const candidates = [...turns];
|
|
90
|
+
if (event.turnId) {
|
|
91
|
+
const exact = candidates.find((turn) => turn.turnId === event.turnId);
|
|
92
|
+
if (exact)
|
|
93
|
+
return exact;
|
|
94
|
+
// Until an interrupted handshake returns its own id, no new id can be
|
|
95
|
+
// attributed safely. The stall fallback keeps this thread unavailable in
|
|
96
|
+
// that narrow window; private text is suppressed below until sendTurn's
|
|
97
|
+
// result binds the old guard or its bounded expiry releases ownership.
|
|
98
|
+
const unboundDiscarded = candidates.filter((turn) => turn.discard && !turn.turnId);
|
|
99
|
+
if (unboundDiscarded.length > 0) {
|
|
100
|
+
// The ownership fallback below keeps a lone abandoned handshake's
|
|
101
|
+
// thread closed, so its first eventual id can safely bind here. More
|
|
102
|
+
// than one unbound candidate is genuinely ambiguous and stays gated.
|
|
103
|
+
if (candidates.length === 1) {
|
|
104
|
+
unboundDiscarded[0].turnId = event.turnId;
|
|
105
|
+
// This event is the first stable identity for an already-abandoned
|
|
106
|
+
// provider turn. Tombstone it immediately so this event and every
|
|
107
|
+
// later completion/request cannot settle a replacement on the same
|
|
108
|
+
// room thread.
|
|
109
|
+
retireProviderTurn(event.turnId);
|
|
110
|
+
return unboundDiscarded[0];
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const pending = candidates.findLast((turn) => !turn.turnId && !turn.discard);
|
|
115
|
+
if (pending && !pending.turnId) {
|
|
116
|
+
pending.turnId = event.turnId;
|
|
117
|
+
return pending;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
// Turn-scoped events normally carry an id. If an adapter omits it, fail
|
|
122
|
+
// closed for private text; with multiple overlapping guards there is no
|
|
123
|
+
// safe way to attribute a completion, so leave cleanup to the bounded timer.
|
|
124
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
125
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { redactSecretsInText } from "./redact.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
6
|
+
import { parseGroupGoalDecision } from "./group-goal-run.js";
|
|
7
|
+
const goalSchema = z.object({
|
|
8
|
+
id: z.string(), botId: z.string(), threadId: z.string(), objective: z.string(),
|
|
9
|
+
criteria: z.array(z.string()), maxTurns: z.number().int().positive().max(1000),
|
|
10
|
+
tokenBudget: z.number().int().positive().optional(), tokensUsed: z.number().nonnegative(),
|
|
11
|
+
turnCount: z.number().int().nonnegative(),
|
|
12
|
+
status: z.enum(["working", "paused", "completed", "needs-input", "blocked", "limit-reached", "stopped", "failed"]),
|
|
13
|
+
detail: z.string(), createdAt: z.number(), updatedAt: z.number(),
|
|
14
|
+
trigger: z.union([z.object({ kind: z.literal("owner") }), z.object({ kind: z.literal("user"), email: z.string().optional(), label: z.string().optional() })]).optional(),
|
|
15
|
+
receiptMessageId: z.string().optional(), missing: z.number(), noProgress: z.number(),
|
|
16
|
+
repeated: z.number(), lastReply: z.string(), lastBlocker: z.string(), blockers: z.number(),
|
|
17
|
+
});
|
|
18
|
+
const terminal = (goal) => goal.status === "completed" || goal.status === "stopped";
|
|
19
|
+
/** A single durable objective per conversation. No provider, timer or authority lives here. */
|
|
20
|
+
export class PersonalGoals {
|
|
21
|
+
records;
|
|
22
|
+
loadError;
|
|
23
|
+
path;
|
|
24
|
+
changed;
|
|
25
|
+
constructor(path, changed = () => { }) {
|
|
26
|
+
this.path = path;
|
|
27
|
+
this.changed = changed;
|
|
28
|
+
this.records = [];
|
|
29
|
+
try {
|
|
30
|
+
this.records = existsSync(path) ? z.array(goalSchema).parse(JSON.parse(readFileSync(path, "utf8"))) : [];
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
this.loadError = "Saved goal state could not be read. It has been preserved for recovery; goals are paused.";
|
|
34
|
+
}
|
|
35
|
+
for (const goal of this.records)
|
|
36
|
+
if (goal.status === "working") {
|
|
37
|
+
goal.status = "paused";
|
|
38
|
+
goal.detail = "Atomic restarted. Use /goal resume to continue.";
|
|
39
|
+
goal.updatedAt = Date.now();
|
|
40
|
+
}
|
|
41
|
+
if (this.records.length) {
|
|
42
|
+
this.save();
|
|
43
|
+
this.records.forEach(this.changed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
save() { writeFileAtomic(this.path, JSON.stringify(this.records), { mode: 0o600 }); }
|
|
47
|
+
touch(goal) { goal.updatedAt = Date.now(); goal.detail = redactSecretsInText(goal.detail); this.changed(goal); this.save(); }
|
|
48
|
+
get(threadId) { return this.records.findLast((goal) => goal.threadId === threadId); }
|
|
49
|
+
byId(id) { return this.records.find((goal) => goal.id === id); }
|
|
50
|
+
start(botId, threadId, input) {
|
|
51
|
+
if (this.loadError)
|
|
52
|
+
throw new Error(this.loadError);
|
|
53
|
+
const previous = this.get(threadId);
|
|
54
|
+
if (previous && !terminal(previous))
|
|
55
|
+
throw new Error("This conversation already has a goal. Resume it or stop it first.");
|
|
56
|
+
const goal = goalSchema.parse({ ...input, objective: redactSecretsInText(input.objective), criteria: input.criteria.map(redactSecretsInText), id: randomUUID(), botId, threadId,
|
|
57
|
+
tokensUsed: 0, turnCount: 0, status: "working", detail: "Starting goal.",
|
|
58
|
+
createdAt: Date.now(), updatedAt: Date.now(), missing: 0, noProgress: 0, repeated: 0, lastReply: "", lastBlocker: "", blockers: 0 });
|
|
59
|
+
this.records.push(goal);
|
|
60
|
+
this.touch(goal);
|
|
61
|
+
return goal;
|
|
62
|
+
}
|
|
63
|
+
control(threadId, action) {
|
|
64
|
+
const goal = this.get(threadId);
|
|
65
|
+
if (!goal)
|
|
66
|
+
throw new Error("This conversation has no goal.");
|
|
67
|
+
if (!terminal(goal)) {
|
|
68
|
+
goal.status = action === "pause" ? "paused" : "stopped";
|
|
69
|
+
goal.detail = action === "pause" ? "Paused by you." : "Stopped by you.";
|
|
70
|
+
this.touch(goal);
|
|
71
|
+
}
|
|
72
|
+
return goal;
|
|
73
|
+
}
|
|
74
|
+
resume(threadId, budgets = {}) {
|
|
75
|
+
const goal = this.get(threadId);
|
|
76
|
+
if (!goal || terminal(goal))
|
|
77
|
+
throw new Error("There is no unfinished goal to resume.");
|
|
78
|
+
if (goal.status === "working")
|
|
79
|
+
throw new Error("This goal is already working.");
|
|
80
|
+
const maxTurns = budgets.maxTurns ?? goal.maxTurns;
|
|
81
|
+
const tokenBudget = budgets.tokenBudget ?? goal.tokenBudget;
|
|
82
|
+
if (goal.turnCount >= maxTurns || (tokenBudget !== undefined && goal.tokensUsed >= tokenBudget)) {
|
|
83
|
+
throw new Error("The goal has reached its budget. Resume with a higher --turns or --tokens total.");
|
|
84
|
+
}
|
|
85
|
+
goal.maxTurns = maxTurns;
|
|
86
|
+
goal.tokenBudget = tokenBudget;
|
|
87
|
+
goal.status = "working";
|
|
88
|
+
goal.detail = "Resuming. Inspect current state before continuing.";
|
|
89
|
+
goal.missing = goal.noProgress = goal.repeated = goal.blockers = 0;
|
|
90
|
+
goal.lastReply = goal.lastBlocker = "";
|
|
91
|
+
this.touch(goal);
|
|
92
|
+
return goal;
|
|
93
|
+
}
|
|
94
|
+
begin(id) {
|
|
95
|
+
const goal = this.byId(id);
|
|
96
|
+
if (!goal || goal.status !== "working")
|
|
97
|
+
return false;
|
|
98
|
+
if (goal.turnCount >= goal.maxTurns || (goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget)) {
|
|
99
|
+
goal.status = "limit-reached";
|
|
100
|
+
goal.detail = "Goal budget reached.";
|
|
101
|
+
this.touch(goal);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
goal.turnCount++;
|
|
105
|
+
this.touch(goal);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
settle(id, result) {
|
|
109
|
+
const goal = this.byId(id);
|
|
110
|
+
if (!goal)
|
|
111
|
+
return;
|
|
112
|
+
if (result.tokens !== undefined && Number.isFinite(result.tokens))
|
|
113
|
+
goal.tokensUsed += Math.max(0, result.tokens);
|
|
114
|
+
if (goal.status !== "working") {
|
|
115
|
+
this.touch(goal);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const parsed = parseGroupGoalDecision(result.text);
|
|
119
|
+
const decision = parsed.decision;
|
|
120
|
+
const reply = createHash("sha256").update(parsed.visibleText.trim()).digest("hex");
|
|
121
|
+
if (decision?.status !== "blocked") {
|
|
122
|
+
goal.blockers = 0;
|
|
123
|
+
goal.lastBlocker = "";
|
|
124
|
+
}
|
|
125
|
+
goal.repeated = reply && reply === goal.lastReply ? goal.repeated + 1 : 1;
|
|
126
|
+
goal.lastReply = reply;
|
|
127
|
+
goal.noProgress = result.tools ? 0 : goal.noProgress + 1;
|
|
128
|
+
goal.missing = decision ? 0 : goal.missing + 1;
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
goal.status = "paused";
|
|
131
|
+
goal.detail = "The turn did not finish successfully. Resume when ready.";
|
|
132
|
+
}
|
|
133
|
+
else if (goal.tokenBudget !== undefined && result.tokens === undefined) {
|
|
134
|
+
goal.status = "paused";
|
|
135
|
+
goal.detail = "The engine did not report token usage. The token budget cannot be verified.";
|
|
136
|
+
}
|
|
137
|
+
else if (decision?.status === "completed") {
|
|
138
|
+
// Evidence is reported by the worker, never presented as an independent verification.
|
|
139
|
+
const close = result.text.lastIndexOf("</openmaus-goal>");
|
|
140
|
+
const open = result.text.lastIndexOf("<openmaus-goal>", close);
|
|
141
|
+
const payload = open < 0 ? "{}" : result.text.slice(open + "<openmaus-goal>".length, close);
|
|
142
|
+
let evidence;
|
|
143
|
+
try {
|
|
144
|
+
evidence = JSON.parse(payload ?? "{}").evidence;
|
|
145
|
+
}
|
|
146
|
+
catch { /* malformed evidence cannot complete */ }
|
|
147
|
+
if (goal.criteria.length && (!Array.isArray(evidence) || evidence.length !== goal.criteria.length || evidence.some((item) => typeof item !== "string" || !item.trim()))) {
|
|
148
|
+
goal.detail = "Completion needs evidence for every criterion. Verify the remaining criteria.";
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
goal.status = "completed";
|
|
152
|
+
goal.detail = decision.detail;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else if (decision?.status === "needs-input") {
|
|
156
|
+
goal.status = "needs-input";
|
|
157
|
+
goal.detail = decision.detail;
|
|
158
|
+
}
|
|
159
|
+
else if (decision?.status === "blocked") {
|
|
160
|
+
const blocker = createHash("sha256").update(decision.detail.trim().toLowerCase()).digest("hex");
|
|
161
|
+
goal.blockers = blocker === goal.lastBlocker ? goal.blockers + 1 : 1;
|
|
162
|
+
goal.lastBlocker = blocker;
|
|
163
|
+
goal.detail = goal.blockers >= 3 ? decision.detail : `Try an alternative and verify the blocker: ${decision.detail}`;
|
|
164
|
+
if (goal.blockers >= 3)
|
|
165
|
+
goal.status = "blocked";
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
goal.detail = decision?.detail ?? "Continue the objective and report a valid goal status.";
|
|
169
|
+
}
|
|
170
|
+
if (goal.status === "working") {
|
|
171
|
+
if (goal.turnCount >= goal.maxTurns || (goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget)) {
|
|
172
|
+
goal.status = "limit-reached";
|
|
173
|
+
goal.detail = "Goal budget reached. Resume with a higher total to continue.";
|
|
174
|
+
}
|
|
175
|
+
else if (goal.missing >= 3 || goal.noProgress >= 3 || goal.repeated >= 3) {
|
|
176
|
+
goal.status = "paused";
|
|
177
|
+
goal.detail = "No measurable progress across three turns. Resume with more direction.";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.touch(goal);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function personalGoalInstructions(goal) {
|
|
184
|
+
return [
|
|
185
|
+
"You are pursuing this conversation's durable goal. Complete authorized work; ordinary turn completion does not end the goal.",
|
|
186
|
+
`Objective: ${goal.objective}`,
|
|
187
|
+
`Completion criteria: ${JSON.stringify(goal.criteria)}. Supply one non-empty evidence string per criterion when completed.`,
|
|
188
|
+
`Turn ${goal.turnCount}/${goal.maxTurns}. Tokens used: ${goal.tokensUsed}; budget: ${goal.tokenBudget ?? "not set"}.`,
|
|
189
|
+
`Progress: ${goal.detail}`,
|
|
190
|
+
"Existing permissions and approval requirements still apply. Never claim completion from intent alone. Ask for required human input using needs-input.",
|
|
191
|
+
'End with a private envelope: <openmaus-goal>{"status":"continue","next":"self","instruction":"next concrete step","detail":"progress"}</openmaus-goal>',
|
|
192
|
+
'Or status completed, needs-input or blocked with detail and no next/instruction. For completed include "evidence":["verification for criterion 1", ...].',
|
|
193
|
+
"Write useful visible results before the envelope. Never show or explain the envelope. On resume inspect current state before acting.",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
2
|
+
/** True when anything accepts TCP on 127.0.0.1:port, not just an Atomic server. */
|
|
3
|
+
export function portInUse(port) {
|
|
4
|
+
return new Promise((done) => {
|
|
5
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
6
|
+
socket.setTimeout(1000);
|
|
7
|
+
socket.once("connect", () => { socket.destroy(); done(true); });
|
|
8
|
+
socket.once("timeout", () => { socket.destroy(); done(false); });
|
|
9
|
+
socket.once("error", () => done(false));
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/** The server binds port and port + 1 (webhooks); report the first busy one. */
|
|
13
|
+
export async function reportBusyPort(port, dataDirFlag) {
|
|
14
|
+
for (const candidate of [port, Number(process.env.OMB_WEBHOOK_PORT || port + 1)]) {
|
|
15
|
+
if (!(await portInUse(candidate)))
|
|
16
|
+
continue;
|
|
17
|
+
console.error(`Port ${candidate} is already in use by another app. Choose a free port, for example: chibi start --port ${port + 20}${dataDirFlag}`);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
@@ -9,6 +9,7 @@ import { existsSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { delimiter, join } from "node:path";
|
|
11
11
|
import { isOllamaCloudModel, validLocalModelTag } from "../../shared/local-models.js";
|
|
12
|
+
import { ollamaBinaryHere } from "../app-installers.js";
|
|
12
13
|
import { PASS } from "./table.js";
|
|
13
14
|
export const OLLAMA_LOCAL_URL = "http://127.0.0.1:11434";
|
|
14
15
|
export const OLLAMA_DOWNLOAD_URL = "https://ollama.com/download";
|
|
@@ -77,9 +78,9 @@ export function installedModelsFromTags(payload, bots, engines) {
|
|
|
77
78
|
}
|
|
78
79
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
79
80
|
}
|
|
80
|
-
/** The Ollama app, or the `ollama` command on PATH. */
|
|
81
|
+
/** The Ollama app, Atomic's own install of it, or the `ollama` command on PATH. */
|
|
81
82
|
export function ollamaInstalledHere(env = process.env) {
|
|
82
|
-
const apps = process.platform === "darwin" ? ["/Applications/Ollama.app", join(homedir(), "Applications", "Ollama.app")] : [];
|
|
83
|
+
const apps = process.platform === "darwin" ? ["/Applications/Ollama.app", join(homedir(), "Applications", "Ollama.app")] : [ollamaBinaryHere()].filter((path) => Boolean(path));
|
|
83
84
|
if (apps.some((path) => existsSync(path)))
|
|
84
85
|
return true;
|
|
85
86
|
const names = process.platform === "win32" ? ["ollama.exe"] : ["ollama"];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Session fallback for company subscription bots (docs/chibitek/engine-tiers.md,
|
|
2
|
+
// "Subscription bots: the one failover exception"). When no account of the
|
|
3
|
+
// bot's own provider can take a turn, one conversation may run on the same
|
|
4
|
+
// class from another provider's subscription for up to SESSION_FALLBACK_TURNS
|
|
5
|
+
// turns. The bot's own default never changes; the next turn tries its own
|
|
6
|
+
// provider first again.
|
|
7
|
+
import { SESSION_FALLBACK_TURNS } from "../shared/subscription-bots.js";
|
|
8
|
+
export { SESSION_FALLBACK_TURNS };
|
|
9
|
+
// ponytail: in memory, so a restart gives a conversation its 50 turns again;
|
|
10
|
+
// persist on the task record if that matters.
|
|
11
|
+
const fallbacks = new Map();
|
|
12
|
+
/** The thread's own provider took this turn: it leaves the fallback (its count stays). */
|
|
13
|
+
export function leaveSessionFallback(threadId) {
|
|
14
|
+
const entry = fallbacks.get(threadId);
|
|
15
|
+
if (entry)
|
|
16
|
+
entry.active = false;
|
|
17
|
+
}
|
|
18
|
+
/** Enter (or stay on) the fallback for this thread. A new turn counts one;
|
|
19
|
+
* a failover within a turn already on it counts nothing. False once the
|
|
20
|
+
* conversation has used its turns. */
|
|
21
|
+
export function enterSessionFallback(threadId, newTurn) {
|
|
22
|
+
const entry = fallbacks.get(threadId) ?? { turns: 0, active: false };
|
|
23
|
+
if (entry.active && !newTurn)
|
|
24
|
+
return true;
|
|
25
|
+
if (entry.turns >= SESSION_FALLBACK_TURNS) {
|
|
26
|
+
entry.active = false;
|
|
27
|
+
fallbacks.set(threadId, entry);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
entry.turns += 1;
|
|
31
|
+
entry.active = true;
|
|
32
|
+
fallbacks.set(threadId, entry);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
/** Turn start for a subscription bot: its own provider's engine (which
|
|
36
|
+
* leaves the fallback), else, on its stop, the fallback's, counted as a
|
|
37
|
+
* turn. No fallback keeps the stop; past the limit, `spent` of it. */
|
|
38
|
+
export function ownOrFallback(threadId, own, fallback, isStop, spent) {
|
|
39
|
+
try {
|
|
40
|
+
const choice = own();
|
|
41
|
+
leaveSessionFallback(threadId);
|
|
42
|
+
return choice;
|
|
43
|
+
}
|
|
44
|
+
catch (stop) {
|
|
45
|
+
if (!isStop(stop))
|
|
46
|
+
throw stop;
|
|
47
|
+
let choice;
|
|
48
|
+
try {
|
|
49
|
+
choice = fallback();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
choice = undefined;
|
|
53
|
+
}
|
|
54
|
+
if (choice === undefined)
|
|
55
|
+
throw stop;
|
|
56
|
+
if (!enterSessionFallback(threadId, true))
|
|
57
|
+
throw spent(stop);
|
|
58
|
+
return choice;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function onSessionFallback(threadId) {
|
|
62
|
+
return fallbacks.get(threadId)?.active === true;
|
|
63
|
+
}
|
|
64
|
+
export function sessionFallbackTurns(threadId) {
|
|
65
|
+
return fallbacks.get(threadId)?.turns ?? 0;
|
|
66
|
+
}
|
|
67
|
+
/** The bot as a fallback turn runs it: an ordinary company bot, so the
|
|
68
|
+
* ladder and every gate read it without the provider lock. */
|
|
69
|
+
export function withoutSubscription(bot) {
|
|
70
|
+
const { subscription: _subscription, ...rest } = bot;
|
|
71
|
+
return rest;
|
|
72
|
+
}
|
|
73
|
+
/** The line a company subscription bot stops with once its fallback turns are used. */
|
|
74
|
+
export function sessionFallbackSpentLine(line) {
|
|
75
|
+
return `${line} This conversation already used its ${SESSION_FALLBACK_TURNS} turns on another provider; download a handoff or hand it to another bot.`;
|
|
76
|
+
}
|
|
@@ -11,6 +11,20 @@ import { z } from "zod";
|
|
|
11
11
|
const MAX_FILES = 30;
|
|
12
12
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
13
13
|
const API = "https://api.github.com";
|
|
14
|
+
// Upload bytes, never a client-supplied path on the server's filesystem.
|
|
15
|
+
export const skillImportSchema = z.union([
|
|
16
|
+
z.object({ source: z.string().min(1).max(2000) }).strict(),
|
|
17
|
+
z.object({ file: z.object({
|
|
18
|
+
name: z.string().min(1).max(255).regex(/^[^/\\]+\.md$/i, "Choose a Markdown (.md) skill file")
|
|
19
|
+
.refine(name => [...name].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127), "Invalid filename"),
|
|
20
|
+
text: z.string().min(1).refine(text => Buffer.byteLength(text, "utf8") <= MAX_FILE_BYTES, "Skill exceeds 256 KiB"),
|
|
21
|
+
}).strict() }).strict(),
|
|
22
|
+
]);
|
|
23
|
+
export async function resolveSkillImport(input) {
|
|
24
|
+
if ("source" in input)
|
|
25
|
+
return fetchSkillFromSource(input.source);
|
|
26
|
+
return { skills: [{ source: `file:${input.file.name}`, files: [{ path: "SKILL.md", content: input.file.text }] }] };
|
|
27
|
+
}
|
|
14
28
|
class ImportLimitError extends Error {
|
|
15
29
|
}
|
|
16
30
|
// One budget for the entire import, including directory discovery. Read the
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { PASS } from "../routes/table.js";
|
|
2
|
+
import { VaultError } from "./store.js";
|
|
3
|
+
/** The owner is supplied by the authenticated host, never from request JSON.
|
|
4
|
+
* Authorization runs on reads too, and is checked again after reading a body. */
|
|
5
|
+
export function createVaultRoutes(deps) {
|
|
6
|
+
return async ({ req, res, path, method, auth, json, readBody }) => {
|
|
7
|
+
if (path !== "/api/vault" && !path.startsWith("/api/vault/"))
|
|
8
|
+
return PASS;
|
|
9
|
+
res.setHeader("cache-control", "no-store");
|
|
10
|
+
const ownerId = deps.owner(req, auth);
|
|
11
|
+
if (!ownerId)
|
|
12
|
+
return json(res, 403, { error: "Only the Vault owner can manage saved items." });
|
|
13
|
+
const store = deps.store();
|
|
14
|
+
if (!store)
|
|
15
|
+
return json(res, 503, { error: "Vault encryption is unavailable. Unlock the secure store and try again." });
|
|
16
|
+
const item = /^\/api\/vault\/([0-9a-f-]{36})$/.exec(path);
|
|
17
|
+
if (path !== "/api/vault" && !item)
|
|
18
|
+
return json(res, 404, { error: "Vault item is unavailable." });
|
|
19
|
+
if (!(path === "/api/vault" ? ["GET", "POST"] : ["PUT", "DELETE"]).includes(method))
|
|
20
|
+
return json(res, 405, { error: "Method not allowed." });
|
|
21
|
+
try {
|
|
22
|
+
if (method === "GET")
|
|
23
|
+
return json(res, 200, { items: store.list(ownerId) });
|
|
24
|
+
const body = method === "DELETE" ? undefined : await readBody(req).catch(() => null);
|
|
25
|
+
if (deps.owner(req, auth) !== ownerId || deps.store() !== store)
|
|
26
|
+
return json(res, 403, { error: "Vault access changed. Try again." });
|
|
27
|
+
if (method === "POST")
|
|
28
|
+
return json(res, 201, { item: store.create(ownerId, body) });
|
|
29
|
+
if (method === "PUT" && item)
|
|
30
|
+
return json(res, 200, { item: store.update(ownerId, item[1], body) });
|
|
31
|
+
if (method === "DELETE" && item) {
|
|
32
|
+
store.remove(ownerId, item[1]);
|
|
33
|
+
return json(res, 200, { removed: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
// No raw parser, filesystem, crypto or browser error crosses this route.
|
|
38
|
+
if (error instanceof VaultError)
|
|
39
|
+
return json(res, 400, { error: error.message });
|
|
40
|
+
return json(res, 503, { error: "Vault could not complete the request." });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { VaultStore } from "./store.js";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ROUTES } from "../routes/table.js";
|
|
6
|
+
import { createVaultRoutes } from "./routes.js";
|
|
7
|
+
const keyMessage = z.object({ type: z.literal("atomic:native-vault-key"), ownerId: z.string().uuid(), key: z.string().regex(/^[0-9a-f]{64}$/) }).strict();
|
|
8
|
+
/** Constructed by the server, bootstrapped only on Electron's private port.
|
|
9
|
+
* No HTTP endpoint can unlock the vault or replace its owner. */
|
|
10
|
+
export class NativeVaultRuntime {
|
|
11
|
+
#directory;
|
|
12
|
+
#store = null;
|
|
13
|
+
#ownerId = null;
|
|
14
|
+
constructor(directory) { this.#directory = directory; }
|
|
15
|
+
receive(raw) {
|
|
16
|
+
if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "atomic:native-vault-key")
|
|
17
|
+
return false;
|
|
18
|
+
const parsed = keyMessage.safeParse(raw);
|
|
19
|
+
if (!parsed.success || this.#store)
|
|
20
|
+
return true;
|
|
21
|
+
const key = Buffer.from(parsed.data.key, "hex");
|
|
22
|
+
try {
|
|
23
|
+
this.#store = new VaultStore(this.#directory, key);
|
|
24
|
+
this.#ownerId = parsed.data.ownerId;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
key.fill(0);
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
store() { return this.#store; }
|
|
32
|
+
/** Until paired owner identity is established, remote clients fail closed.
|
|
33
|
+
* GET needs the desktop capability too; loopback alone is not authority. */
|
|
34
|
+
owner(req, auth, desktopToken) {
|
|
35
|
+
if (!desktopToken || auth.kind !== "loopback" || auth.trust === "service" || !auth.scopes.includes("admin"))
|
|
36
|
+
return null;
|
|
37
|
+
const supplied = req.headers["x-chibi-desktop-owner"];
|
|
38
|
+
if (typeof supplied !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(supplied) || supplied.length !== desktopToken.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(desktopToken)))
|
|
39
|
+
return null;
|
|
40
|
+
return this.#ownerId ?? "pending";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Keep bootstrapping and route registration together, outside index.ts. */
|
|
44
|
+
export function installNativeVault(dataDir, port, desktopToken, fromBot) {
|
|
45
|
+
const runtime = new NativeVaultRuntime(join(dataDir, "providers", "native-vault"));
|
|
46
|
+
port?.on("message", (event) => { runtime.receive(event?.data); });
|
|
47
|
+
ROUTES.push(createVaultRoutes({ store: () => runtime.store(), owner: (req, auth) => fromBot(req) ? null : runtime.owner(req, auth, desktopToken()) }));
|
|
48
|
+
}
|