workbite 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -3,22 +3,20 @@
3
3
  WorkBite installs and opens **Work Stack** (organizes the work) and
4
4
  **StackBite** (gets it moving) as one native suite.
5
5
 
6
- Launcher 0.1.8 (native bundle 0.1.8) is a **Windows x64 only** release. It
7
- opens the familiar TypeScript Work Stack UI by default, which has
8
- `9 Execution` in the same window: select a Task, start its agent, and see and
9
- control its native terminal without leaving the UI. New in 0.1.8: a one-screen
10
- launch panel (provider/model/effort, work folder with recent folders and an
11
- optional workspace default), `9` / `a` to show every Run again, `Ctrl+B` to
12
- hide the side menu in Execution, and typed, retained StackBite launch-refusal
13
- diagnostics. The unified Rust UI stays
6
+ Launcher 0.1.10 (native bundle 0.1.10) supports **Windows x64 and Linux x64**
7
+ (glibc 2.28+). It opens the familiar TypeScript Work Stack UI by default, which
8
+ has `9 Execution` in the same window: select a Task, start its agent, and see
9
+ and control its native terminal without leaving the UI. New in 0.1.10: Cursor
10
+ CLI as a raw native provider in opted-in Rooms (you approve its trust; its
11
+ lifecycle/resume is not verified yet). The unified Rust UI stays
14
12
  an explicit alternative per open: `workbite open --frontend rust` (or bare
15
13
  `workbite --frontend rust`). Either frontend reuses the same owner and
16
14
  StackBite backend; the choice is not saved. Leaving the view or closing the UI
17
15
  keeps the agent running, and Task completion stays an explicit action.
18
16
 
19
- Linux x64: keep using `workbite@0.1.6` (native bundle 0.1.6, glibc 2.28+)
20
- until separate Linux compatibility work for this feature is done; 0.1.8 does
21
- not install on Linux. Updating from the 0.1.4 native bundle requires an
17
+ Linux x64 support is verified for the glibc 2.28 ELF build and the POSIX PTY
18
+ in an AlmaLinux 8 userspace; real AI agent turns on Linux, a booted AlmaLinux 8
19
+ kernel and SSH/VM use are not yet proven. Updating from the 0.1.4 native bundle requires an
22
20
  operator handover of existing backend processes; the launcher does not stop
23
21
  them or clear startup records automatically.
24
22
 
@@ -30,7 +28,7 @@ workbite # afterwards (TypeScr
30
28
  workbite --frontend rust # unified Rust UI with 9 Execution
31
29
  ```
32
30
 
33
- Node.js 20+ on Windows x64 (Linux x64: `npm install -g workbite@0.1.6`).
31
+ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
34
32
 
35
33
  - `workbite install` downloads the pinned bundle for your platform from
36
34
  `github.com/Shinick-Han/WorkBite-releases`, verifies its size and SHA-256 and
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const { execFile } = require('node:child_process');
5
+ // WMI LastBootUpTime is the OS boot time, not wall-clock minus uptime:
6
+ // https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-operatingsystem
7
+ // Use the framework WMI API directly, with no profile, PATH or module lookup.
8
+ const SCRIPT = String.raw`$ErrorActionPreference='Stop'
9
+ Add-Type -AssemblyName System.Management
10
+ $q = [System.Management.ManagementObjectSearcher]::new('SELECT LastBootUpTime FROM Win32_OperatingSystem')
11
+ try {
12
+ $rows = @($q.Get())
13
+ if ($rows.Count -ne 1) { throw 'ambiguous operating system' }
14
+ $r = $rows[0]
15
+ $t = [System.Management.ManagementDateTimeConverter]::ToDateTime([string]$r.LastBootUpTime).ToUniversalTime().Ticks
16
+ $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Cryptography')
17
+ try { $hostId = [string]$key.GetValue('MachineGuid') } finally { $key.Dispose() }
18
+ [Console]::Out.Write($hostId.ToLowerInvariant() + ':' + $t.ToString([Globalization.CultureInfo]::InvariantCulture))
19
+ } finally { $q.Dispose() }`;
20
+ function validBoot(value) {
21
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 3
22
+ && value.host !== '0'.repeat(32) && value.id !== '00000000-0000-0000-0000-000000000000'
23
+ && typeof value.host === 'string' && /^[0-9a-f]{32}$/.test(value.host)
24
+ && typeof value.id === 'string'
25
+ && ((value.source === 'linux-boot-id' && /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.id))
26
+ || (value.source === 'windows-wmi-boot-time' && /^[1-9][0-9]{17}$/.test(value.id)));
27
+ }
28
+ async function bootIdentity({ platform = process.platform, environment = process.env, execImpl = execFile,
29
+ readImpl = fs.readFile } = {}) {
30
+ let result;
31
+ try {
32
+ if (platform === 'linux') result = { source: 'linux-boot-id',
33
+ host: (await readImpl('/etc/machine-id', 'utf8')).trim(),
34
+ id: (await readImpl('/proc/sys/kernel/random/boot_id', 'utf8')).trim() };
35
+ else if (platform === 'win32') {
36
+ const root = environment.SystemRoot || environment.SYSTEMROOT || 'C:\\Windows';
37
+ if (!/^[A-Za-z]:\\[^\x00-\x1f"*?<>|]*$/.test(root)) return null;
38
+ const id = await new Promise(resolve => execImpl(path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
39
+ ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(SCRIPT, 'utf16le').toString('base64')],
40
+ { env: { SystemRoot: root }, shell: false, windowsHide: true, timeout: 5000, maxBuffer: 4096, encoding: 'utf8' },
41
+ (error, stdout) => resolve(error ? '' : String(stdout).trim())));
42
+ const [host, ticks, extra] = id.split(':');
43
+ if (extra !== undefined || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(host || '')) return null;
44
+ result = { source: 'windows-wmi-boot-time', host: host.replace(/-/g, ''), id: ticks };
45
+ }
46
+ } catch { return null; }
47
+ return validBoot(result) ? result : null;
48
+ }
49
+ function previousBoot(saved, current) {
50
+ if (!validBoot(saved) || !validBoot(current) || saved.source !== current.source || saved.host !== current.host) return false;
51
+ if (saved.source === 'linux-boot-id') return saved.id !== current.id;
52
+ return BigInt(saved.id) < BigInt(current.id);
53
+ }
54
+ module.exports = { bootIdentity, validBoot, previousBoot };
package/lib/runtime.js CHANGED
@@ -12,6 +12,7 @@ const path = require('node:path');
12
12
  const { spawn, execFile } = require('node:child_process');
13
13
  const { randomBytes, randomUUID } = require('node:crypto');
14
14
  const { fail, exists, safeDirectory, writeAtomic, readJson, readSmall, sha256, UUID, BACKEND_ID } = require('./common');
15
+ const { bootIdentity, validBoot, previousBoot } = require('./boot-provenance');
15
16
  const { BUNDLE_V2 } = require('./install');
16
17
  const { protectPrivateDirectories } = require('./private-state');
17
18
 
@@ -284,14 +285,17 @@ async function readyRecord(log) {
284
285
  // Startup intent (private token, launch id, port) is saved before any child is
285
286
  // spawned, so an attempt that timed out or crashed is never followed by a second
286
287
  // manager on the same state: a later open either authenticates that same attempt
287
- // or refuses. Records are never cleared and nothing is stopped automatically.
288
+ // or refuses. Proven previous-boot records are archived; nothing is stopped automatically.
288
289
  const RECOVERY = 'WorkBite never starts a second one on the same state. If it is still starting, open again shortly; '
289
290
  + 'if you have confirmed no process from this state is running, move that record aside and open again.';
290
291
  const LAUNCH = /^[0-9a-f]{16}$/;
291
292
  // Distinct from every JSON value (including null): only an absent file is "missing".
292
293
  const MISSING = Symbol('missing record');
293
294
  async function savedRecord(file, what) {
294
- try { return await readJson(file); } catch (error) {
295
+ try {
296
+ const bytes = await readSmall(file, 65536);
297
+ return { record: JSON.parse(bytes.toString('utf8')), bytes };
298
+ } catch (error) {
295
299
  if (error.code === 'ENOENT') return MISSING;
296
300
  fail(`The ${what} record ${file} is unreadable; WorkBite does not replace it. ${RECOVERY}`);
297
301
  }
@@ -300,6 +304,31 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array.
300
304
  && (value.state === 'starting' || value.state === 'ready') && typeof value.launch === 'string' && LAUNCH.test(value.launch);
301
305
  const recordBytes = value => Buffer.from(JSON.stringify(value, null, 2) + '\n');
302
306
 
307
+ // Called under open.lock. Compare the exact bytes, retain a private backup before
308
+ // renaming the record, and refuse if it changed. Logs and native intent stay put.
309
+ async function archivePreviousRecord(file, saved, original, boot) {
310
+ if (!previousBoot(saved.boot, boot)) return false;
311
+ const current = await readSmall(file, 65536);
312
+ if (!current.equals(original)) fail('Startup record changed during recovery; refusing another launch.');
313
+ const backup = `${file}.previous-boot-${randomBytes(12).toString('hex')}`;
314
+ await fs.copyFile(file, backup, fsSync.constants.COPYFILE_EXCL);
315
+ if (!(await readSmall(backup, 65536)).equals(original)
316
+ || !(await readSmall(file, 65536)).equals(original)) {
317
+ fail('Startup record changed during recovery; backup retained, launch refused.');
318
+ }
319
+ // The rename retains the original record's ACL. A separate backup survives a
320
+ // crash before the new current-boot intent is persisted.
321
+ await fs.rename(file, `${backup}.record`);
322
+ if (!(await readSmall(`${backup}.record`, 65536)).equals(original)) {
323
+ fail('Startup record changed during quarantine; archived evidence retained, launch refused.');
324
+ }
325
+ return true;
326
+ }
327
+ function checkBoot(saved, recordPath) {
328
+ if (Object.hasOwn(saved, 'boot') && !validBoot(saved.boot)) {
329
+ fail(`The startup boot provenance in ${recordPath} is invalid; WorkBite does not replace it.`);
330
+ }
331
+ }
303
332
  // A v2 (execution UI) bundle reuses only processes its own startup intent marked: the owner needs
304
333
  // this bundle's execution commands, the StackBite backend its native launch opt-in (read once at
305
334
  // startup). A matching token or UID alone is not that proof. Checked from the record before any
@@ -322,30 +351,38 @@ function requireExecutionMarker(saved, execution, recordPath, what, extra = {})
322
351
  const executionFields = (execution, extra = {}) => (execution ? { execution_bundle_version: execution.bundleVersion, ...extra } : {});
323
352
  const NATIVE_LAUNCH = { native_launch: true };
324
353
 
325
- async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000, execution = null }) {
354
+ async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000, execution = null, boot = null, identityImpl = engineIdentity }) {
326
355
  const key = workspaceKey(workspace);
327
356
  const recordPath = path.join(layout.owners, `${key}.json`);
328
357
  const logFor = launch => path.join(layout.logs, `workstack-engine-${key}-${launch}.log`);
329
- const saved = await savedRecord(recordPath, 'Work Stack owner');
358
+ const snapshot = await savedRecord(recordPath, 'Work Stack owner');
359
+ const saved = snapshot === MISSING ? MISSING : snapshot.record;
360
+ const original = snapshot === MISSING ? null : snapshot.bytes;
330
361
  if (saved !== MISSING) {
331
362
  if (!isRecord(saved) || saved.schema !== OWNER_SCHEMA || typeof saved.workspace !== 'string'
332
363
  || workspaceKey(saved.workspace) !== key || saved.workspace_uid !== uid
333
- || typeof saved.token !== 'string' || !/^[0-9a-f]{64}$/.test(saved.token)) {
364
+ || typeof saved.token !== 'string' || !/^[0-9a-f]{64}$/.test(saved.token)
365
+ || (saved.state === 'ready' && (typeof saved.endpoint !== 'string' || !LOOPBACK.test(saved.endpoint)))) {
334
366
  fail(`The Work Stack owner record ${recordPath} is invalid for this workspace; WorkBite does not replace it. ${RECOVERY}`);
335
367
  }
368
+ checkBoot(saved, recordPath);
336
369
  requireExecutionMarker(saved, execution, recordPath, 'Work Stack owner');
337
370
  let endpoint = saved.state === 'ready' ? saved.endpoint : null;
338
371
  if (saved.state === 'starting') {
339
372
  const ready = await readyRecord(logFor(saved.launch));
340
373
  if (ready?.workspace_uid === uid && ready.protocol_version === 1) endpoint = ready.endpoint;
341
374
  }
342
- if (!await engineIdentity(endpoint, saved.token, uid)) {
343
- fail(`The Work Stack owner recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
375
+ if (!await identityImpl(endpoint, saved.token, uid)) {
376
+ if (!await archivePreviousRecord(recordPath, saved, original, boot))
377
+ fail(`The Work Stack owner recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
378
+ } else {
379
+ if (saved.state === 'starting' || validBoot(boot))
380
+ await writeAtomic(recordPath, recordBytes({ ...saved, ...(validBoot(boot) ? { boot } : {}), state: 'ready', endpoint }));
381
+ return { endpoint, reused: true, pid: saved.pid };
344
382
  }
345
- if (saved.state === 'starting') await writeAtomic(recordPath, recordBytes({ ...saved, state: 'ready', endpoint }));
346
- return { endpoint, reused: true, pid: saved.pid };
347
383
  }
348
- const intent = { schema: OWNER_SCHEMA, state: 'starting', workspace, workspace_uid: uid,
384
+ if (!validBoot(boot)) fail('Trusted OS boot provenance is unavailable; no owner was started.');
385
+ const intent = { boot, schema: OWNER_SCHEMA, state: 'starting', workspace, workspace_uid: uid,
349
386
  launch: randomBytes(8).toString('hex'), token: randomBytes(32).toString('hex'), ...executionFields(execution) };
350
387
  const log = logFor(intent.launch);
351
388
  await writeAtomic(recordPath, recordBytes(intent));
@@ -355,30 +392,37 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
355
392
  await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
356
393
  const ready = await waitFor(() => readyRecord(log), exited, timeout, 'Work Stack native owner', log);
357
394
  if (ready.workspace_uid !== uid || ready.protocol_version !== 1 || !LOOPBACK.test(ready.endpoint)
358
- || !await engineIdentity(ready.endpoint, intent.token, uid)) fail(`Work Stack native owner identity was not proven; see ${log}`);
395
+ || !await identityImpl(ready.endpoint, intent.token, uid)) fail(`Work Stack native owner identity was not proven; see ${log}`);
359
396
  await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready', endpoint: ready.endpoint }));
360
397
  return { endpoint: ready.endpoint, reused: false, pid: child.pid };
361
398
  }
362
399
 
363
- async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000, execution = null }) {
400
+ async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000, execution = null, boot = null, identityImpl = stackbiteIdentity }) {
364
401
  const recordPath = layout.stackbiteRecord;
365
402
  const logFor = launch => path.join(layout.logs, `stackbite-backend-${launch}.log`);
366
- const saved = await savedRecord(recordPath, 'StackBite backend');
403
+ const snapshot = await savedRecord(recordPath, 'StackBite backend');
404
+ const saved = snapshot === MISSING ? MISSING : snapshot.record;
405
+ const original = snapshot === MISSING ? null : snapshot.bytes;
367
406
  if (saved !== MISSING) {
368
407
  if (!isRecord(saved) || saved.schema !== STACKBITE_SCHEMA || saved.backend_id !== backendId
369
408
  || typeof saved.url !== 'string' || LOOPBACK.exec(saved.url)?.[1] !== String(saved.port)) {
370
409
  fail(`The StackBite backend record ${recordPath} is invalid for this state; WorkBite does not replace it. ${RECOVERY}`);
371
410
  }
411
+ checkBoot(saved, recordPath);
372
412
  requireExecutionMarker(saved, execution, recordPath, 'StackBite backend', NATIVE_LAUNCH);
373
- if (!await stackbiteIdentity(saved.url, layout.stackbite, backendId)) {
374
- fail(`The StackBite backend recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
413
+ if (!await identityImpl(saved.url, layout.stackbite, backendId)) {
414
+ if (!await archivePreviousRecord(recordPath, saved, original, boot))
415
+ fail(`The StackBite backend recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
416
+ } else {
417
+ if (saved.state === 'starting' || validBoot(boot))
418
+ await writeAtomic(recordPath, recordBytes({ ...saved, ...(validBoot(boot) ? { boot } : {}), state: 'ready' }));
419
+ return { url: saved.url, reused: true, pid: saved.pid };
375
420
  }
376
- if (saved.state === 'starting') await writeAtomic(recordPath, recordBytes({ ...saved, state: 'ready' }));
377
- return { url: saved.url, reused: true, pid: saved.pid };
378
421
  }
422
+ if (!validBoot(boot)) fail('Trusted OS boot provenance is unavailable; no backend was started.');
379
423
  const port = await freePort();
380
424
  const url = `http://127.0.0.1:${port}`;
381
- const intent = { schema: STACKBITE_SCHEMA, state: 'starting', backend_id: backendId, launch: randomBytes(8).toString('hex'), url, port,
425
+ const intent = { boot, schema: STACKBITE_SCHEMA, state: 'starting', backend_id: backendId, launch: randomBytes(8).toString('hex'), url, port,
382
426
  ...executionFields(execution, NATIVE_LAUNCH) };
383
427
  const log = logFor(intent.launch);
384
428
  const home = path.dirname(commands.stackbite[commands.stackbite.length - 1]);
@@ -395,7 +439,7 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
395
439
  ...(execution ? { STACKBITE_WORKBITE_NATIVE_LAUNCH: '1' } : {}) }, home),
396
440
  cwd: home, log, spawnImpl });
397
441
  await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
398
- await waitFor(() => stackbiteIdentity(url, layout.stackbite, backendId), exited, timeout, 'StackBite backend', log);
442
+ await waitFor(() => identityImpl(url, layout.stackbite, backendId), exited, timeout, 'StackBite backend', log);
399
443
  await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready' }));
400
444
  return { url, reused: false, pid: child.pid };
401
445
  }
@@ -555,7 +599,7 @@ function runForeground(command, args, environment, { spawnImpl = spawn, signalSo
555
599
  // commands maps each bundle executable to its argv prefix; tests substitute fixtures.
556
600
  async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
557
601
  ui = true, frontend = 'typescript', spawnImpl = spawn, execImpl = execFile, platform = process.platform,
558
- stdout = process.stdout, timeouts = {} }) {
602
+ stdout = process.stdout, timeouts = {}, bootImpl = bootIdentity }) {
559
603
  if (!FRONTENDS.includes(frontend)) fail(`--frontend must be one of: ${FRONTENDS.join(', ')}`);
560
604
  if (workspace !== undefined) workspace = checkedWorkspace(workspace);
561
605
  else if (init) fail('--init needs --workspace ABS naming the new or empty folder');
@@ -596,10 +640,11 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
596
640
  const uid = init ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
597
641
  if (remembered && uid !== remembered.uid) fail(`The workspace ${workspace} changed while opening. ${EXPLICIT}`);
598
642
  const backendId = await ensureBackendId(layout);
643
+ const boot = await bootImpl({ platform, environment: base });
599
644
  const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl,
600
- timeout: timeouts.engine, execution });
645
+ timeout: timeouts.engine, execution, boot });
601
646
  const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl,
602
- timeout: timeouts.stackbite, execution });
647
+ timeout: timeouts.stackbite, execution, boot });
603
648
  if (execution) {
604
649
  await initializeNativeRuntime({ commands, layout, url: stackbite.url, environment: base, execImpl,
605
650
  timeout: timeouts.nativeInit });
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "workbite",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "WorkBite (Work Stack + StackBite) verified native suite installer and launcher",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
7
7
  "node": ">=20"
8
8
  },
9
9
  "os": [
10
- "win32"
10
+ "win32",
11
+ "linux"
11
12
  ],
12
13
  "cpu": [
13
14
  "x64"
package/release.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "schema": "workbite.release.v1",
3
3
  "product": "WorkBite",
4
- "version": "0.1.8",
5
- "launcherVersion": "0.1.8",
4
+ "version": "0.1.10",
5
+ "launcherVersion": "0.1.10",
6
6
  "repository": "Shinick-Han/WorkBite-releases",
7
- "tag": "v0.1.8",
7
+ "tag": "v0.1.10",
8
8
  "platforms": {
9
9
  "windows-x64": {
10
- "asset": "workbite-0.1.8-windows-x64.zip",
11
- "sha256": "236a80d01b4630f8c70924bb8e56c743b7564fc59c025c5ba88a19635d38a517",
12
- "size": 97538305,
13
- "bundleSha256": "9971dc8ff0619f3f5a9cf6e4b3bec60d88da7127d8306f37fa80e5f415d72c61",
14
- "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.8/workbite-0.1.8-windows-x64.zip"
10
+ "asset": "workbite-0.1.10-windows-x64.zip",
11
+ "sha256": "c3d86b7bae3226e5f9c63d70680998ec297253baa94aa1c1f39f887a12eafc9e",
12
+ "size": 97557746,
13
+ "bundleSha256": "1e89d35d4ec3efb816ee9a536fce1de6786c800804c8e03c2b5df3acfdf9399f",
14
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.10/workbite-0.1.10-windows-x64.zip"
15
+ },
16
+ "linux-x64": {
17
+ "asset": "workbite-0.1.10-linux-x64.tar.gz",
18
+ "sha256": "9297ada2dbc30cc3fc7a14d74d85af0e1b4e49c8e7d6fb7d0bed230a51ca3e63",
19
+ "size": 81924111,
20
+ "bundleSha256": "a02994da01602e36e866411bc81d49632c9285c952c90b5cc50b252880c6123a",
21
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.10/workbite-0.1.10-linux-x64.tar.gz"
15
22
  }
16
23
  }
17
24
  }