@chibitek/chibi 0.1.171 → 0.1.173

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.
Files changed (83) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-DrPBKmVh.js → abnfDiagram-O67JEVCF-DaA9WpZw.js} +1 -1
  2. package/dist/assets/{arc-BEIChVSB.js → arc-DJ4Kdvv6.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-Da60nTzs.js → architectureDiagram-NJMV4G6O-B975BUTv.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-_NelwIQj.js → blockDiagram-BEXU5L5S-CXOOfLy7.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-DUdkQ3dy.js → c4Diagram-YGBWAQC7-CorqQ94E.js} +1 -1
  6. package/dist/assets/channel-sBYHnjxz.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BQgYAptM.js → chunk-2Q5K7J3B-CPFHpKez.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-XXOXwcoW.js → chunk-3FUC2YCW-CrzBuB3A.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-CUyMXCSt.js → chunk-5VM5RSS4-DXJTBdvO.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-BwctbINm.js → chunk-GTNCS2PH-B0TxALG4.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-DQ0bZK59.js → chunk-GWA4HPMP-BL44uVOP.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-BADCk9AK.js → chunk-JWPE2WC7-CxSklH7P.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-CoJxabSR.js → chunk-NETBCI7D-_Lb-D8AY.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-fupByVJn.js → chunk-WEXAMYUT-Dz5jp0xb.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BavbGHGi.js → chunk-XXDRQBXY-C-cd9N0r.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-B5wfUNKJ.js → classDiagram-v2-NBCMYWYE-DtQ1781-.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-DlYFmKDe.js → cose-bilkent-JH36ORCC-CHzWXNdH.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-Y0U-21MH.js → cynefin-OW5HDTMX-lHbKcf1S.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-D1Fh1sU0.js → cynefinDiagram-VND7K2PF-BxY6MCKD.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-BNU-Trog.js → dagre-6A5THRUB-C2SBw-Mo.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-Bjie4nDQ.js → diagram-22UHCM2B-BwIAlmAU.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-Qnbrq1T5.js → diagram-3UASUU5V-i6fBkckD.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-CNJasPCN.js → diagram-ATOU4E4O-DwedCbB_.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-BAzGZBKX.js → diagram-CDSNMT55-BISF-AQC.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-I8ACRaw-.js → diagram-MLGK6HIB-ImgdRRxp.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-nLlrAEPz.js → diagram-MPIPVDR6-CxlAwPCS.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-DF9BG3pH.js → ebnfDiagram-ZINNZB2B-sVJkymKa.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-C1AJYKMV.js → elk-276RUBZZ-DU1vyX4w.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-MNNcRrPy.js → erDiagram-OPXOYQCR-CnCmL1Ve.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-C7mfBfgt.js → flowDiagram-KWPJA3E3-DME2uLJf.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-a9zNyDbb.js → ganttDiagram-FUAMR5RP-CJQNW9ts.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-OL8NhUsO.js → gitGraphDiagram-X574FWY7-D88_Y9tg.js} +1 -1
  33. package/dist/assets/{index-xypOVpVT.js → index-ChmZZe7i.js} +1 -1
  34. package/dist/assets/{index-DojGBgbN.js → index-U9LN4GhF.js} +140 -140
  35. package/dist/assets/{infoDiagram-VRGFBTTK-CF-3pzUZ.js → infoDiagram-VRGFBTTK-CxWEf3NI.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-OU5B5YK6-B2HqD-sv.js → ishikawaDiagram-OU5B5YK6-B_YoNVjF.js} +1 -1
  37. package/dist/assets/{journeyDiagram-ZHPQQLJL-84gzOTb9.js → journeyDiagram-ZHPQQLJL-ZYuBE2Bm.js} +1 -1
  38. package/dist/assets/{kanban-definition-PNTS6WVX-D7Ee23QC.js → kanban-definition-PNTS6WVX-jmY_M4Qf.js} +1 -1
  39. package/dist/assets/{linear-CdAXsmfc.js → linear-kKUF6ayN.js} +1 -1
  40. package/dist/assets/{mermaid.core-CE_M1v1e.js → mermaid.core-DC5051S_.js} +5 -5
  41. package/dist/assets/{mindmap-definition-NLK3R4M7-KSTIET9y.js → mindmap-definition-NLK3R4M7-C5knfygJ.js} +1 -1
  42. package/dist/assets/{pegDiagram-GJSIUBJH-C7ogvddb.js → pegDiagram-GJSIUBJH-Ex6iedL2.js} +1 -1
  43. package/dist/assets/{pieDiagram-5QR66LMP-Bp1CgCrM.js → pieDiagram-5QR66LMP-Dv38Ox0D.js} +1 -1
  44. package/dist/assets/{quadrantDiagram-O4NWA36T-Bw7aN9Lh.js → quadrantDiagram-O4NWA36T-CwOEor8N.js} +1 -1
  45. package/dist/assets/{railroadDiagram-XR7U4H2S-Dij4U4NY.js → railroadDiagram-XR7U4H2S-DVgqz5MR.js} +1 -1
  46. package/dist/assets/{requirementDiagram-PLB6GJNP-DOxTQvCl.js → requirementDiagram-PLB6GJNP-BxiuYo52.js} +1 -1
  47. package/dist/assets/{sankeyDiagram-IPEJSGJF-DPCUmNbV.js → sankeyDiagram-IPEJSGJF--vq_1flP.js} +1 -1
  48. package/dist/assets/{sequenceDiagram-PO4LG4MO-DPjC8zGe.js → sequenceDiagram-PO4LG4MO-9hLpfPA6.js} +1 -1
  49. package/dist/assets/{sizeCapture-INFHLROL-WlmsNlh-.js → sizeCapture-INFHLROL-BeGMkj9s.js} +1 -1
  50. package/dist/assets/{stateDiagram-v2-GCMORJYK-BwMPiapI.js → stateDiagram-v2-GCMORJYK-BnNOMugb.js} +1 -1
  51. package/dist/assets/{swimlanes-2SLR337P-Cyja7ZPb.js → swimlanes-2SLR337P-C1YG6Jq-.js} +1 -1
  52. package/dist/assets/swimlanesDiagram-TC7HE7FX-9DghUs7S.js +8 -0
  53. package/dist/assets/{timeline-definition-EJHVYXUP-BehkSauz.js → timeline-definition-EJHVYXUP-Co7OtwGA.js} +1 -1
  54. package/dist/assets/{usecaseDiagram-POWQR4AR-koUjcmpW.js → usecaseDiagram-POWQR4AR-BYHFARv3.js} +1 -1
  55. package/dist/assets/{vennDiagram-UO4OBE2U-Ch8TXIpy.js → vennDiagram-UO4OBE2U-C-sdhL3h.js} +1 -1
  56. package/dist/assets/{wardleyDiagram-VNRHLVJA-Dm5kC4c2.js → wardleyDiagram-VNRHLVJA-wf8ZNrmQ.js} +1 -1
  57. package/dist/assets/{xychartDiagram-PMCCYNJV-YA-1zyQz.js → xychartDiagram-PMCCYNJV-BjRlkVGv.js} +1 -1
  58. package/dist/index.html +1 -1
  59. package/dist-server/companion/src/routes.js +8 -0
  60. package/dist-server/index.js +2139 -1526
  61. package/dist-server/openmausbot.js +63 -0
  62. package/dist-server/pair-cli.js +63 -0
  63. package/dist-server/server/builtin-chibi.js +1 -1
  64. package/dist-server/server/drivers/acp/custom.js +3 -0
  65. package/dist-server/server/engine-tiers-view.js +49 -0
  66. package/dist-server/server/engine-tiers.js +37 -55
  67. package/dist-server/server/harness-import/engine-policy.js +6 -1
  68. package/dist-server/server/index.js +12 -13
  69. package/dist-server/server/personal-bot.js +0 -26
  70. package/dist-server/server/remote-desktop.js +139 -7
  71. package/dist-server/server/routes/engine-tiers.js +12 -1
  72. package/dist-server/server/subscription-bots.js +2 -21
  73. package/dist-server/server/turn-resources.js +28 -0
  74. package/dist-server/server/workspace-backup-archive.js +138 -0
  75. package/dist-server/server/workspace-backup-http.js +50 -8
  76. package/dist-server/server/workspace-backup-private.js +83 -0
  77. package/dist-server/server/workspace-backup-secrets.js +90 -0
  78. package/dist-server/server/workspace-backup.js +171 -235
  79. package/dist-server/server/workspace-restore-paths.js +186 -0
  80. package/dist-server/shared/provider-accounts.js +29 -0
  81. package/package.json +1 -1
  82. package/dist/assets/channel-DEJSSlVQ.js +0 -1
  83. package/dist/assets/swimlanesDiagram-TC7HE7FX-Dh25zxzq.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({ screenshot_width: z.number().int().positive(), screenshot_height: z.number().int().positive() })
59
- .parse(await call("get_desktop_state", {}, png));
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
- return {
63
- image: `data:image/jpeg;base64,${(await readFile(jpeg)).toString("base64")}`,
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
- if (m[2] === "frame")
124
- return reply(200, await remoteFrame(call));
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 input = remoteInputSchema.safeParse(await readBody(req));
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, engineTiersView, rebalanceRow, withLadder, withTierClass } from "../engine-tiers.js";
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
- if (candidate.billing === "payPerUse")
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
- lastRestoreId: options.restored.restored ? options.restored.id : null,
142
- safetyCopyPath: options.restored.safetyCopyPath ?? null,
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
- const body = z.object({ password: passwordSchema, clientState: z.record(z.string(), z.string()).default({}), excludePrivateBot: z.boolean().default(false) }).parse(await options.readBody(req, MAX_CLIENT_STATE_BYTES + 8 * 1024));
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
- return createWorkspaceBackup(options.dataDir, { password: body.password, clientState, appVersion: options.appVersion, excludePrivateBot: body.excludePrivateBot });
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("Select a backup containing no more than 10 GB of workspace data.", 413);
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("Select a backup containing no more than 10 GB of workspace data.", 413);
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
- const body = z.object({ id: z.string().uuid(), password: passwordSchema }).parse(await options.readBody(req));
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
  }