@chibitek/chibi 0.1.172 → 0.1.174
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-CGoW52MW.js → abnfDiagram-O67JEVCF-BVDndX1g.js} +1 -1
- package/dist/assets/{arc-DEkKLw4F.js → arc-WduS6wYB.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-Cyi6MYs6.js → architectureDiagram-NJMV4G6O-CYK9LkCf.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CddA0wa_.js → blockDiagram-BEXU5L5S-BMj-aPW9.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Dg8q9n90.js → c4Diagram-YGBWAQC7-cXYIStgL.js} +1 -1
- package/dist/assets/channel-TratrwmM.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BSMub34c.js → chunk-2Q5K7J3B-CZ_nCppA.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW--Xc7Up5y.js → chunk-3FUC2YCW-C3wRAbtM.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-B9Ae4f30.js → chunk-5VM5RSS4-CMvgOXJP.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Dh1To9G3.js → chunk-GTNCS2PH-9wBuLQzU.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-C_5eiV2n.js → chunk-GWA4HPMP-BpJMmfAi.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-5wQuPsks.js → chunk-JWPE2WC7-BnM06ABv.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-BGXS05ur.js → chunk-NETBCI7D-CjjiiXiT.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-ClIMhmD5.js → chunk-WEXAMYUT-D3heHHZs.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CcKs11-L.js → chunk-XXDRQBXY-n_P41ZlM.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BCiLckGU.js → classDiagram-v2-NBCMYWYE-Dz60Ek-6.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-JyAqT-ND.js → cose-bilkent-JH36ORCC-BCt6vBi9.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-BznovZj0.js → cynefin-OW5HDTMX-U0uq4EAQ.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BDRrTIEB.js → cynefinDiagram-VND7K2PF-CaQX0eUJ.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-BbWguhLj.js → dagre-6A5THRUB-DlCMESYH.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BMk1KTBr.js → diagram-22UHCM2B-Cl1xmlTD.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-ChnEP2in.js → diagram-3UASUU5V-Cus3CPg7.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-C78imnTV.js → diagram-ATOU4E4O-i06rMvae.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-Dekx02f_.js → diagram-CDSNMT55-CUM5_heN.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-D2__fPwe.js → diagram-MLGK6HIB-B5NKRruo.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-C9l7MZm7.js → diagram-MPIPVDR6-CAumAcGl.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-D4x0h3XT.js → ebnfDiagram-ZINNZB2B-B19d7d8l.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-twChz5io.js → elk-276RUBZZ-NJEpfRWi.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BXEKWWMq.js → erDiagram-OPXOYQCR-BExk6CHc.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Ct-XrJMr.js → flowDiagram-KWPJA3E3-ClExQ_p0.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-Bn92lxCs.js → ganttDiagram-FUAMR5RP-1MeM9sSl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dvfjo7cn.js → gitGraphDiagram-X574FWY7-Tw9Dudpa.js} +1 -1
- package/dist/assets/{index-DvneZE6Z.js → index-KYHldywn.js} +140 -140
- package/dist/assets/{index-CarzpTfN.js → index-qsorLr2x.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-MJ14OyNX.js → infoDiagram-VRGFBTTK-ChU2tjvl.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-nf9xhTNX.js → ishikawaDiagram-OU5B5YK6-BeTbrX2Q.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DqfK6X2e.js → journeyDiagram-ZHPQQLJL-B7ijaWBD.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-CjUR1QYT.js → kanban-definition-PNTS6WVX-JBmK2F1r.js} +1 -1
- package/dist/assets/{linear-dPqGsZwL.js → linear-B7FXe3Ue.js} +1 -1
- package/dist/assets/{mermaid.core-zL0DLPAI.js → mermaid.core-BPlfZpqv.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-C1-Jqg3_.js → mindmap-definition-NLK3R4M7-CmT3S5QR.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CFlvuAHC.js → pegDiagram-GJSIUBJH-CFFHIcdQ.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BvyT0IgF.js → pieDiagram-5QR66LMP-yTiPmXRV.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-bg7JeR2c.js → quadrantDiagram-O4NWA36T-Cw8u7esm.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-dF2Nqim8.js → railroadDiagram-XR7U4H2S-BfKggsOW.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CwVu_Or6.js → requirementDiagram-PLB6GJNP-Cf3n79jF.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-wBC3-mRy.js → sankeyDiagram-IPEJSGJF-B2NkSZig.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-Bs9lHMf-.js → sequenceDiagram-PO4LG4MO-BfFYiYK3.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-d6YiGOIQ.js → sizeCapture-INFHLROL-CZ_2nC3f.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK--f6dscuE.js → stateDiagram-v2-GCMORJYK-B9jwzMXf.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CQpzBoAc.js → swimlanes-2SLR337P-Bxhafu77.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-DWNGCaH3.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-DpoyUyqt.js → timeline-definition-EJHVYXUP-Dle5SJls.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-Dg7mR3GW.js → usecaseDiagram-POWQR4AR-CgRTu9EO.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-CoEfTL7k.js → vennDiagram-UO4OBE2U-D7m_1z6B.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-24tIY01C.js → wardleyDiagram-VNRHLVJA-7-TzmX4L.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-CsoqIRqn.js → xychartDiagram-PMCCYNJV-BVXXdviS.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/index.js +2797 -1875
- package/dist-server/openmausbot.js +64 -2
- package/dist-server/pair-cli.js +64 -2
- package/dist-server/server/builtin-chibi.js +1 -1
- package/dist-server/server/dictation.js +8 -0
- package/dist-server/server/drivers/claude.js +4 -4
- package/dist-server/server/engine-tiers-view.js +49 -0
- package/dist-server/server/engine-tiers.js +37 -55
- package/dist-server/server/harness-import/engine-policy.js +6 -1
- package/dist-server/server/index.js +12 -13
- package/dist-server/server/oauth-account-core.js +153 -0
- package/dist-server/server/oauth-account-tools.js +7 -348
- package/dist-server/server/oauth-calendar-tools.js +551 -0
- package/dist-server/server/personal-bot.js +0 -26
- package/dist-server/server/remote-desktop.js +139 -7
- package/dist-server/server/routes/engine-tiers.js +12 -1
- package/dist-server/server/subscription-bots.js +2 -21
- package/dist-server/server/turn-resources.js +28 -0
- package/dist-server/server/workspace-backup-archive.js +138 -0
- package/dist-server/server/workspace-backup-http.js +50 -8
- package/dist-server/server/workspace-backup-private.js +83 -0
- package/dist-server/server/workspace-backup-secrets.js +90 -0
- package/dist-server/server/workspace-backup.js +171 -235
- package/dist-server/server/workspace-restore-paths.js +186 -0
- package/dist-server/shared/oauth-providers.js +2 -1
- package/dist-server/shared/provider-accounts.js +29 -0
- package/package.json +1 -1
- package/dist/assets/channel-Dda8O6gd.js +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C1ko4o6t.js +0 -8
|
@@ -40,6 +40,80 @@ export const remoteInputSchema = z.discriminatedUnion("type", [
|
|
|
40
40
|
amount: z.number().int().min(1).max(20).default(3),
|
|
41
41
|
}),
|
|
42
42
|
]);
|
|
43
|
+
// Pointer events in frame space (docs/computer-pointer-contract.md). The phone
|
|
44
|
+
// sends where on the picture the person touched; the host owns the way back
|
|
45
|
+
// to the display, because only the host knows what it captured and shrank.
|
|
46
|
+
const unit = z.number().finite().min(0).max(1);
|
|
47
|
+
const frameSide = z.number().int().min(1).max(20_000);
|
|
48
|
+
const pointerBase = {
|
|
49
|
+
type: z.literal("pointer"),
|
|
50
|
+
u: unit,
|
|
51
|
+
v: unit,
|
|
52
|
+
frameWidth: frameSide,
|
|
53
|
+
frameHeight: frameSide,
|
|
54
|
+
display: z.string().min(1).max(64).optional(),
|
|
55
|
+
};
|
|
56
|
+
export const pointerInputSchema = z.discriminatedUnion("action", [
|
|
57
|
+
z.object({ ...pointerBase, action: z.enum(["tap", "doubleTap", "secondaryTap"]) }),
|
|
58
|
+
z.object({ ...pointerBase, action: z.literal("drag"), toU: unit, toV: unit }),
|
|
59
|
+
z.object({
|
|
60
|
+
...pointerBase,
|
|
61
|
+
action: z.literal("scroll"),
|
|
62
|
+
direction: z.enum(["up", "down", "left", "right"]),
|
|
63
|
+
amount: z.number().int().min(1).max(20).default(3),
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
/** Null when the event was aimed at the frame the host last sent, else why
|
|
67
|
+
* not. A different size means the resolution, monitor or shrink changed
|
|
68
|
+
* since the phone's picture, so a mapped point would land somewhere else. */
|
|
69
|
+
export function staleFrameReason(event, sent) {
|
|
70
|
+
if (!sent)
|
|
71
|
+
return "No frame has been sent for this computer yet; wait for the next picture";
|
|
72
|
+
if (event.display !== undefined && event.display !== sent.layout.id)
|
|
73
|
+
return "That picture is from another display; wait for the next one";
|
|
74
|
+
if (event.frameWidth !== sent.frameWidth || event.frameHeight !== sent.frameHeight) {
|
|
75
|
+
return "The screen changed since that picture; wait for the next one";
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** The capture shrink is uniform, so (u, v) over the frame is the same
|
|
80
|
+
* fraction of the display: no need to undo the JPEG size, only to land on
|
|
81
|
+
* a real pixel (u = 1 is the far edge, not one past it). */
|
|
82
|
+
export function framePointToDisplay(u, v, layout) {
|
|
83
|
+
const x = Math.min(Math.floor(u * layout.width), layout.width - 1);
|
|
84
|
+
const y = Math.min(Math.floor(v * layout.height), layout.height - 1);
|
|
85
|
+
return { display: layout.id, x, y, pointX: layout.originX + x / layout.scale, pointY: layout.originY + y / layout.scale };
|
|
86
|
+
}
|
|
87
|
+
/** Pointer events through Cua Driver's desktop target, which takes display
|
|
88
|
+
* pixels and does the point and scale conversion itself. It addresses the
|
|
89
|
+
* primary display only in this release, which is all remoteFrame captures.
|
|
90
|
+
* Windows and Linux plug in here: the same driver ships for both, so the
|
|
91
|
+
* work there is lifting the darwin gate in remoteDesktopRoute and checking
|
|
92
|
+
* its desktop target on each; a backend that wants global points uses
|
|
93
|
+
* pointX/pointY instead. */
|
|
94
|
+
export async function remotePointer(call, event, layout) {
|
|
95
|
+
const at = framePointToDisplay(event.u, event.v, layout);
|
|
96
|
+
const target = { kind: "desktop", display_id: at.display };
|
|
97
|
+
switch (event.action) {
|
|
98
|
+
case "tap":
|
|
99
|
+
case "doubleTap":
|
|
100
|
+
case "secondaryTap":
|
|
101
|
+
await call("click", {
|
|
102
|
+
target, x: at.x, y: at.y,
|
|
103
|
+
button: event.action === "secondaryTap" ? "right" : "left",
|
|
104
|
+
count: event.action === "doubleTap" ? 2 : 1,
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
case "drag": {
|
|
108
|
+
const to = framePointToDisplay(event.toU, event.toV, layout);
|
|
109
|
+
await call("drag", { target, from_x: at.x, from_y: at.y, to_x: to.x, to_y: to.y });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case "scroll":
|
|
113
|
+
await call("scroll", { target, x: at.x, y: at.y, direction: event.direction, amount: event.amount });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
43
117
|
export function hostCuaCall(connection) {
|
|
44
118
|
const socket = connection.args[connection.args.indexOf("--socket") + 1];
|
|
45
119
|
if (!connection.args.includes("--socket") || !socket)
|
|
@@ -55,14 +129,36 @@ export async function remoteFrame(call, shrink = sipsJpeg) {
|
|
|
55
129
|
const dir = await mkdtemp(join(tmpdir(), "atomic-remote-"));
|
|
56
130
|
try {
|
|
57
131
|
const png = join(dir, "screen.png");
|
|
58
|
-
const state = z.object({
|
|
59
|
-
.
|
|
132
|
+
const state = z.object({
|
|
133
|
+
screenshot_width: z.number().int().positive(),
|
|
134
|
+
screenshot_height: z.number().int().positive(),
|
|
135
|
+
scale_factor: z.number().positive().optional(),
|
|
136
|
+
display: z.string().min(1).optional(),
|
|
137
|
+
}).parse(await call("get_desktop_state", {}, png));
|
|
60
138
|
const jpeg = join(dir, "screen.jpg");
|
|
61
139
|
await shrink(png, jpeg);
|
|
62
|
-
|
|
63
|
-
|
|
140
|
+
const bytes = await readFile(jpeg);
|
|
141
|
+
const size = jpegSize(bytes);
|
|
142
|
+
if (!size)
|
|
143
|
+
throw new Error("The screen picture came back unreadable");
|
|
144
|
+
const layout = {
|
|
145
|
+
id: state.display ?? "primary",
|
|
64
146
|
width: state.screenshot_width,
|
|
65
147
|
height: state.screenshot_height,
|
|
148
|
+
scale: state.scale_factor ?? 1,
|
|
149
|
+
originX: 0,
|
|
150
|
+
originY: 0,
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
frame: {
|
|
154
|
+
image: `data:image/jpeg;base64,${bytes.toString("base64")}`,
|
|
155
|
+
width: layout.width,
|
|
156
|
+
height: layout.height,
|
|
157
|
+
frameWidth: size.width,
|
|
158
|
+
frameHeight: size.height,
|
|
159
|
+
display: layout.id,
|
|
160
|
+
},
|
|
161
|
+
sent: { frameWidth: size.width, frameHeight: size.height, layout },
|
|
66
162
|
};
|
|
67
163
|
}
|
|
68
164
|
finally {
|
|
@@ -70,6 +166,24 @@ export async function remoteFrame(call, shrink = sipsJpeg) {
|
|
|
70
166
|
await rm(dir, { recursive: true, force: true });
|
|
71
167
|
}
|
|
72
168
|
}
|
|
169
|
+
/** Pixel size from a JPEG's start-of-frame marker, or null if it has none. */
|
|
170
|
+
export function jpegSize(bytes) {
|
|
171
|
+
if (bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
172
|
+
return null;
|
|
173
|
+
let at = 2;
|
|
174
|
+
while (at + 9 < bytes.length) {
|
|
175
|
+
if (bytes[at] !== 0xff)
|
|
176
|
+
return null;
|
|
177
|
+
const marker = bytes[at + 1];
|
|
178
|
+
const length = (bytes[at + 2] << 8) | bytes[at + 3];
|
|
179
|
+
// SOF0..SOF15, minus DHT (C4), JPG (C8) and DAC (CC), which share the range.
|
|
180
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
181
|
+
return { height: (bytes[at + 5] << 8) | bytes[at + 6], width: (bytes[at + 7] << 8) | bytes[at + 8] };
|
|
182
|
+
}
|
|
183
|
+
at += 2 + length;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
73
187
|
async function sipsJpeg(input, output) {
|
|
74
188
|
await run("/usr/bin/sips", ["-s", "format", "jpeg", "-s", "formatOptions", "60", "-Z", "1600", input, "--out", output], { timeout: 15_000 });
|
|
75
189
|
}
|
|
@@ -112,6 +226,7 @@ export async function remoteDesktopRoute(path, method, req, res, deps) {
|
|
|
112
226
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
113
227
|
return reply(415, { error: "content-type must be application/json" });
|
|
114
228
|
}
|
|
229
|
+
// Windows and Linux: see remotePointer. Lifting this gate is the switch.
|
|
115
230
|
if ((deps.platform ?? process.platform) !== "darwin" || await deps.surface(bot, bot.threadId) !== "local") {
|
|
116
231
|
return reply(409, { error: "Phone remote control works for This Mac for now. Choose This Mac for this bot, or open the cloud desktop." });
|
|
117
232
|
}
|
|
@@ -120,11 +235,27 @@ export async function remoteDesktopRoute(path, method, req, res, deps) {
|
|
|
120
235
|
return reply(409, { error: readCuaUnavailableReason() ?? "CUA Driver is not ready on this computer" });
|
|
121
236
|
res.setHeader("cache-control", "private, no-store");
|
|
122
237
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
238
|
+
const frames = deps.frames ?? sentFrames;
|
|
239
|
+
if (m[2] === "frame") {
|
|
240
|
+
const { frame, sent } = await remoteFrame(call);
|
|
241
|
+
frames.set(bot.id, sent);
|
|
242
|
+
return reply(200, frame);
|
|
243
|
+
}
|
|
125
244
|
if (!deps.controlHeld(bot.id))
|
|
126
245
|
return reply(409, { error: "Take control before using this computer" });
|
|
127
|
-
const
|
|
246
|
+
const body = await readBody(req);
|
|
247
|
+
if (body?.type === "pointer") {
|
|
248
|
+
const event = pointerInputSchema.safeParse(body);
|
|
249
|
+
if (!event.success)
|
|
250
|
+
return reply(400, { error: "invalid pointer input" });
|
|
251
|
+
const sent = frames.get(bot.id);
|
|
252
|
+
const stale = staleFrameReason(event.data, sent);
|
|
253
|
+
if (stale || !sent)
|
|
254
|
+
return reply(409, { error: stale, code: "stale-frame" });
|
|
255
|
+
await remotePointer(call, event.data, sent.layout);
|
|
256
|
+
return reply(200, { ok: true });
|
|
257
|
+
}
|
|
258
|
+
const input = remoteInputSchema.safeParse(body);
|
|
128
259
|
if (!input.success)
|
|
129
260
|
return reply(400, { error: "invalid remote input" });
|
|
130
261
|
await remoteInput(call, input.data);
|
|
@@ -136,6 +267,7 @@ export async function remoteDesktopRoute(path, method, req, res, deps) {
|
|
|
136
267
|
return reply(409, { error: error.message });
|
|
137
268
|
}
|
|
138
269
|
}
|
|
270
|
+
const sentFrames = new Map();
|
|
139
271
|
function hostCall() {
|
|
140
272
|
const connection = readCuaConnection();
|
|
141
273
|
return connection ? hostCuaCall(connection) : null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isBotTier, isModelClass, MODEL_CLASSES } from "../../shared/engine-tiers.js";
|
|
2
|
-
import { checkedLadder,
|
|
2
|
+
import { checkedLadder, rebalanceRow, withLadder, withTierClass } from "../engine-tiers.js";
|
|
3
|
+
import { engineTiersView } from "../engine-tiers-view.js";
|
|
3
4
|
import { PASS } from "./table.js";
|
|
4
5
|
const MAX_CHANGES = 500;
|
|
5
6
|
export function createEngineTierRoutes(deps) {
|
|
@@ -87,6 +88,16 @@ export function createEngineTierRoutes(deps) {
|
|
|
87
88
|
deps.saveEngineTiers(withTierClass(deps.engineTiers(), tier, value));
|
|
88
89
|
return json(res, 200, await view());
|
|
89
90
|
}
|
|
91
|
+
if (path === "/api/engine-tiers/include-free" && method === "PUT") {
|
|
92
|
+
if (deps.disabled())
|
|
93
|
+
return disabled();
|
|
94
|
+
const body = await readBody(req);
|
|
95
|
+
const value = body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 1 ? body.includeFree : undefined;
|
|
96
|
+
if (typeof value !== "boolean")
|
|
97
|
+
return json(res, 400, { error: "send { includeFree: boolean }" });
|
|
98
|
+
deps.saveEngineTiers({ ...(deps.engineTiers() ?? { ladders: {} }), includeFree: value || undefined });
|
|
99
|
+
return json(res, 200, await view());
|
|
100
|
+
}
|
|
90
101
|
if (path === "/api/engine-tiers/rebalance" && method === "GET") {
|
|
91
102
|
if (deps.disabled())
|
|
92
103
|
return disabled();
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
// Subscription bots' rules (docs/chibitek/subscription-bots.md;
|
|
2
|
-
// shared/subscription-bots.ts). Pure, so each rule is unit tested without a
|
|
3
|
-
// server; server/index.ts wires them into the engine-selection points, the
|
|
4
|
-
// sign-in refresh and the routes.
|
|
5
|
-
//
|
|
6
|
-
// 1. One bot per signed-in subscription account, made once. The record of
|
|
7
|
-
// what was made (config.json `subscriptionBots.made`) is keyed by the
|
|
8
|
-
// account and its owner, so signing in again never makes a second, and
|
|
9
|
-
// a bot the person deleted is not made again.
|
|
10
|
-
// 2. It runs only on its provider's own engine, on accounts of that
|
|
11
|
-
// provider on its own side of the personal line: never the Atomic
|
|
12
|
-
// Engine, the company connection, a pay-per-use key or another
|
|
13
|
-
// provider. Every ladder walk, failover, stand-in, rebalance and manual
|
|
14
|
-
// pick reads the same refusal (`providerAllows`).
|
|
15
|
-
// 3. Its model is the best its account offers for the bot's job, within
|
|
16
|
-
// the provider; a person may pick any other model the account offers.
|
|
17
|
-
// 4. When none of its accounts can take a turn, it stops with one line and
|
|
18
|
-
// offers Chibi. It never fails over to another provider.
|
|
19
|
-
// 5. An account marked Personal makes a bot under the Private Bot's rules
|
|
20
|
-
// (server/personal-bot.ts): `personal: true` on the bot.
|
|
21
1
|
import { ATOMIC_ENGINE_INSTANCE } from "./harness-import/engine-policy.js";
|
|
22
2
|
import { MODEL_CLASSES } from "../shared/engine-tiers.js";
|
|
23
3
|
import { SUBSCRIPTION_PROVIDERS, isProviderModel, isSubscriptionBot, subscriptionBotName, subscriptionProvider, subscriptionProviderForEngine, } from "../shared/subscription-bots.js";
|
|
@@ -34,7 +14,8 @@ export function subscriptionAccount(candidate, facts) {
|
|
|
34
14
|
return undefined;
|
|
35
15
|
if (facts.companyManaged(candidate.instanceId))
|
|
36
16
|
return undefined;
|
|
37
|
-
|
|
17
|
+
// a pay-per-use key or a free tier is never a plan that gets its own bot
|
|
18
|
+
if (candidate.billing === "payPerUse" || candidate.billing === "free")
|
|
38
19
|
return undefined;
|
|
39
20
|
if (provider.planOnly && candidate.billing !== "subscription")
|
|
40
21
|
return undefined;
|
|
@@ -46,6 +46,34 @@ export function workspaceResource(cwd) {
|
|
|
46
46
|
const canonical = realpathSync.native(resolve(cwd));
|
|
47
47
|
return `workspace:${process.platform === "win32" ? canonical.toLowerCase() : canonical}`;
|
|
48
48
|
}
|
|
49
|
+
/** The folder a queued turn will claim, predicted without side effects so
|
|
50
|
+
* the handoff queue can wait for it instead of dispatching straight into
|
|
51
|
+
* workspace_busy. Mirrors prepareTurnRepos + pinTaskCwd: a pinned folder
|
|
52
|
+
* stays; an unstarted thread takes its worktree or primary repo, then the
|
|
53
|
+
* bot's folder; a worktree still to be created is new, so nothing holds it. */
|
|
54
|
+
export function queuedTurnFolder(taskCwd, started, plan, botCwd) {
|
|
55
|
+
if (taskCwd !== undefined)
|
|
56
|
+
return taskCwd;
|
|
57
|
+
if (started)
|
|
58
|
+
return null;
|
|
59
|
+
if (plan?.createWorktree)
|
|
60
|
+
return null;
|
|
61
|
+
const repoCwd = plan?.worktree && plan.primary && plan.worktree.repoId === plan.primary.id ? plan.worktree.path : plan?.primary?.path;
|
|
62
|
+
return repoCwd ?? botCwd ?? null;
|
|
63
|
+
}
|
|
64
|
+
/** True when another thread holds `folder`, so a queued handoff waits
|
|
65
|
+
* like it does for a busy bot instead of failing with workspace_busy. A
|
|
66
|
+
* missing folder reads as free so the turn itself reports the real error. */
|
|
67
|
+
export function folderHeldElsewhere(resources, folder, threadId) {
|
|
68
|
+
if (!folder)
|
|
69
|
+
return false;
|
|
70
|
+
try {
|
|
71
|
+
return Boolean(resources.blocker(workspaceResource(folder), { threadId, generation: "" }));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
49
77
|
function overlaps(a, b) {
|
|
50
78
|
if (a === b)
|
|
51
79
|
return true;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The .ombbackup container. Two layouts share one extension:
|
|
2
|
+
// encrypted "OMB-WORKSPACE-1\n" + salt(16) + iv(12), AES-256-GCM tar, tag(16).
|
|
3
|
+
// The key comes from the passphrase through scrypt; the header is
|
|
4
|
+
// authenticated too. Only this layout may carry secrets.
|
|
5
|
+
// plain "OMB-WORKSPACE-PLAIN-1\n", tar, SHA-256 of everything before it.
|
|
6
|
+
// The checksum catches damage, not deliberate edits, which is why
|
|
7
|
+
// a plain backup never holds credentials.
|
|
8
|
+
// Nothing is parsed until the whole file has been authenticated or checked.
|
|
9
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from "node:crypto";
|
|
10
|
+
import { closeSync, constants, createReadStream, createWriteStream, fstatSync, fsyncSync, openSync, readSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import * as tar from "tar";
|
|
13
|
+
export const MAX_WORKSPACE_BACKUP_BYTES = 64 * 1024 ** 3;
|
|
14
|
+
export const MAX_WORKSPACE_BACKUP_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_BYTES + 256 * 1024 ** 2;
|
|
15
|
+
const MAGIC = Buffer.from("OMB-WORKSPACE-1\n");
|
|
16
|
+
const PLAIN = Buffer.from("OMB-WORKSPACE-PLAIN-1\n");
|
|
17
|
+
const HEADER_BYTES = MAGIC.length + 16 + 12;
|
|
18
|
+
const TAG_BYTES = 16;
|
|
19
|
+
const DIGEST_BYTES = 32;
|
|
20
|
+
const DAMAGED = "The backup password is incorrect, or the backup file is damaged. Nothing was restored.";
|
|
21
|
+
export function passwordKey(password, salt) {
|
|
22
|
+
if (typeof password !== "string" || password.length < 12 || Buffer.byteLength(password) > 1024) {
|
|
23
|
+
throw new Error("Use a backup password of at least 12 characters (at most 1024 bytes).");
|
|
24
|
+
}
|
|
25
|
+
return new Promise((resolveKey, reject) => {
|
|
26
|
+
scrypt(password, salt, 32, { N: 131_072, r: 8, p: 1, maxmem: 256 * 1024 ** 2 }, (error, key) => {
|
|
27
|
+
if (error)
|
|
28
|
+
reject(error);
|
|
29
|
+
else
|
|
30
|
+
resolveKey(key);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Tar `names` from `cwd` into `path`; encrypted when a key is given. */
|
|
35
|
+
export async function writeArchive(path, cwd, names, encryption) {
|
|
36
|
+
const source = () => tar.c({ cwd, portable: true, strict: true }, names);
|
|
37
|
+
if (!encryption) {
|
|
38
|
+
writeFileSync(path, PLAIN, { mode: 0o600, flag: "wx" });
|
|
39
|
+
await pipeline(source(), createWriteStream(path, { flags: "a", mode: 0o600 }));
|
|
40
|
+
const digest = await digestRange(path, 0);
|
|
41
|
+
const fd = openSync(path, "a");
|
|
42
|
+
try {
|
|
43
|
+
writeSync(fd, digest);
|
|
44
|
+
fsyncSync(fd);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const iv = randomBytes(12);
|
|
52
|
+
const header = Buffer.concat([MAGIC, encryption.salt, iv]);
|
|
53
|
+
const cipher = createCipheriv("aes-256-gcm", encryption.key, iv);
|
|
54
|
+
cipher.setAAD(header);
|
|
55
|
+
writeFileSync(path, header, { mode: 0o600, flag: "wx" });
|
|
56
|
+
await pipeline(source(), cipher, createWriteStream(path, { flags: "a", mode: 0o600 }));
|
|
57
|
+
const fd = openSync(path, "a");
|
|
58
|
+
try {
|
|
59
|
+
writeSync(fd, cipher.getAuthTag());
|
|
60
|
+
fsyncSync(fd);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function digestRange(path, trailer) {
|
|
67
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
68
|
+
try {
|
|
69
|
+
const size = fstatSync(fd).size - trailer;
|
|
70
|
+
const hash = createHash("sha256");
|
|
71
|
+
if (size > 0)
|
|
72
|
+
await pipeline(createReadStream(path, { fd, autoClose: false, start: 0, end: size - 1 }), hash);
|
|
73
|
+
return hash.digest();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Whether a backup file needs a password, without reading past its header. */
|
|
80
|
+
export function archiveEncrypted(path) {
|
|
81
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
82
|
+
try {
|
|
83
|
+
const head = Buffer.alloc(PLAIN.length);
|
|
84
|
+
const count = readSync(fd, head, 0, head.length, 0);
|
|
85
|
+
if (count >= PLAIN.length && head.equals(PLAIN))
|
|
86
|
+
return false;
|
|
87
|
+
if (count >= MAGIC.length && head.subarray(0, MAGIC.length).equals(MAGIC))
|
|
88
|
+
return true;
|
|
89
|
+
throw new Error("This is not a supported workspace backup.");
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Authenticate (or checksum) the whole file, then write its tar payload. */
|
|
96
|
+
export async function readArchive(inputPath, plaintext, password) {
|
|
97
|
+
const encrypted = archiveEncrypted(inputPath);
|
|
98
|
+
const fd = openSync(inputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
99
|
+
let key;
|
|
100
|
+
try {
|
|
101
|
+
const stat = fstatSync(fd);
|
|
102
|
+
if (!stat.isFile() || stat.size > MAX_WORKSPACE_BACKUP_UPLOAD_BYTES)
|
|
103
|
+
throw new Error("Invalid or oversized workspace backup file.");
|
|
104
|
+
if (!encrypted) {
|
|
105
|
+
if (stat.size < PLAIN.length + DIGEST_BYTES)
|
|
106
|
+
throw new Error("The backup file is damaged. Nothing was restored.");
|
|
107
|
+
const expected = Buffer.alloc(DIGEST_BYTES);
|
|
108
|
+
readSync(fd, expected, 0, DIGEST_BYTES, stat.size - DIGEST_BYTES);
|
|
109
|
+
if (!(await digestRange(inputPath, DIGEST_BYTES)).equals(expected))
|
|
110
|
+
throw new Error("The backup file is damaged. Nothing was restored.");
|
|
111
|
+
await pipeline(createReadStream(inputPath, { fd, autoClose: false, start: PLAIN.length, end: stat.size - DIGEST_BYTES - 1 }), createWriteStream(plaintext, { flags: "wx", mode: 0o600 }));
|
|
112
|
+
return { encrypted };
|
|
113
|
+
}
|
|
114
|
+
if (!password)
|
|
115
|
+
throw new Error("This backup is encrypted. Enter its password.");
|
|
116
|
+
if (stat.size < HEADER_BYTES + TAG_BYTES)
|
|
117
|
+
throw new Error("This is not a supported encrypted workspace backup.");
|
|
118
|
+
const header = Buffer.alloc(HEADER_BYTES);
|
|
119
|
+
const tag = Buffer.alloc(TAG_BYTES);
|
|
120
|
+
readSync(fd, header, 0, header.length, 0);
|
|
121
|
+
readSync(fd, tag, 0, tag.length, stat.size - TAG_BYTES);
|
|
122
|
+
key = await passwordKey(password, header.subarray(MAGIC.length, MAGIC.length + 16));
|
|
123
|
+
const decipher = createDecipheriv("aes-256-gcm", key, header.subarray(MAGIC.length + 16));
|
|
124
|
+
decipher.setAAD(header);
|
|
125
|
+
decipher.setAuthTag(tag);
|
|
126
|
+
try {
|
|
127
|
+
await pipeline(createReadStream(inputPath, { fd, autoClose: false, start: HEADER_BYTES, end: stat.size - TAG_BYTES - 1 }), decipher, createWriteStream(plaintext, { flags: "wx", mode: 0o600 }));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error(DAMAGED);
|
|
131
|
+
}
|
|
132
|
+
return { encrypted };
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
key?.fill(0);
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -6,6 +6,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { WORKSPACE_BACKUP_CLIENT_KEYS } from "../shared/workspace-backup-client.js";
|
|
8
8
|
import { createWorkspaceBackup, stageWorkspaceBackup, commitPendingWorkspaceRestore, MAX_WORKSPACE_BACKUP_UPLOAD_BYTES, removeWorkspaceBackupJob, } from "./workspace-backup.js";
|
|
9
|
+
import { desktopWorkspaceSecrets } from "./workspace-backup-secrets.js";
|
|
9
10
|
import { listSavedBackups, revealInFileManager, saveWorkspaceBackup, savedBackupPath, savedWorkspaceBackupFile } from "./atomic-backup.js";
|
|
10
11
|
const PREFIX = "/api/workspace-backup";
|
|
11
12
|
const MAX_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_UPLOAD_BYTES;
|
|
@@ -13,6 +14,7 @@ const MAX_CLIENT_STATE_BYTES = 2 * 1024 ** 2;
|
|
|
13
14
|
const EXPIRES_MS = 60 * 60_000;
|
|
14
15
|
const passwordSchema = z.string().min(12, "Use a password of at least 12 characters.").max(1024);
|
|
15
16
|
const failure = (message, status = 400) => Object.assign(new Error(message), { status });
|
|
17
|
+
const TOO_LARGE = "Select a backup containing no more than 64 GB of workspace data.";
|
|
16
18
|
/** Session-only controls remain usable while the workspace is locked. Their
|
|
17
19
|
* excluded session file is not part of the snapshot or replacement. */
|
|
18
20
|
export function isWorkspaceBackupSessionControl(method, path) {
|
|
@@ -25,7 +27,12 @@ export function isWorkspaceBackupSessionControl(method, path) {
|
|
|
25
27
|
export function createWorkspaceBackupRoutes(options) {
|
|
26
28
|
// ponytail: one bounded set per server; transfers expire after an hour.
|
|
27
29
|
const artifacts = new Map();
|
|
30
|
+
// A staged backup's desktop secrets wait here, never on disk, until the
|
|
31
|
+
// person confirms the restore or the staged backup expires.
|
|
32
|
+
const stagedSecrets = new Map();
|
|
28
33
|
let operating = false;
|
|
34
|
+
let progress = null;
|
|
35
|
+
const onProgress = (next) => { progress = next; };
|
|
29
36
|
const json = (res, status, body) => {
|
|
30
37
|
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
31
38
|
res.end(JSON.stringify(body));
|
|
@@ -49,6 +56,7 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
49
56
|
catch { /* Recovery jobs are deliberately protected. */ }
|
|
50
57
|
}
|
|
51
58
|
artifacts.delete(id);
|
|
59
|
+
stagedSecrets.delete(id);
|
|
52
60
|
}
|
|
53
61
|
// A crashed process has no in-memory transfer map. Reclaim only its old,
|
|
54
62
|
// app-named jobs; never safety copies or pending/committed recovery state.
|
|
@@ -126,6 +134,7 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
126
134
|
}
|
|
127
135
|
finally {
|
|
128
136
|
operating = false;
|
|
137
|
+
progress = null;
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
return async (req, res, path, auth) => {
|
|
@@ -136,10 +145,17 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
136
145
|
try {
|
|
137
146
|
check(req, auth);
|
|
138
147
|
if (method === "GET" && path === `${PREFIX}/status`) {
|
|
148
|
+
const restored = options.restored;
|
|
139
149
|
json(res, 200, {
|
|
140
150
|
...options.status(),
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
progress,
|
|
152
|
+
lastRestoreId: restored.restored ? restored.id : null,
|
|
153
|
+
safetyCopyPath: restored.safetyCopyPath ?? null,
|
|
154
|
+
// What Settings shows after a restore: what to sign in to or clone again.
|
|
155
|
+
lastRestore: restored.restored ? {
|
|
156
|
+
secretsIncluded: restored.summary?.secrets === true, secrets: restored.secrets ?? null,
|
|
157
|
+
unresolvedPaths: restored.unresolvedPaths ?? [], checkouts: restored.summary?.checkouts ?? [], exclusions: restored.summary?.exclusions ?? [],
|
|
158
|
+
} : null,
|
|
143
159
|
});
|
|
144
160
|
return true;
|
|
145
161
|
}
|
|
@@ -153,16 +169,32 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
153
169
|
}
|
|
154
170
|
if (method === "POST" && path === `${PREFIX}/export`) {
|
|
155
171
|
// Allow the full preference budget plus the JSON envelope/password.
|
|
156
|
-
|
|
172
|
+
// Encryption is on unless the caller turns it off; secrets need it.
|
|
173
|
+
const body = z.object({
|
|
174
|
+
password: passwordSchema.optional(), encrypted: z.boolean().default(true), includeSecrets: z.boolean().default(false),
|
|
175
|
+
clientState: z.record(z.string(), z.string()).default({}), excludePrivateBot: z.boolean().default(false),
|
|
176
|
+
}).parse(await options.readBody(req, MAX_CLIENT_STATE_BYTES + 8 * 1024));
|
|
157
177
|
if (Buffer.byteLength(JSON.stringify(body.clientState)) > MAX_CLIENT_STATE_BYTES)
|
|
158
178
|
throw failure("Saved drafts exceed the 2 MB backup preference limit.", 413);
|
|
179
|
+
if (body.encrypted !== (body.password !== undefined))
|
|
180
|
+
throw failure(body.encrypted ? "Set a backup password of at least 12 characters." : "An unencrypted backup does not use a password.");
|
|
181
|
+
if (body.includeSecrets && !body.encrypted)
|
|
182
|
+
throw failure("Keys, passwords and tokens are only included in an encrypted backup.");
|
|
159
183
|
const clientState = Object.fromEntries(Object.entries(body.clientState).filter(([key]) => WORKSPACE_BACKUP_CLIENT_KEYS.includes(key)));
|
|
160
184
|
const result = await operate(() => options.exclusive(async () => {
|
|
161
185
|
check(req, auth);
|
|
162
186
|
replacePrevious(auth, ["download"]);
|
|
163
187
|
if (artifacts.size >= 4)
|
|
164
188
|
throw failure("There are already four backup files in progress. Restart or wait an hour before creating another.", 409);
|
|
165
|
-
|
|
189
|
+
const desktop = body.includeSecrets ? desktopWorkspaceSecrets() : null;
|
|
190
|
+
const secrets = desktop ? await desktop.export().catch((error) => {
|
|
191
|
+
console.error(`[workspace-backup] secure credential store not read: ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
return null;
|
|
193
|
+
}) : null;
|
|
194
|
+
return createWorkspaceBackup(options.dataDir, {
|
|
195
|
+
password: body.password, includeSecrets: body.includeSecrets, secrets, onProgress,
|
|
196
|
+
clientState, appVersion: options.appVersion, excludePrivateBot: body.excludePrivateBot,
|
|
197
|
+
});
|
|
166
198
|
}));
|
|
167
199
|
try {
|
|
168
200
|
check(req, auth);
|
|
@@ -211,7 +243,7 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
211
243
|
if (artifacts.size >= 4)
|
|
212
244
|
throw failure("There are already four backup files in progress. Restart or wait an hour before importing another.", 409);
|
|
213
245
|
if (statSync(source).size > MAX_UPLOAD_BYTES)
|
|
214
|
-
throw failure(
|
|
246
|
+
throw failure(TOO_LARGE, 413);
|
|
215
247
|
const id = randomUUID();
|
|
216
248
|
const file = join(uploadsDirectory(), `${id}.ombbackup`);
|
|
217
249
|
copyFileSync(source, file);
|
|
@@ -237,7 +269,7 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
237
269
|
await operate(async () => {
|
|
238
270
|
const length = Number(req.headers["content-length"]);
|
|
239
271
|
if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_UPLOAD_BYTES)
|
|
240
|
-
throw failure(
|
|
272
|
+
throw failure(TOO_LARGE, 413);
|
|
241
273
|
replacePrevious(auth, ["upload", "staged"]);
|
|
242
274
|
if (artifacts.size >= 4)
|
|
243
275
|
throw failure("There are already four backup files in progress. Restart or wait an hour before uploading another.", 409);
|
|
@@ -268,9 +300,10 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
268
300
|
return true;
|
|
269
301
|
}
|
|
270
302
|
if (method === "POST" && path === `${PREFIX}/preview`) {
|
|
271
|
-
|
|
303
|
+
// An unencrypted backup needs no password; an encrypted one says so.
|
|
304
|
+
const body = z.object({ id: z.string().uuid(), password: passwordSchema.optional() }).parse(await options.readBody(req));
|
|
272
305
|
const artifact = get(body.id, auth, "upload");
|
|
273
|
-
const result = await operate(() => stageWorkspaceBackup(options.dataDir, artifact.path, { password: body.password, currentAppVersion: options.appVersion }));
|
|
306
|
+
const result = await operate(() => stageWorkspaceBackup(options.dataDir, artifact.path, { password: body.password, currentAppVersion: options.appVersion, onProgress }));
|
|
274
307
|
try {
|
|
275
308
|
check(req, auth);
|
|
276
309
|
}
|
|
@@ -284,6 +317,8 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
284
317
|
catch { }
|
|
285
318
|
artifacts.delete(body.id);
|
|
286
319
|
artifacts.set(result.id, { owner: owner(auth), kind: "staged", summary: result.summary, expires: Date.now() + EXPIRES_MS });
|
|
320
|
+
if (result.secrets)
|
|
321
|
+
stagedSecrets.set(result.id, result.secrets);
|
|
287
322
|
json(res, 200, { id: result.id, summary: result.summary });
|
|
288
323
|
return true;
|
|
289
324
|
}
|
|
@@ -292,9 +327,16 @@ export function createWorkspaceBackupRoutes(options) {
|
|
|
292
327
|
get(body.id, auth, "staged");
|
|
293
328
|
const result = await operate(() => options.exclusive(async () => {
|
|
294
329
|
check(req, auth);
|
|
330
|
+
// The desktop holds the saved credentials until the restored files
|
|
331
|
+
// are in place (next boot); refusing here leaves nothing changed.
|
|
332
|
+
const secrets = stagedSecrets.get(body.id);
|
|
333
|
+
const desktop = desktopWorkspaceSecrets();
|
|
334
|
+
if (secrets && desktop)
|
|
335
|
+
await desktop.stage(body.id, secrets);
|
|
295
336
|
return commitPendingWorkspaceRestore(options.dataDir, body.id);
|
|
296
337
|
}, true));
|
|
297
338
|
artifacts.delete(body.id);
|
|
339
|
+
stagedSecrets.delete(body.id);
|
|
298
340
|
json(res, 200, { ...result, restoreId: body.id });
|
|
299
341
|
return true;
|
|
300
342
|
}
|