workbite 0.1.2 → 0.1.4

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,6 +3,10 @@
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
+ Version 0.1.4 opens the unified Rust UI: select a Task, start its agent, and
7
+ use `9 Execution` to see and control its native terminal in the same window.
8
+ The existing 1–8 menus remain available. Task completion stays an explicit action.
9
+
6
10
  ```sh
7
11
  npm install -g workbite
8
12
  workbite install
@@ -19,7 +23,7 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
19
23
  - `workbite check` re-verifies the installed bundle without downloading.
20
24
  - `workbite open --workspace ABS [--init] [--no-ui]` starts or reuses the
21
25
  workspace's Work Stack owner and a StackBite backend (only processes it can
22
- prove it started), then runs the Work Stack terminal UI. Backends keep
26
+ prove it started), then runs the unified WorkBite terminal UI. Backends keep
23
27
  running after the UI exits.
24
28
  - `workbite` (or `workbite open` without `--workspace`) reopens the last
25
29
  workspace that opened successfully; an explicit `--workspace` always wins.
package/lib/cli.js CHANGED
@@ -237,7 +237,7 @@ async function main(argv = process.argv.slice(2), options = {}) {
237
237
  return { code: 0, signal: null };
238
238
  }
239
239
  return openSuite({ installed, commands: options.commands, workspace: parsed.workspace, init: parsed.init, ui: parsed.ui,
240
- stateRoot, environment, spawnImpl: options.spawnImpl, stdout, timeouts: options.timeouts });
240
+ stateRoot, environment, spawnImpl: options.spawnImpl, execImpl: options.execImpl, stdout, timeouts: options.timeouts });
241
241
  }
242
242
 
243
243
  module.exports = { main, parseArguments, HELP };
package/lib/install.js CHANGED
@@ -15,6 +15,14 @@ const EXECUTABLES = Object.freeze({
15
15
  workstack_tui: 'workstack/workstack-tui',
16
16
  stackbite: 'stackbite/stackbite',
17
17
  });
18
+ // Exact executable roster per bundle schema: v1 keeps the four legacy entries; v2 adds only the
19
+ // same-window execution UI (proprietary assembly binary workstack-native-tui). Any other schema is refused.
20
+ const BUNDLE_V1 = 'workbite.bundle.v1';
21
+ const BUNDLE_V2 = 'workbite.bundle.v2';
22
+ const ROSTERS = Object.freeze({
23
+ [BUNDLE_V1]: EXECUTABLES,
24
+ [BUNDLE_V2]: Object.freeze({ ...EXECUTABLES, workbite_tui: 'workstack/workstack-native-tui' }),
25
+ });
18
26
 
19
27
  function absoluteOverride(environment, name) {
20
28
  const value = environment[name];
@@ -75,9 +83,10 @@ function releaseSpec(manifest, platform = process.platform, arch = process.arch,
75
83
  platform, target, version: manifest.version };
76
84
  }
77
85
 
78
- function expectedExecutables(spec) {
86
+ function expectedExecutables(spec, schema = BUNDLE_V1) {
87
+ if (!Object.hasOwn(ROSTERS, schema)) fail('bundle.json identity or executable layout mismatch');
79
88
  const suffix = spec.platform === 'win32' ? '.exe' : '';
80
- return Object.fromEntries(Object.entries(EXECUTABLES).map(([name, file]) => [name, file + suffix]));
89
+ return Object.fromEntries(Object.entries(ROSTERS[schema]).map(([name, file]) => [name, file + suffix]));
81
90
  }
82
91
  function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
83
92
  function sameKeys(value, keys) {
@@ -93,8 +102,9 @@ async function validateBundle(directory, spec) {
93
102
  if (sha256(bundleBytes) !== spec.bundleSha256) fail('Pinned bundle.json SHA-256 mismatch');
94
103
  let bundle;
95
104
  try { bundle = JSON.parse(bundleBytes.toString('utf8')); } catch { fail('bundle.json is not valid JSON'); }
96
- const executables = expectedExecutables(spec);
97
- if (!plainObject(bundle) || bundle.schema !== 'workbite.bundle.v1' || bundle.product !== 'WorkBite'
105
+ if (!plainObject(bundle) || !Object.hasOwn(ROSTERS, bundle.schema)) fail('bundle.json identity or executable layout mismatch');
106
+ const executables = expectedExecutables(spec, bundle.schema);
107
+ if (bundle.product !== 'WorkBite'
98
108
  || bundle.version !== spec.version || bundle.target !== spec.target || !plainObject(bundle.components)
99
109
  || !sameKeys(bundle.executables, Object.keys(executables))
100
110
  || Object.entries(executables).some(([name, file]) => bundle.executables[name] !== file)
@@ -220,5 +230,5 @@ function stageReporter(stderr) {
220
230
  return stage => stderr.write(`WorkBite: ${messages[stage]}\n`);
221
231
  }
222
232
 
223
- module.exports = { REPOSITORY, TARGETS, EXECUTABLES, locations, platformTarget, releaseSpec, expectedExecutables,
233
+ module.exports = { REPOSITORY, TARGETS, EXECUTABLES, BUNDLE_V1, BUNDLE_V2, ROSTERS, locations, platformTarget, releaseSpec, expectedExecutables,
224
234
  validateBundle, ensureInstalled, installName, stageReporter, packageVersion };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+ // Private Windows state for the v2 execution UI: the native TUI intent directory and the
3
+ // deploy journal must be owned by the current user and grant mutating access only to that
4
+ // user, SYSTEM and Administrators (the Work Stack TUI's private-parent check), but a folder
5
+ // created under %LOCALAPPDATA% inherits whatever its parents grant (sandbox groups included).
6
+ // One bounded Windows PowerShell child replaces exactly the named directories' DACLs with a
7
+ // protected private one; owner, contents and file identities are kept. Linux keeps 0700.
8
+ const path = require('node:path');
9
+ const { execFile } = require('node:child_process');
10
+ const { fail } = require('./common');
11
+
12
+ const RESULT_SCHEMA = 'workbite.private-directories.v1';
13
+ const MAXIMUM = 8;
14
+ // Constant script, no interpolation: paths arrive only as WORKBITE_PRIVATE_DIRECTORY_<n>.
15
+ // .NET types only (no cmdlets), so a foreign PSModulePath cannot change what runs.
16
+ const SCRIPT = String.raw`
17
+ $ErrorActionPreference = 'Stop'
18
+ $ProgressPreference = 'SilentlyContinue'
19
+ try {
20
+ $count = [int]$env:WORKBITE_PRIVATE_DIRECTORY_COUNT
21
+ if ($count -lt 1 -or $count -gt ${MAXIMUM}) { throw 'invalid directory count' }
22
+ $me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
23
+ $allowed = @($me.Value, 'S-1-5-18', 'S-1-5-32-544')
24
+ $sid = [System.Security.Principal.SecurityIdentifier]
25
+ $sections = [System.Security.AccessControl.AccessControlSections]'Access, Owner'
26
+ $results = @()
27
+ for ($index = 0; $index -lt $count; $index++) {
28
+ $target = [Environment]::GetEnvironmentVariable('WORKBITE_PRIVATE_DIRECTORY_' + $index)
29
+ if (-not $target -or -not [System.IO.Path]::IsPathRooted($target)) { throw ('directory ' + $index + ' is not an absolute path') }
30
+ $info = [System.IO.DirectoryInfo]::new($target)
31
+ $info.Refresh()
32
+ if (-not $info.Exists) { throw ('directory ' + $index + ' is missing or not a directory') }
33
+ if (($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('directory ' + $index + ' is a link or reparse point') }
34
+ $current = $info.GetAccessControl($sections)
35
+ if ($current.GetOwner($sid).Value -ne $me.Value) { throw ('directory ' + $index + ' is not owned by the current Windows user') }
36
+ $rules = @($current.GetAccessRules($true, $true, $sid))
37
+ $private = $current.AreAccessRulesProtected -and $rules.Count -gt 0
38
+ $mine = $false
39
+ foreach ($rule in $rules) {
40
+ if ($rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $allowed -notcontains $rule.IdentityReference.Value) { $private = $false }
41
+ if ($rule.IdentityReference.Value -eq $me.Value -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $mine = $true }
42
+ }
43
+ if ($private -and $mine) { $results += 'kept'; continue }
44
+ $security = [System.Security.AccessControl.DirectorySecurity]::new()
45
+ $security.SetAccessRuleProtection($true, $false)
46
+ foreach ($principal in $allowed) {
47
+ $security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($sid::new($principal),
48
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
49
+ [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit',
50
+ [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow))
51
+ }
52
+ # Only the DACL section is written; the owner is left as verified above.
53
+ $info.SetAccessControl($security)
54
+ $info.Refresh()
55
+ if (($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw ('directory ' + $index + ' changed while protecting it') }
56
+ $after = $info.GetAccessControl($sections)
57
+ if ($after.GetOwner($sid).Value -ne $me.Value -or -not $after.AreAccessRulesProtected) { throw ('directory ' + $index + ' did not keep a protected private DACL') }
58
+ foreach ($rule in @($after.GetAccessRules($true, $true, $sid))) {
59
+ if ($rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $allowed -notcontains $rule.IdentityReference.Value) { throw ('directory ' + $index + ' still grants another principal access') }
60
+ }
61
+ $results += 'protected'
62
+ }
63
+ [Console]::Out.Write('{"schema":"${RESULT_SCHEMA}","results":["' + ($results -join '","') + '"]}')
64
+ exit 0
65
+ } catch {
66
+ $message = ($_.Exception.Message -replace '[\x00-\x1f\x7f]', ' ')
67
+ if ($message.Length -gt 300) { $message = $message.Substring(0, 300) }
68
+ [Console]::Error.Write($message)
69
+ exit 3
70
+ }
71
+ `;
72
+ const ENCODED = Buffer.from(SCRIPT, 'utf16le').toString('base64');
73
+
74
+ function windowsPowerShell(environment) {
75
+ const root = environment.SystemRoot || environment.SYSTEMROOT || environment.windir || 'C:\\Windows';
76
+ if (!/^[A-Za-z]:\\[^\x00-\x1f"*?<>|]*$/.test(root)) fail('Cannot locate Windows PowerShell: SystemRoot is not an absolute local path.');
77
+ return path.win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
78
+ }
79
+ const bounded = text => String(text || '').replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 300);
80
+
81
+ // Windows only: each directory must already exist (created by safeDirectory). Returns 'kept'
82
+ // or 'protected' per directory; anything else refuses and nothing that uses them is started.
83
+ // No-op elsewhere: Linux directories are created 0700 and checked by the native TUI itself.
84
+ async function protectPrivateDirectories(directories, { platform = process.platform, environment = process.env,
85
+ execImpl = execFile, timeout = 60000 } = {}) {
86
+ if (platform !== 'win32') return null;
87
+ if (!Array.isArray(directories) || !directories.length || directories.length > MAXIMUM
88
+ || directories.some(value => typeof value !== 'string' || !path.win32.isAbsolute(value) || /^[\\/]{2}|[\x00-\x1f]/.test(value))) {
89
+ fail('WorkBite private state directories must be absolute local paths.');
90
+ }
91
+ const childEnvironment = {};
92
+ for (const [key, value] of Object.entries(environment)) {
93
+ // Windows PowerShell must not load another PowerShell's modules; earlier helper values are replaced.
94
+ if (value !== undefined && key.toUpperCase() !== 'PSMODULEPATH' && !/^WORKBITE_PRIVATE_DIRECTORY_/i.test(key)) childEnvironment[key] = value;
95
+ }
96
+ childEnvironment.WORKBITE_PRIVATE_DIRECTORY_COUNT = String(directories.length);
97
+ directories.forEach((value, index) => { childEnvironment[`WORKBITE_PRIVATE_DIRECTORY_${index}`] = path.win32.resolve(value); });
98
+ const file = windowsPowerShell(environment);
99
+ const result = await new Promise(resolve => {
100
+ execImpl(file, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', ENCODED],
101
+ { env: childEnvironment, shell: false, windowsHide: true, timeout, maxBuffer: 64 * 1024, encoding: 'utf8' },
102
+ (error, stdout, stderr) => resolve({ error, stdout: stdout || '', stderr: stderr || '' }));
103
+ });
104
+ let record = null;
105
+ try { record = JSON.parse(result.stdout.trim()); } catch { record = null; }
106
+ if (result.error || record === null || typeof record !== 'object' || Array.isArray(record)
107
+ || Object.keys(record).length !== 2 || record.schema !== RESULT_SCHEMA || !Array.isArray(record.results)
108
+ || record.results.length !== directories.length || record.results.some(value => value !== 'kept' && value !== 'protected')) {
109
+ fail(`WorkBite could not make its private execution state private (${bounded(result.stderr) || 'no result'}); `
110
+ + `nothing that uses ${directories.join(', ')} was started. Each must be a real directory owned by the current `
111
+ + 'Windows user; WorkBite does not take ownership or follow links.');
112
+ }
113
+ return record.results;
114
+ }
115
+
116
+ module.exports = { protectPrivateDirectories, RESULT_SCHEMA };
package/lib/runtime.js CHANGED
@@ -12,6 +12,8 @@ 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 { BUNDLE_V2 } = require('./install');
16
+ const { protectPrivateDirectories } = require('./private-state');
15
17
 
16
18
  const BACKEND_SCHEMA = 'stackbite.workstack-backend.v1';
17
19
  const CONNECTION_SCHEMA = 'workstack.helm-connection.v1';
@@ -48,7 +50,11 @@ function stateLayout(root) {
48
50
  journal: path.join(root, 'deploy-journal'), tuiState: path.join(root, 'workstack-tui'), owners: path.join(root, 'owners'),
49
51
  logs: path.join(root, 'logs'), dock: path.join(root, 'execution-dock.json'),
50
52
  stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock'),
51
- selection: path.join(root, 'last-workspace.json') };
53
+ selection: path.join(root, 'last-workspace.json'),
54
+ // v2 execution UI only: its private intent journal and device settings, never under a workspace.
55
+ nativeTui: path.join(root, 'workstack-native-tui'),
56
+ tuiIntent: path.join(root, 'workstack-native-tui', 'intent.json'),
57
+ tuiSettings: path.join(root, 'workstack-native-tui', 'tui.json') };
52
58
  }
53
59
  async function prepareState(layout) {
54
60
  for (const directory of [layout.root, layout.stackbite, layout.workstackRuntime, layout.journal, layout.tuiState,
@@ -292,7 +298,29 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array.
292
298
  && (value.state === 'starting' || value.state === 'ready') && typeof value.launch === 'string' && LAUNCH.test(value.launch);
293
299
  const recordBytes = value => Buffer.from(JSON.stringify(value, null, 2) + '\n');
294
300
 
295
- async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000 }) {
301
+ // A v2 (execution UI) bundle reuses only processes its own startup intent marked: the owner needs
302
+ // this bundle's execution commands, the StackBite backend its native launch opt-in (read once at
303
+ // startup). A matching token or UID alone is not that proof. Checked from the record before any
304
+ // network contact; the record is kept and nothing is stopped. A v1 bundle never checks or writes it.
305
+ const HANDOVER = 'Operator handover needed: WorkBite never stops, adopts or replaces it. Close the UIs using it, '
306
+ + 'stop that process yourself (its pid is in the record), move the record aside and open again; WorkBite then '
307
+ + 'starts a new one from this bundle.';
308
+ const shown = value => (typeof value === 'string' ? JSON.stringify(value.slice(0, 40)) : value === undefined ? 'absent' : 'invalid');
309
+ function requireExecutionMarker(saved, execution, recordPath, what, extra = {}) {
310
+ if (!execution) return;
311
+ const missing = saved.execution_bundle_version !== execution.bundleVersion
312
+ || Object.entries(extra).some(([key, value]) => saved[key] !== value);
313
+ if (missing) {
314
+ fail(`The ${what} recorded in ${recordPath} was not started by the WorkBite ${execution.bundleVersion} execution bundle `
315
+ + `(execution_bundle_version ${shown(saved.execution_bundle_version)}`
316
+ + Object.keys(extra).map(key => `, ${key} ${saved[key] === undefined ? 'absent' : JSON.stringify(saved[key])}`).join('')
317
+ + `), so it cannot serve 9 Execution. ${HANDOVER}`);
318
+ }
319
+ }
320
+ const executionFields = (execution, extra = {}) => (execution ? { execution_bundle_version: execution.bundleVersion, ...extra } : {});
321
+ const NATIVE_LAUNCH = { native_launch: true };
322
+
323
+ async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000, execution = null }) {
296
324
  const key = workspaceKey(workspace);
297
325
  const recordPath = path.join(layout.owners, `${key}.json`);
298
326
  const logFor = launch => path.join(layout.logs, `workstack-engine-${key}-${launch}.log`);
@@ -303,6 +331,7 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
303
331
  || typeof saved.token !== 'string' || !/^[0-9a-f]{64}$/.test(saved.token)) {
304
332
  fail(`The Work Stack owner record ${recordPath} is invalid for this workspace; WorkBite does not replace it. ${RECOVERY}`);
305
333
  }
334
+ requireExecutionMarker(saved, execution, recordPath, 'Work Stack owner');
306
335
  let endpoint = saved.state === 'ready' ? saved.endpoint : null;
307
336
  if (saved.state === 'starting') {
308
337
  const ready = await readyRecord(logFor(saved.launch));
@@ -315,7 +344,7 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
315
344
  return { endpoint, reused: true, pid: saved.pid };
316
345
  }
317
346
  const intent = { schema: OWNER_SCHEMA, state: 'starting', workspace, workspace_uid: uid,
318
- launch: randomBytes(8).toString('hex'), token: randomBytes(32).toString('hex') };
347
+ launch: randomBytes(8).toString('hex'), token: randomBytes(32).toString('hex'), ...executionFields(execution) };
319
348
  const log = logFor(intent.launch);
320
349
  await writeAtomic(recordPath, recordBytes(intent));
321
350
  const { child, exited } = spawnDetached(commands.workstack_engine,
@@ -329,7 +358,7 @@ async function ensureEngine({ commands, workspace, uid, layout, environment, spa
329
358
  return { endpoint: ready.endpoint, reused: false, pid: child.pid };
330
359
  }
331
360
 
332
- async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000 }) {
361
+ async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000, execution = null }) {
333
362
  const recordPath = layout.stackbiteRecord;
334
363
  const logFor = launch => path.join(layout.logs, `stackbite-backend-${launch}.log`);
335
364
  const saved = await savedRecord(recordPath, 'StackBite backend');
@@ -338,6 +367,7 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
338
367
  || typeof saved.url !== 'string' || LOOPBACK.exec(saved.url)?.[1] !== String(saved.port)) {
339
368
  fail(`The StackBite backend record ${recordPath} is invalid for this state; WorkBite does not replace it. ${RECOVERY}`);
340
369
  }
370
+ requireExecutionMarker(saved, execution, recordPath, 'StackBite backend', NATIVE_LAUNCH);
341
371
  if (!await stackbiteIdentity(saved.url, layout.stackbite, backendId)) {
342
372
  fail(`The StackBite backend recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
343
373
  }
@@ -346,17 +376,21 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
346
376
  }
347
377
  const port = await freePort();
348
378
  const url = `http://127.0.0.1:${port}`;
349
- const intent = { schema: STACKBITE_SCHEMA, state: 'starting', backend_id: backendId, launch: randomBytes(8).toString('hex'), url, port };
379
+ const intent = { schema: STACKBITE_SCHEMA, state: 'starting', backend_id: backendId, launch: randomBytes(8).toString('hex'), url, port,
380
+ ...executionFields(execution, NATIVE_LAUNCH) };
350
381
  const log = logFor(intent.launch);
351
382
  const home = path.dirname(commands.stackbite[commands.stackbite.length - 1]);
352
383
  await writeAtomic(recordPath, recordBytes(intent));
353
384
  // The private Python terminal core and this backend's own loopback bridge are
354
385
  // mandatory for the room backend (taskrooms reads STACKBITE_BRIDGE_URL; the agent
355
386
  // CLI and frontend read STACKBITE_ROOM_BRIDGE_URL); caller values are overridden here only.
387
+ // A v2 bundle also starts it with the WorkBite native launch opt-in (cleanEnvironment drops any
388
+ // caller value, so a v1 backend never gets it); STACKBITE_HOME lets it resolve the bundle's native assets.
356
389
  const { child, exited } = spawnDetached(commands.stackbite, ['--room-backend', '--port', String(port)], {
357
390
  env: withPath({ ...environment, STACKBITE_HOME: home, STACKBITE_STATE_DIR: layout.stackbite,
358
391
  STACKBITE_PTY_BACKEND: 'private', STACKBITE_ROOM_BACKEND: '1', STACKBITE_TERMINAL_CORE: 'python',
359
- STACKBITE_BRIDGE_URL: url, STACKBITE_ROOM_BRIDGE_URL: url, PYINSTALLER_RESET_ENVIRONMENT: '1' }, home),
392
+ STACKBITE_BRIDGE_URL: url, STACKBITE_ROOM_BRIDGE_URL: url, PYINSTALLER_RESET_ENVIRONMENT: '1',
393
+ ...(execution ? { STACKBITE_WORKBITE_NATIVE_LAUNCH: '1' } : {}) }, home),
360
394
  cwd: home, log, spawnImpl });
361
395
  await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
362
396
  await waitFor(() => stackbiteIdentity(url, layout.stackbite, backendId), exited, timeout, 'StackBite backend', log);
@@ -364,6 +398,36 @@ async function ensureStackbite({ commands, layout, backendId, environment, spawn
364
398
  return { url, reused: false, pid: child.pid };
365
399
  }
366
400
 
401
+ // v2 only, after this backend's identity is proven: one headless `stackbite --workbite-native-init`
402
+ // selects (or, through StackBite's own durable coordinator, reuses) the native terminal daemon for
403
+ // this backend. No UI, window, Room or Seat; exactly one exit-0 selected record is accepted.
404
+ // Anything else (refused, Unknown, malformed, timed out) refuses the open: WorkBite never retries,
405
+ // starts another daemon or falls back, and a later open asks the same coordinator again.
406
+ const NATIVE_INIT_SCHEMA = 'stackbite.workbite-native-init.v1';
407
+ async function initializeNativeRuntime({ commands, layout, url, environment, execImpl = execFile, timeout = 120000 }) {
408
+ const home = path.dirname(commands.stackbite[commands.stackbite.length - 1]);
409
+ const [file, ...prefix] = commands.stackbite;
410
+ const result = await new Promise(resolve => {
411
+ execImpl(file, [...prefix, '--workbite-native-init'], {
412
+ env: withPath({ ...environment, STACKBITE_HOME: home, STACKBITE_STATE_DIR: layout.stackbite,
413
+ STACKBITE_BRIDGE_URL: url, STACKBITE_ROOM_BRIDGE_URL: url, STACKBITE_WORKBITE_NATIVE_LAUNCH: '1',
414
+ PYINSTALLER_RESET_ENVIRONMENT: '1' }, home),
415
+ cwd: home, shell: false, windowsHide: true, timeout, maxBuffer: 64 * 1024, encoding: 'utf8',
416
+ }, (error, stdout, stderr) => resolve({ error, stdout: stdout || '', stderr: stderr || '' }));
417
+ });
418
+ const text = result.stdout.trim();
419
+ let record = null;
420
+ if (text && !/[\r\n]/.test(text) && text.length <= 4096) { try { record = JSON.parse(text); } catch { record = null; } }
421
+ if (result.error || record === null || typeof record !== 'object' || Array.isArray(record) || Object.keys(record).length !== 2
422
+ || record.schema !== NATIVE_INIT_SCHEMA || record.status !== 'selected') {
423
+ const exit = result.error ? (result.error.killed ? 'timed out' : `exit ${result.error.code ?? 'unknown'}`) : 'no selected record';
424
+ fail(`StackBite native runtime initialization did not select a native runtime (${exit}: ${boundedText(result.stderr) || 'no reason'}); `
425
+ + 'the execution UI was not started. WorkBite does not retry, start another daemon or fall back; '
426
+ + 'if the outcome is unknown, open again later and StackBite looks the same selection up.');
427
+ }
428
+ return record.status;
429
+ }
430
+
367
431
  async function writeDockConfig(layout, url) {
368
432
  const bytes = Buffer.from(JSON.stringify({ schema: CONNECTION_SCHEMA, base_url: url, state_dir: layout.stackbite }, null, 2) + '\n');
369
433
  let current = null;
@@ -397,12 +461,65 @@ async function withLock(file, action, { wait = 180000, stale = 300000 } = {}) {
397
461
  }
398
462
  }
399
463
 
400
- function uiEnvironment(base, { executables, layout, endpoint, uid, backendId }) {
464
+ // WB-EXEC-UI-V1 §4.4 device opt-in for 9 Execution, in WorkBite's own private settings file.
465
+ // Written only when no execution_ui choice exists; a present choice (enabled or disabled, any
466
+ // prefix) is kept byte for byte, and anything else there is refused, never repaired.
467
+ const EXECUTION_UI_SCHEMA = 'workbite.execution-ui-settings.v1';
468
+ const EXECUTION_UI_DEFAULT = Object.freeze({ schema: EXECUTION_UI_SCHEMA, enabled: true, prefix: 'ctrl+]', last_view_execution: false });
469
+ const SETTINGS_FIX = 'WorkBite does not repair or replace it; fix it or move it aside and open again.';
470
+ function validExecutionUi(value) {
471
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 4
472
+ && value.schema === EXECUTION_UI_SCHEMA && typeof value.enabled === 'boolean' && typeof value.prefix === 'string'
473
+ && typeof value.last_view_execution === 'boolean';
474
+ }
475
+ // Same-directory temporary file published by hard link: never replaces a file another writer created.
476
+ async function publishNew(file, bytes) {
477
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(8).toString('hex')}.tmp`);
478
+ const handle = await fs.open(temporary, 'wx', 0o600);
479
+ try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
480
+ try { await fs.link(temporary, file); return true; } catch (error) {
481
+ if (error.code !== 'EEXIST') throw error;
482
+ return false;
483
+ } finally { await fs.unlink(temporary); }
484
+ }
485
+ async function ensureExecutionSettings(file, attempt = 0) {
486
+ let bytes = null;
487
+ try { bytes = await readSmall(file, 1024 * 1024); } catch (error) {
488
+ if (error.code !== 'ENOENT') fail(`The execution UI settings ${file} are unreadable. ${SETTINGS_FIX}`);
489
+ }
490
+ if (bytes === null) {
491
+ const created = await publishNew(file, Buffer.from(JSON.stringify({ execution_ui: EXECUTION_UI_DEFAULT }, null, 2) + '\n'));
492
+ if (created) return 'initialized';
493
+ if (attempt) fail(`The execution UI settings ${file} keep changing while opening. ${SETTINGS_FIX}`);
494
+ return ensureExecutionSettings(file, attempt + 1);
495
+ }
496
+ let document;
497
+ try {
498
+ const text = bytes.toString('utf8');
499
+ document = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
500
+ } catch { fail(`The execution UI settings ${file} are not valid JSON. ${SETTINGS_FIX}`); }
501
+ if (document === null || typeof document !== 'object' || Array.isArray(document)) {
502
+ fail(`The execution UI settings ${file} are not a JSON object. ${SETTINGS_FIX}`);
503
+ }
504
+ if (!Object.hasOwn(document, 'execution_ui')) {
505
+ // Other settings are kept; only the missing opt-in is added.
506
+ await writeAtomic(file, Buffer.from(JSON.stringify({ ...document, execution_ui: EXECUTION_UI_DEFAULT }, null, 2) + '\n'));
507
+ return 'initialized';
508
+ }
509
+ if (!validExecutionUi(document.execution_ui)) {
510
+ fail(`The execution UI settings ${file} hold an execution_ui value that is not the exact four-key opt-in object `
511
+ + `(schema "${EXECUTION_UI_SCHEMA}", enabled, prefix, last_view_execution). ${SETTINGS_FIX}`);
512
+ }
513
+ return document.execution_ui.enabled ? 'enabled' : 'disabled';
514
+ }
515
+
516
+ function uiEnvironment(base, { executables, layout, endpoint, uid, backendId, execution = null }) {
401
517
  const home = path.dirname(executables.stackbite);
402
518
  return withPath({ ...base, WORKSTACK_TRANSPORT: 'native', WORKSTACK_NATIVE_CLIENT: executables.workstack_client,
403
519
  WORKSTACK_ENDPOINT: endpoint, WORKSTACK_WORKSPACE_UID: uid, WORKSTACK_STATE_DIR: layout.tuiState,
404
520
  WORKSTACK_EXECUTION_DOCK_CONFIG: layout.dock, WORKSTACK_EXECUTION_DOCK_BACKEND_ID: backendId,
405
- WORKSTACK_DEPLOY_JOURNAL_DIR: layout.journal, WORKSTACK_STACKBITE_EXECUTABLE: executables.stackbite }, home);
521
+ WORKSTACK_DEPLOY_JOURNAL_DIR: layout.journal, WORKSTACK_STACKBITE_EXECUTABLE: executables.stackbite,
522
+ ...(execution ? { WORKSTACK_TUI_STATE: layout.tuiIntent, WORKSTACK_TUI_SETTINGS: layout.tuiSettings } : {}) }, home);
406
523
  }
407
524
 
408
525
  function runForeground(command, args, environment, { spawnImpl = spawn, signalSource = process } = {}) {
@@ -423,13 +540,23 @@ function runForeground(command, args, environment, { spawnImpl = spawn, signalSo
423
540
 
424
541
  // commands maps each bundle executable to its argv prefix; tests substitute fixtures.
425
542
  async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
426
- ui = true, spawnImpl = spawn, stdout = process.stdout, timeouts = {} }) {
543
+ ui = true, spawnImpl = spawn, execImpl = execFile, platform = process.platform, stdout = process.stdout, timeouts = {} }) {
427
544
  if (workspace !== undefined) workspace = checkedWorkspace(workspace);
428
545
  else if (init) fail('--init needs --workspace ABS naming the new or empty folder');
429
546
  commands = commands || Object.fromEntries(Object.entries(installed.executables).map(([name, file]) => [name, [file]]));
547
+ // A v2 bundle always runs its execution UI; a v1 bundle keeps the legacy UI. There is no fallback between them.
548
+ const execution = installed.bundle?.schema === BUNDLE_V2 ? { bundleVersion: installed.bundle.version } : null;
549
+ if (execution && !(Array.isArray(commands.workbite_tui) && commands.workbite_tui.length)) {
550
+ fail('This WorkBite bundle has no verified execution UI executable; nothing was started.');
551
+ }
430
552
  const layout = stateLayout(path.resolve(stateRoot));
431
553
  await prepareState(layout);
432
554
  const base = cleanEnvironment(environment);
555
+ if (execution) {
556
+ // The execution UI's intent parent and deploy journal, made private (Windows DACL) before any file in them is written.
557
+ await safeDirectory(layout.nativeTui);
558
+ await protectPrivateDirectories([layout.nativeTui, layout.journal], { platform, environment: base, execImpl });
559
+ }
433
560
  const engineEnvironment = { ...base, WORK_STACK_RUNTIME: layout.workstackRuntime };
434
561
  const session = await withLock(layout.lock, async () => {
435
562
  let remembered = null;
@@ -437,29 +564,40 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
437
564
  remembered = await rememberedWorkspace(layout);
438
565
  workspace = checkedWorkspace(remembered.workspace);
439
566
  }
567
+ // Settings are settled before any workspace init or backend start, so a refused file changes nothing else.
568
+ const executionUi = execution ? await ensureExecutionSettings(layout.tuiSettings) : null;
440
569
  const uid = init ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
441
570
  if (remembered && uid !== remembered.uid) fail(`The workspace ${workspace} changed while opening. ${EXPLICIT}`);
442
571
  const backendId = await ensureBackendId(layout);
443
- const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl, timeout: timeouts.engine });
444
- const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl, timeout: timeouts.stackbite });
572
+ const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl,
573
+ timeout: timeouts.engine, execution });
574
+ const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl,
575
+ timeout: timeouts.stackbite, execution });
576
+ if (execution) {
577
+ await initializeNativeRuntime({ commands, layout, url: stackbite.url, environment: base, execImpl,
578
+ timeout: timeouts.nativeInit });
579
+ }
445
580
  await writeDockConfig(layout, stackbite.url);
446
581
  // Only after the owner, StackBite and dock config are proven ready, and before the UI.
447
582
  await rememberWorkspace(layout, workspace, uid);
448
- return { uid, backendId, engine, stackbite };
583
+ return { uid, backendId, engine, stackbite, executionUi };
449
584
  });
450
585
  const summary = { schema: 'workbite.open.v1', workspace, workspace_uid: session.uid, endpoint: session.engine.endpoint,
451
586
  owner_reused: session.engine.reused, stackbite_url: session.stackbite.url, stackbite_reused: session.stackbite.reused,
452
- backend_id: session.backendId, state: layout.root };
587
+ backend_id: session.backendId, state: layout.root,
588
+ ...(execution ? { execution_ui: session.executionUi, execution_bundle_version: execution.bundleVersion } : {}) };
453
589
  if (!ui) {
454
590
  stdout.write(JSON.stringify(summary) + '\n');
455
591
  return { code: 0, signal: null, summary };
456
592
  }
457
593
  const environmentForUi = uiEnvironment(base, { executables: installed.executables, layout, endpoint: session.engine.endpoint,
458
- uid: session.uid, backendId: session.backendId });
459
- const result = await runForeground(commands.workstack_tui, [], environmentForUi, { spawnImpl });
594
+ uid: session.uid, backendId: session.backendId, execution });
595
+ // Exactly one foreground child in this terminal (inherited stdio, never detached or a new console).
596
+ const result = await runForeground(execution ? commands.workbite_tui : commands.workstack_tui, [], environmentForUi, { spawnImpl });
460
597
  return { ...result, summary };
461
598
  }
462
599
 
463
600
  module.exports = { cleanEnvironment, stateLayout, prepareState, ensureBackendId, workspaceKey, readWorkspaceUid,
464
601
  initializeWorkspace, engineIdentity, stackbiteIdentity, freePort, ensureEngine, ensureStackbite, writeDockConfig,
465
- withLock, uiEnvironment, openSuite, rememberedWorkspace, RESERVED_PORTS, SELECTION_SCHEMA };
602
+ withLock, uiEnvironment, openSuite, rememberedWorkspace, ensureExecutionSettings, initializeNativeRuntime, RESERVED_PORTS,
603
+ SELECTION_SCHEMA, EXECUTION_UI_DEFAULT, NATIVE_INIT_SCHEMA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workbite",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "WorkBite (Work Stack + StackBite) verified native suite installer and launcher",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
package/release.json CHANGED
@@ -1,24 +1,24 @@
1
1
  {
2
2
  "schema": "workbite.release.v1",
3
3
  "product": "WorkBite",
4
- "version": "0.1.0",
5
- "launcherVersion": "0.1.2",
4
+ "version": "0.1.4",
5
+ "launcherVersion": "0.1.4",
6
6
  "repository": "Shinick-Han/WorkBite-releases",
7
- "tag": "v0.1.0",
7
+ "tag": "v0.1.4",
8
8
  "platforms": {
9
9
  "windows-x64": {
10
- "asset": "workbite-0.1.0-windows-x64.zip",
11
- "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.0/workbite-0.1.0-windows-x64.zip",
12
- "sha256": "345cc42f025203da75ac17d048f6103f098a3fa98f795a03857f53788b8e2a21",
13
- "size": 92677172,
14
- "bundleSha256": "f62351c975f153ae1d0843403150a4d6079ac39ab0d5ab7afcd739c72487c34b"
10
+ "asset": "workbite-0.1.4-windows-x64.zip",
11
+ "sha256": "82150e6731bbe2cc00b08265ea5f97f12dc92e6213d32fc53ba4cd3bb1f98c50",
12
+ "size": 97936126,
13
+ "bundleSha256": "77efe214a312c14f4565075574541777a96e541ce3d0065d3e85184b0c4a2568",
14
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.4/workbite-0.1.4-windows-x64.zip"
15
15
  },
16
16
  "linux-x64": {
17
- "asset": "workbite-0.1.0-linux-x64.tar.gz",
18
- "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.0/workbite-0.1.0-linux-x64.tar.gz",
19
- "sha256": "e96e7c75bfce493ebf0ffd7009f67c44c1b312d5ebfda3467e8b47720a1aff32",
20
- "size": 77250118,
21
- "bundleSha256": "52764882937a214a7c2519ea542782abb55894d7660e0a1a6706a9e99de0392d"
17
+ "asset": "workbite-0.1.4-linux-x64.tar.gz",
18
+ "sha256": "f349f8c0eebbcf171a488bd1108d74b123cb57633ec6fc54c55c79532b47ae12",
19
+ "size": 81647804,
20
+ "bundleSha256": "aa37923f4dd74e8a12ceac09d26cea956a960ea6d34939be1bef303c79042222",
21
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.4/workbite-0.1.4-linux-x64.tar.gz"
22
22
  }
23
23
  }
24
24
  }