workbite 0.0.0-stage → 0.1.0

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
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # workbite
2
+
3
+ WorkBite installs and opens **Work Stack** (organizes the work) and
4
+ **StackBite** (gets it moving) as one native suite.
5
+
6
+ ```sh
7
+ npm install -g workbite
8
+ workbite install
9
+ workbite open --workspace /abs/path/new-folder --init
10
+ workbite open --workspace /abs/path/existing-workspace
11
+ ```
12
+
13
+ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
14
+
15
+ - `workbite install` downloads the pinned bundle for your platform from
16
+ `github.com/Shinick-Han/WorkBite-releases`, verifies its size and SHA-256 and
17
+ every file listed in its `bundle.json`, and installs it into an immutable
18
+ per-user version folder. No auto-update, no install scripts, no dependencies.
19
+ - `workbite check` re-verifies the installed bundle without downloading.
20
+ - `workbite open --workspace ABS [--init] [--no-ui]` starts or reuses the
21
+ 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
23
+ running after the UI exits.
24
+ - Each backend's startup record (private token, launch id, port) is saved in
25
+ the state folder *before* the process is started. A later `open` reuses
26
+ that same attempt once it authenticates (also after a slow start), and
27
+ otherwise refuses: an unreadable, invalid or unreachable record is never
28
+ cleared, replaced or followed by a second backend, and nothing is stopped.
29
+ If a refusal names a record and you have confirmed no WorkBite process for
30
+ that state is running, move that record aside and open again.
31
+
32
+ WorkBite keeps its own state (`%LOCALAPPDATA%\WorkBite` on Windows,
33
+ `~/.local/share/workbite` and `~/.local/state/workbite` on Linux; override
34
+ with absolute `WORKBITE_HOME` / `WORKBITE_STATE_DIR`) and never touches
35
+ separately installed Work Stack or StackBite apps, their state or ports. It
36
+ never approves provider trust or starts paid agents by itself.
37
+
38
+ This package is `UNLICENSED` (all rights reserved). Third-party licenses ship
39
+ in each bundle's `licenses/` folder.
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { main } = require('../lib/cli');
4
+ main().then(({ code, signal }) => {
5
+ if (signal && process.platform !== 'win32') {
6
+ process.kill(process.pid, signal);
7
+ } else {
8
+ process.exitCode = code === null ? 1 : code;
9
+ }
10
+ }).catch(error => {
11
+ console.error(`WorkBite: ${error.message}`);
12
+ process.exitCode = 1;
13
+ });
package/lib/archive.js ADDED
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+ // ZIP/TAR structural parsers reused from the StackBite npm bootstrapper (same
3
+ // owner, proprietary): every member is bounded and checked before the OS tar
4
+ // command sees the archive. WorkBite archives have no wrapping root directory.
5
+ const fs = require('node:fs/promises');
6
+ const { createReadStream, createWriteStream } = require('node:fs');
7
+ const { createGunzip, createInflateRaw } = require('node:zlib');
8
+ const { Transform, Writable } = require('node:stream');
9
+ const { pipeline } = require('node:stream/promises');
10
+ const { spawn } = require('node:child_process');
11
+ const path = require('node:path');
12
+ const { LIMITS, fail, memberPath } = require('./common');
13
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
14
+ const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
15
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
16
+ return value >>> 0;
17
+ });
18
+ const TOP_DIRECTORIES = Object.freeze(['workstack', 'stackbite', 'licenses']);
19
+ const BUNDLE = 'bundle.json';
20
+
21
+ async function readAt(file, offset, length) {
22
+ const bytes = Buffer.alloc(length);
23
+ const { bytesRead } = await file.read(bytes, 0, length, offset);
24
+ if (bytesRead !== length) fail('Truncated release archive');
25
+ return bytes;
26
+ }
27
+ // Top level is exactly workstack/ stackbite/ licenses/ and one bundle.json file; no links at all.
28
+ function validateEntries(entries, windows) {
29
+ if (!entries.length || entries.length > LIMITS.members) fail('Invalid archive member count');
30
+ const names = new Map();
31
+ let expanded = 0, bundle = false;
32
+ for (const entry of entries) {
33
+ entry.name = memberPath(entry.name);
34
+ if (entry.kind !== 'file' && entry.kind !== 'directory') fail('Archive links and special members are not allowed');
35
+ const top = entry.name.split('/')[0];
36
+ if (entry.name === BUNDLE) {
37
+ if (entry.kind !== 'file') fail('bundle.json must be a regular file');
38
+ bundle = true;
39
+ } else if (!TOP_DIRECTORIES.includes(top) || (entry.name === top && entry.kind !== 'directory')) {
40
+ fail('Archive member is outside the WorkBite layout');
41
+ }
42
+ const key = windows ? entry.name.toLowerCase() : entry.name;
43
+ if (names.has(key)) fail('Duplicate archive member');
44
+ names.set(key, entry);
45
+ expanded += entry.size;
46
+ if (entry.size < 0 || entry.size > LIMITS.member || expanded > LIMITS.expanded) fail('Archive expansion exceeds limit');
47
+ }
48
+ if (!bundle) fail('Archive has no bundle.json');
49
+ for (const entry of entries) {
50
+ for (let parent = path.posix.dirname(entry.name); parent !== '.'; parent = path.posix.dirname(parent)) {
51
+ const ancestor = names.get(windows ? parent.toLowerCase() : parent);
52
+ if (ancestor && ancestor.kind !== 'directory') fail('Archive file is another member parent');
53
+ }
54
+ }
55
+ return entries;
56
+ }
57
+ async function zipEntries(archive) {
58
+ const file = await fs.open(archive, 'r');
59
+ const signal = AbortSignal.timeout(LIMITS.timeout);
60
+ try {
61
+ const size = (await file.stat()).size;
62
+ const tailStart = Math.max(0, size - 65557);
63
+ const tail = await readAt(file, tailStart, size - tailStart);
64
+ const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
65
+ if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
66
+ const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
67
+ if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
68
+ || count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
69
+ || offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
70
+ const central = await readAt(file, offset, directorySize);
71
+ const entries = [], ranges = [];
72
+ let position = 0, totalExpanded = 0;
73
+ for (let index = 0; index < count; index++) {
74
+ signal.throwIfAborted();
75
+ if (position + 46 > central.length || central.readUInt32LE(position) !== 0x02014b50) fail('Invalid ZIP member');
76
+ const flags = central.readUInt16LE(position + 8), method = central.readUInt16LE(position + 10);
77
+ const compressed = central.readUInt32LE(position + 20), expanded = central.readUInt32LE(position + 24);
78
+ totalExpanded += expanded;
79
+ if (totalExpanded > LIMITS.expanded) fail('ZIP total expansion exceeds limit');
80
+ const nameSize = central.readUInt16LE(position + 28), extraSize = central.readUInt16LE(position + 30), commentSize = central.readUInt16LE(position + 32);
81
+ const localOffset = central.readUInt32LE(position + 42), mode = central.readUInt32LE(position + 38) >>> 16;
82
+ if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
83
+ || central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
84
+ || expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
85
+ const rawName = central.subarray(position + 46, position + 46 + nameSize);
86
+ if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
87
+ const name = utf8.decode(rawName);
88
+ const directory = name.endsWith('/');
89
+ if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
90
+ const directoryMode = (mode & 0o170000) === 0o040000 || !!(central.readUInt32LE(position + 38) & 16);
91
+ if (directoryMode && !directory) fail('ZIP directory identity mismatch');
92
+ const local = await readAt(file, localOffset, 30);
93
+ const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
94
+ const dataStart = localOffset + 30 + localNameSize + localExtraSize;
95
+ if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
96
+ || localNameSize !== nameSize || dataStart + compressed > offset
97
+ || !(await readAt(file, localOffset + 30, localNameSize)).equals(rawName)) fail('ZIP local/central identity mismatch');
98
+ if (!(flags & 8) && (local.readUInt32LE(14) !== central.readUInt32LE(position + 16)
99
+ || local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== expanded)) fail('ZIP local size mismatch');
100
+ if (!directory) {
101
+ // Bound the actual inflated bytes, not only the central-directory claim,
102
+ // before handing the same archive to the native extraction command.
103
+ if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
104
+ let actualSize = 0, crc = 0xffffffff;
105
+ const sink = new Writable({ write(chunk, encoding, callback) {
106
+ actualSize += chunk.length;
107
+ if (actualSize > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
108
+ for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
109
+ callback();
110
+ } });
111
+ if (compressed) {
112
+ const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
113
+ if (method === 8) await pipeline(input, createInflateRaw(), sink, { signal });
114
+ else await pipeline(input, sink, { signal });
115
+ }
116
+ if (actualSize !== expanded || ((crc ^ 0xffffffff) >>> 0) !== central.readUInt32LE(position + 16)) fail('ZIP content size/CRC mismatch');
117
+ }
118
+ // Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
119
+ // and sizes checked here, without a second interpretation of this entry.
120
+ for (const extra of [central.subarray(position + 46 + nameSize, position + 46 + nameSize + extraSize),
121
+ await readAt(file, localOffset + 30 + localNameSize, localExtraSize)]) {
122
+ for (let at = 0; at < extra.length;) {
123
+ if (at + 4 > extra.length) fail('Invalid ZIP extra field');
124
+ const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
125
+ if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
126
+ at += 4 + length;
127
+ }
128
+ }
129
+ ranges.push([localOffset, dataStart + compressed]);
130
+ entries.push({ name, kind: directory ? 'directory' : 'file', size: expanded });
131
+ position += 46 + nameSize + extraSize + commentSize;
132
+ }
133
+ if (position !== central.length) fail('Extra ZIP directory records');
134
+ ranges.sort((a, b) => a[0] - b[0]);
135
+ if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
136
+ return entries;
137
+ } finally { await file.close(); }
138
+ }
139
+ function tarNumber(bytes) {
140
+ const value = bytes.toString('ascii').replace(/\0.*$/, '').trim();
141
+ if (value && !/^[0-7]+$/.test(value)) fail('Unsupported TAR numeric field');
142
+ const result = Number.parseInt(value || '0', 8);
143
+ if (!Number.isSafeInteger(result)) fail('Invalid TAR size');
144
+ return result;
145
+ }
146
+ function tarText(bytes) { return utf8.decode(bytes.subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0))); }
147
+ function paxFields(bytes) {
148
+ const fields = {};
149
+ for (let offset = 0; offset < bytes.length;) {
150
+ const space = bytes.indexOf(32, offset);
151
+ if (space < 0 || !/^\d+$/.test(bytes.subarray(offset, space).toString())) fail('Invalid TAR PAX length');
152
+ const length = Number(bytes.subarray(offset, space).toString());
153
+ if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > bytes.length || bytes[offset + length - 1] !== 10) fail('Invalid TAR PAX record');
154
+ const record = utf8.decode(bytes.subarray(space + 1, offset + length - 1));
155
+ const equal = record.indexOf('=');
156
+ const key = record.slice(0, equal), value = record.slice(equal + 1);
157
+ if (equal < 1 || !['path', 'linkpath', 'mtime', 'atime', 'ctime'].includes(key) || Object.hasOwn(fields, key)) fail('Unsupported TAR PAX field');
158
+ fields[key] = value;
159
+ offset += length;
160
+ }
161
+ return fields;
162
+ }
163
+ async function tarEntries(archive) {
164
+ const file = await fs.open(archive, 'r');
165
+ try {
166
+ const total = (await file.stat()).size;
167
+ const entries = [];
168
+ let offset = 0, pax = null, headers = 0;
169
+ while (offset + 512 <= total) {
170
+ const header = await readAt(file, offset, 512);
171
+ offset += 512;
172
+ if (header.every(value => value === 0)) {
173
+ if (pax || offset + 512 > total) fail('Truncated TAR trailer');
174
+ for (let at = offset; at < total; at += 65536) {
175
+ if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
176
+ }
177
+ return entries;
178
+ }
179
+ if (++headers > LIMITS.members * 2) fail('Too many TAR headers');
180
+ if (header.subarray(257, 263).toString('ascii') !== 'ustar\0') fail('Unsupported TAR header format');
181
+ let checksum = 0;
182
+ for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
183
+ if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
184
+ const size = tarNumber(header.subarray(124, 136));
185
+ const kind = String.fromCharCode(header[156]);
186
+ if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
187
+ || tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
188
+ if (kind === 'x') {
189
+ if (pax) fail('Chained TAR PAX headers');
190
+ pax = paxFields(await readAt(file, offset, size));
191
+ } else {
192
+ if (!['\0', '0', '5', '2'].includes(kind) || (kind !== '0' && kind !== '\0' && size)) fail('Unsupported TAR member');
193
+ const prefix = tarText(header.subarray(345, 500));
194
+ const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
195
+ entries.push({ name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
196
+ link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) });
197
+ pax = null;
198
+ }
199
+ offset += Math.ceil(size / 512) * 512;
200
+ }
201
+ fail('TAR trailer is missing');
202
+ } finally { await file.close(); }
203
+ }
204
+ async function boundedGunzip(archive, destination) {
205
+ let size = 0;
206
+ const bounded = new Transform({ transform(chunk, encoding, callback) {
207
+ size += chunk.length;
208
+ callback(size > LIMITS.expanded ? new Error('TAR expansion exceeds limit') : null, chunk);
209
+ } });
210
+ await pipeline(createReadStream(archive), createGunzip(), bounded,
211
+ createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: AbortSignal.timeout(LIMITS.timeout) });
212
+ }
213
+ function nativeTar() {
214
+ return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
215
+ }
216
+ async function extractArchive(archive, destination, spec, { spawnImpl = spawn } = {}) {
217
+ let input = archive;
218
+ const windows = spec.asset.endsWith('.zip');
219
+ if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
220
+ const entries = windows ? await zipEntries(input) : await tarEntries(input);
221
+ validateEntries(entries, windows);
222
+ await new Promise((resolve, reject) => {
223
+ const child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
224
+ { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
225
+ let errorBytes = 0;
226
+ const timer = setTimeout(() => { child.kill(); reject(new Error('Native tar extraction timed out')); }, LIMITS.timeout);
227
+ child.stderr.on('data', chunk => { errorBytes += chunk.length; if (errorBytes > 65536) { child.kill(); } });
228
+ child.once('error', () => { clearTimeout(timer); reject(new Error('Native tar is unavailable; install the operating system tar command')); });
229
+ child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('Native tar rejected the release archive')); });
230
+ });
231
+ return entries;
232
+ }
233
+ module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, TOP_DIRECTORIES, BUNDLE };
package/lib/cli.js ADDED
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const { fail } = require('./common');
4
+ const { locations, releaseSpec, ensureInstalled, stageReporter, packageVersion } = require('./install');
5
+ const { openSuite } = require('./runtime');
6
+
7
+ const HELP = `WorkBite ${packageVersion} - Work Stack organizes the work. StackBite gets it moving.
8
+
9
+ Usage:
10
+ workbite install download, verify and install the pinned native suite
11
+ workbite check verify the installed suite (no download, no processes)
12
+ workbite open --workspace ABS [--init] [--no-ui]
13
+ start or reuse this workspace's Work Stack owner and the
14
+ StackBite backend, then run the Work Stack terminal UI
15
+ workbite --version | --help
16
+
17
+ open:
18
+ --workspace ABS absolute workspace folder
19
+ --init create a new Work Stack workspace in a new or empty folder
20
+ --no-ui start or reuse the backends, print one JSON summary and exit
21
+
22
+ The Work Stack owner and StackBite backend keep running after the UI exits, so
23
+ agents continue. WorkBite never auto-updates, installs dependencies, approves
24
+ provider trust or stops processes it cannot prove are its own.
25
+
26
+ Locations (override with absolute WORKBITE_HOME / WORKBITE_STATE_DIR):
27
+ Windows %LOCALAPPDATA%\\WorkBite\\app\\<version> %LOCALAPPDATA%\\WorkBite\\state
28
+ Linux $XDG_DATA_HOME/workbite/app/<version> $XDG_STATE_HOME/workbite
29
+ `;
30
+
31
+ function parseArguments(argv) {
32
+ if (argv.length === 1 && ['--help', '-h', 'help'].includes(argv[0])) return { command: 'help' };
33
+ if (!argv.length) return { command: 'help' };
34
+ if (argv.length === 1 && ['--version', '-v', 'version'].includes(argv[0])) return { command: 'version' };
35
+ const [command, ...rest] = argv;
36
+ if ((command === 'install' || command === 'check') && !rest.length) return { command };
37
+ if (command === 'open') {
38
+ const options = { command, init: false, ui: true };
39
+ for (let index = 0; index < rest.length; index++) {
40
+ const argument = rest[index];
41
+ if (argument === '--init' && !options.init) options.init = true;
42
+ else if (argument === '--no-ui' && options.ui) options.ui = false;
43
+ else if (argument === '--workspace' && options.workspace === undefined && rest[index + 1] !== undefined
44
+ && !rest[index + 1].startsWith('--')) options.workspace = rest[++index];
45
+ else fail(`Unknown or repeated open option: ${String(argument).slice(0, 64)}. See workbite --help.`);
46
+ }
47
+ if (options.workspace === undefined) fail('workbite open needs --workspace ABS');
48
+ if (!path.isAbsolute(options.workspace)) fail('--workspace must be an absolute path');
49
+ return options;
50
+ }
51
+ fail(`Unknown command: ${String(command).slice(0, 64)}. See workbite --help.`);
52
+ }
53
+
54
+ async function main(argv = process.argv.slice(2), options = {}) {
55
+ const stdout = options.stdout || process.stdout, stderr = options.stderr || process.stderr;
56
+ const parsed = parseArguments(argv);
57
+ if (parsed.command === 'help') { stdout.write(HELP); return { code: 0, signal: null }; }
58
+ if (parsed.command === 'version') { stdout.write(`workbite ${packageVersion}\n`); return { code: 0, signal: null }; }
59
+ const environment = options.environment || process.env;
60
+ const platform = options.platform || process.platform;
61
+ const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
62
+ const where = locations(environment, platform);
63
+ const installed = await ensureInstalled(spec, { ...options, apps: options.apps || where.apps,
64
+ checkOnly: parsed.command === 'check', progress: options.progress || stageReporter(stderr) });
65
+ if (parsed.command === 'install' || parsed.command === 'check') {
66
+ stdout.write(JSON.stringify({ schema: 'workbite.install.v1', version: spec.version, target: spec.target,
67
+ home: installed.home, state: options.stateRoot || where.state }) + '\n');
68
+ return { code: 0, signal: null };
69
+ }
70
+ return openSuite({ installed, commands: options.commands, workspace: parsed.workspace, init: parsed.init, ui: parsed.ui,
71
+ stateRoot: options.stateRoot || where.state, environment, spawnImpl: options.spawnImpl, stdout, timeouts: options.timeouts });
72
+ }
73
+
74
+ module.exports = { main, parseArguments, HELP };
package/lib/common.js ADDED
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ // Archive/path primitives adapted from the StackBite npm bootstrapper (same
3
+ // owner, proprietary); WorkBite keeps their bounds and refusal behaviour.
4
+ const fs = require('node:fs/promises');
5
+ const { createReadStream } = require('node:fs');
6
+ const path = require('node:path');
7
+ const { createHash, randomBytes } = require('node:crypto');
8
+
9
+ const LIMITS = Object.freeze({ download: 1024 * 1024 * 1024, expanded: 2048 * 1024 * 1024,
10
+ member: 256 * 1024 * 1024, metadata: 8 * 1024 * 1024, members: 40000, timeout: 300000 });
11
+ function fail(message) { throw new Error(message); }
12
+ function memberPath(name) {
13
+ if (typeof name !== 'string' || !name || name.length > 1024 || /[\\:<>"|?*\x00-\x1f\x7f]/.test(name)) fail('Unsafe archive path');
14
+ const cleaned = name.endsWith('/') ? name.slice(0, -1) : name;
15
+ const parts = cleaned.split('/');
16
+ if (parts.some(part => !part || part === '.' || part === '..' || /[ .]$/.test(part)
17
+ || /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(part))) fail('Unsafe archive path');
18
+ return cleaned;
19
+ }
20
+ function inside(root, target) {
21
+ const relative = path.relative(root, target);
22
+ return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
23
+ }
24
+ async function hashFile(filename, maximum = LIMITS.member) {
25
+ const info = await fs.stat(filename);
26
+ if (!info.isFile() || info.size > maximum) fail('Release file exceeds its size limit');
27
+ const hash = createHash('sha256');
28
+ let size = 0;
29
+ for await (const chunk of createReadStream(filename)) {
30
+ size += chunk.length;
31
+ if (size > maximum) fail('Release file exceeds its size limit');
32
+ hash.update(chunk);
33
+ }
34
+ return hash.digest('hex');
35
+ }
36
+ async function readSmall(filename, maximum = LIMITS.metadata) {
37
+ const info = await fs.lstat(filename);
38
+ if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) fail('Unsafe or oversized metadata file');
39
+ return fs.readFile(filename);
40
+ }
41
+ async function exists(filename) {
42
+ try { await fs.lstat(filename); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
43
+ }
44
+ // Creates missing directories privately; every existing component must be a real directory.
45
+ async function safeDirectory(directory) {
46
+ const parent = path.dirname(directory);
47
+ if (parent !== directory) await safeDirectory(parent);
48
+ if (!await exists(directory)) {
49
+ try { await fs.mkdir(directory, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
50
+ }
51
+ const info = await fs.lstat(directory);
52
+ if (!info.isDirectory() || info.isSymbolicLink()) fail('WorkBite directory path contains a link or non-directory');
53
+ }
54
+ // Same-directory temporary file + rename: readers see the old or the new document, never a partial one.
55
+ async function writeAtomic(filename, bytes) {
56
+ const temporary = path.join(path.dirname(filename), `.${path.basename(filename)}.${randomBytes(8).toString('hex')}.tmp`);
57
+ const handle = await fs.open(temporary, 'wx', 0o600);
58
+ try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
59
+ await fs.rename(temporary, filename);
60
+ }
61
+ async function readJson(filename, maximum = 65536) {
62
+ return JSON.parse((await readSmall(filename, maximum)).toString('utf8'));
63
+ }
64
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
65
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
66
+ const BACKEND_ID = /^[A-Za-z0-9_-]{1,128}$/;
67
+ module.exports = { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory, writeAtomic, readJson,
68
+ sha256, UUID, BACKEND_ID };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ const https = require('node:https');
3
+ const { createWriteStream } = require('node:fs');
4
+ const { Transform } = require('node:stream');
5
+ const { pipeline } = require('node:stream/promises');
6
+ const { LIMITS, fail, hashFile } = require('./common');
7
+
8
+ // GitHub release assets redirect to these hosts only; nothing else is followed.
9
+ function secureURL(value) {
10
+ const url = new URL(value);
11
+ if (url.protocol !== 'https:' || url.username || url.password || (url.port && url.port !== '443')
12
+ || !['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com'].includes(url.hostname)) {
13
+ fail('Release redirect is not an approved HTTPS origin');
14
+ }
15
+ return url;
16
+ }
17
+ async function download(spec, destination, { request = https.get, timeout = LIMITS.timeout } = {}) {
18
+ if (!Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned download size');
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), timeout);
21
+ let current = secureURL(spec.url);
22
+ try {
23
+ for (let redirects = 0; redirects <= 5; redirects++) {
24
+ const response = await new Promise((resolve, reject) => {
25
+ const req = request(current, { signal: controller.signal, headers: {
26
+ 'User-Agent': 'WorkBite-npm-installer', 'Accept-Encoding': 'identity', Accept: 'application/octet-stream'
27
+ } }, resolve);
28
+ req.once('error', () => reject(new Error('Release download failed or timed out')));
29
+ });
30
+ if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
31
+ const location = response.headers.location;
32
+ response.destroy();
33
+ if (redirects === 5 || typeof location !== 'string' || location.length > 4096) fail('Invalid release redirect');
34
+ current = secureURL(new URL(location, current).href);
35
+ continue;
36
+ }
37
+ if (response.statusCode !== 200 || (response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
38
+ response.destroy(); fail('Release download was not available');
39
+ }
40
+ const length = response.headers['content-length'];
41
+ if (length !== undefined && String(spec.size) !== length) { response.destroy(); fail('Release download size mismatch'); }
42
+ let size = 0;
43
+ const bounded = new Transform({ transform(chunk, encoding, callback) {
44
+ size += chunk.length;
45
+ callback(size > spec.size ? new Error('Release download exceeds pinned size') : null, chunk);
46
+ } });
47
+ await pipeline(response, bounded, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: controller.signal });
48
+ if (size !== spec.size || await hashFile(destination, LIMITS.download) !== spec.sha256) fail('Release SHA-256 or size mismatch');
49
+ return;
50
+ }
51
+ } finally { clearTimeout(timer); }
52
+ }
53
+ module.exports = { download, secureURL };
package/lib/install.js ADDED
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory, sha256 } = require('./common');
6
+ const { download } = require('./download');
7
+ const { extractArchive, TOP_DIRECTORIES, BUNDLE } = require('./archive');
8
+ const packageVersion = require('../package.json').version;
9
+
10
+ const REPOSITORY = 'Shinick-Han/WorkBite-releases';
11
+ const TARGETS = Object.freeze({ 'win32-x64': 'windows-x64', 'linux-x64': 'linux-x64' });
12
+ const EXECUTABLES = Object.freeze({
13
+ workstack_engine: 'workstack/workstack-native-engine',
14
+ workstack_client: 'workstack/workstack-native-client',
15
+ workstack_tui: 'workstack/workstack-tui',
16
+ stackbite: 'stackbite/stackbite',
17
+ });
18
+
19
+ function absoluteOverride(environment, name) {
20
+ const value = environment[name];
21
+ if (value === undefined || value === '') return undefined;
22
+ if (!path.isAbsolute(value) || /[\x00-\x1f]/.test(value)) fail(`${name} must be an absolute path`);
23
+ return path.resolve(value);
24
+ }
25
+ // Per-user locations owned by WorkBite only; existing Work Stack/StackBite locations are never used.
26
+ function locations(environment = process.env, platform = process.platform, home = os.homedir()) {
27
+ const windows = platform === 'win32';
28
+ const local = windows ? (environment.LOCALAPPDATA || path.join(home, 'AppData', 'Local')) : undefined;
29
+ const data = absoluteOverride(environment, 'WORKBITE_HOME') || (windows ? path.resolve(local, 'WorkBite')
30
+ : path.resolve(environment.XDG_DATA_HOME || path.join(environment.HOME || home, '.local', 'share'), 'workbite'));
31
+ const state = absoluteOverride(environment, 'WORKBITE_STATE_DIR') || (windows ? path.resolve(local, 'WorkBite', 'state')
32
+ : path.resolve(environment.XDG_STATE_HOME || path.join(environment.HOME || home, '.local', 'state'), 'workbite'));
33
+ return { apps: path.join(data, 'app'), state };
34
+ }
35
+
36
+ function platformTarget(platform = process.platform, arch = process.arch, runtime = {}) {
37
+ const key = `${platform}-${arch}`;
38
+ const target = TARGETS[key];
39
+ if (!target) fail(`WorkBite has no native release for ${key}; supported platforms are Windows x64 and Linux x64 (glibc 2.28+). Nothing was downloaded.`);
40
+ if (platform === 'linux' && (process.platform === 'linux' || Object.hasOwn(runtime, 'glibcVersionRuntime'))) {
41
+ const glibc = Object.hasOwn(runtime, 'glibcVersionRuntime')
42
+ ? runtime.glibcVersionRuntime : process.report.getReport().header.glibcVersionRuntime;
43
+ const match = typeof glibc === 'string' ? /^(\d+)\.(\d+)/.exec(glibc) : null;
44
+ if (!match) fail('WorkBite requires Linux x64 with glibc; Alpine/musl Linux is unsupported. Nothing was downloaded.');
45
+ if (Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 28)) {
46
+ fail(`WorkBite requires glibc 2.28 or newer; this system has ${glibc}. Nothing was downloaded.`);
47
+ }
48
+ }
49
+ return target;
50
+ }
51
+
52
+ function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}) {
53
+ const target = platformTarget(platform, arch, runtime);
54
+ if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || manifest.version !== packageVersion
55
+ || manifest.repository !== REPOSITORY || manifest.tag !== `v${manifest.version}`) fail('Invalid pinned WorkBite release identity');
56
+ const spec = manifest.platforms?.[target];
57
+ const asset = `workbite-${manifest.version}-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`;
58
+ if (!spec || spec.asset !== asset
59
+ || spec.url !== `https://github.com/${manifest.repository}/releases/download/${manifest.tag}/${asset}`) fail('Invalid pinned release asset');
60
+ if (spec.sha256 === null || spec.size === null || spec.bundleSha256 === null) {
61
+ fail(`This workbite ${manifest.version} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
62
+ }
63
+ if (![spec.sha256, spec.bundleSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
64
+ || !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
65
+ return { asset, url: spec.url, sha256: spec.sha256, size: spec.size, bundleSha256: spec.bundleSha256,
66
+ platform, target, version: manifest.version };
67
+ }
68
+
69
+ function expectedExecutables(spec) {
70
+ const suffix = spec.platform === 'win32' ? '.exe' : '';
71
+ return Object.fromEntries(Object.entries(EXECUTABLES).map(([name, file]) => [name, file + suffix]));
72
+ }
73
+ function plainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
74
+ function sameKeys(value, keys) {
75
+ return plainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
76
+ }
77
+
78
+ // Full payload proof: pinned bundle.json bytes, then every regular file against its map, with no extras.
79
+ async function validateBundle(directory, spec) {
80
+ const info = await fs.lstat(directory);
81
+ if (!info.isDirectory() || info.isSymbolicLink()) fail('Installed WorkBite root is not a regular directory');
82
+ const root = await fs.realpath(directory);
83
+ const bundleBytes = await readSmall(path.join(root, BUNDLE));
84
+ if (sha256(bundleBytes) !== spec.bundleSha256) fail('Pinned bundle.json SHA-256 mismatch');
85
+ let bundle;
86
+ try { bundle = JSON.parse(bundleBytes.toString('utf8')); } catch { fail('bundle.json is not valid JSON'); }
87
+ const executables = expectedExecutables(spec);
88
+ if (!plainObject(bundle) || bundle.schema !== 'workbite.bundle.v1' || bundle.product !== 'WorkBite'
89
+ || bundle.version !== spec.version || bundle.target !== spec.target || !plainObject(bundle.components)
90
+ || !sameKeys(bundle.executables, Object.keys(executables))
91
+ || Object.entries(executables).some(([name, file]) => bundle.executables[name] !== file)
92
+ || !plainObject(bundle.files)) fail('bundle.json identity or executable layout mismatch');
93
+ const expected = new Map();
94
+ for (const [name, digest] of Object.entries(bundle.files)) {
95
+ if (memberPath(name) !== name || name === BUNDLE || !TOP_DIRECTORIES.includes(name.split('/')[0])
96
+ || !name.includes('/') || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) fail('Invalid bundle file entry');
97
+ const key = spec.platform === 'win32' ? name.toLowerCase() : name;
98
+ if (expected.has(key)) fail('Duplicate bundle file entry');
99
+ expected.set(key, { name, digest });
100
+ }
101
+ if (!expected.size || expected.size > LIMITS.members) fail('Invalid bundle file map');
102
+ for (const file of Object.values(executables)) if (!Object.hasOwn(bundle.files, file)) fail('Bundle executable is not in its file map');
103
+ let total = 0, entries = 0, seen = 0;
104
+ async function visit(current) {
105
+ for (const name of await fs.readdir(current)) {
106
+ if (++entries > LIMITS.members * 2) fail('Installed WorkBite has too many entries');
107
+ const target = path.join(current, name);
108
+ const relative = path.relative(root, target).split(path.sep).join('/');
109
+ memberPath(relative);
110
+ const entry = await fs.lstat(target);
111
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail('Installed WorkBite contains a link or special file');
112
+ if (entry.isDirectory()) {
113
+ if (current === root && !TOP_DIRECTORIES.includes(name)) fail('Installed WorkBite has an unexpected top-level directory');
114
+ await visit(target); continue;
115
+ }
116
+ if (relative === BUNDLE) continue;
117
+ const record = expected.get(spec.platform === 'win32' ? relative.toLowerCase() : relative);
118
+ if (!record || record.name !== relative) fail(`Installed file is not in bundle.json: ${relative}`);
119
+ total += entry.size;
120
+ if (total > LIMITS.expanded) fail('Installed WorkBite exceeds expansion limit');
121
+ if (await hashFile(target) !== record.digest) fail(`Installed file SHA-256 mismatch: ${relative}`);
122
+ seen++;
123
+ }
124
+ }
125
+ await visit(root);
126
+ if (seen !== expected.size) fail('Installed files disagree with bundle.json');
127
+ const paths = {};
128
+ for (const [name, file] of Object.entries(executables)) {
129
+ const executable = path.join(root, ...file.split('/'));
130
+ const handle = await fs.open(executable, 'r');
131
+ try {
132
+ const magic = Buffer.alloc(4);
133
+ if ((await handle.read(magic, 0, 4, 0)).bytesRead !== 4
134
+ || !(spec.platform === 'win32' ? magic.subarray(0, 2).equals(Buffer.from('MZ')) : magic.equals(Buffer.from([0x7f, 69, 76, 70])))) {
135
+ fail(`Invalid native executable identity: ${file}`);
136
+ }
137
+ } finally { await handle.close(); }
138
+ if (spec.platform === 'linux' && process.platform !== 'win32' && !((await fs.stat(executable)).mode & 0o111)) {
139
+ fail(`Native executable has no execute permission: ${file}`);
140
+ }
141
+ paths[name] = executable;
142
+ }
143
+ return { home: root, executables: paths, bundle };
144
+ }
145
+
146
+ function installName(spec) { return `${spec.version}-${spec.target}-${spec.sha256.slice(0, 16)}`; }
147
+
148
+ // Windows cannot start bundle executables (or load their files) past MAX_PATH.
149
+ const WINDOWS_MAX_PATH = 259;
150
+ function checkPathBudget(target, bundle, spec) {
151
+ if (spec.platform !== 'win32') return;
152
+ const longest = Object.keys(bundle.files).reduce((best, name) => (name.length > best.length ? name : best), '');
153
+ if (path.join(target, longest).length > WINDOWS_MAX_PATH) {
154
+ fail(`WorkBite app folder path is too long for Windows (${path.join(target, longest).length} > ${WINDOWS_MAX_PATH} characters); set WORKBITE_HOME to a shorter absolute folder.`);
155
+ }
156
+ }
157
+
158
+ // A version directory is published once by rename and never rewritten; reuse revalidates it fully.
159
+ async function ensureInstalled(spec, { apps, downloadImpl = download, extractImpl = extractArchive,
160
+ checkOnly = false, progress = () => {} } = {}) {
161
+ apps = path.resolve(apps);
162
+ const target = path.join(apps, installName(spec));
163
+ if (await exists(target)) {
164
+ progress('validate');
165
+ const installed = await validateBundle(target, spec);
166
+ checkPathBudget(target, installed.bundle, spec);
167
+ progress('ready');
168
+ return installed;
169
+ }
170
+ if (checkOnly) fail('WorkBite is not installed for this package version; run workbite install.');
171
+ await safeDirectory(apps);
172
+ const staging = await fs.mkdtemp(path.join(apps, '.staging-'));
173
+ try {
174
+ const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'payload');
175
+ await fs.mkdir(extraction, { mode: 0o700 });
176
+ progress('download');
177
+ await downloadImpl(spec, archive);
178
+ progress('archive');
179
+ if ((await fs.stat(archive)).size !== spec.size || await hashFile(archive, LIMITS.download) !== spec.sha256) fail('Pinned archive SHA-256 or size mismatch');
180
+ progress('extract');
181
+ await extractImpl(archive, extraction, spec);
182
+ progress('validate');
183
+ checkPathBudget(target, (await validateBundle(extraction, spec)).bundle, spec);
184
+ progress('publish');
185
+ try { await fs.rename(extraction, target); }
186
+ catch (error) {
187
+ // A concurrent installer may have published the same immutable version first.
188
+ if (!await exists(target)) throw error;
189
+ }
190
+ progress('validate-published');
191
+ const installed = await validateBundle(target, spec);
192
+ progress('ready');
193
+ return installed;
194
+ } finally {
195
+ // Only this invocation's mkdtemp staging directory is ever removed.
196
+ if (!inside(apps, staging) || path.dirname(staging) !== apps) fail('Unsafe staging cleanup path');
197
+ await fs.rm(staging, { recursive: true, force: true });
198
+ }
199
+ }
200
+
201
+ function stageReporter(stderr) {
202
+ const messages = {
203
+ download: 'Downloading pinned WorkBite release...',
204
+ archive: 'Verifying archive...',
205
+ extract: 'Extracting...',
206
+ validate: 'Validating installed files...',
207
+ publish: 'Publishing version directory...',
208
+ 'validate-published': 'Validating published version...',
209
+ ready: 'Verified WorkBite release is ready.',
210
+ };
211
+ return stage => stderr.write(`WorkBite: ${messages[stage]}\n`);
212
+ }
213
+
214
+ module.exports = { REPOSITORY, TARGETS, EXECUTABLES, locations, platformTarget, releaseSpec, expectedExecutables,
215
+ validateBundle, ensureInstalled, installName, stageReporter, packageVersion };
package/lib/runtime.js ADDED
@@ -0,0 +1,402 @@
1
+ 'use strict';
2
+ // WorkBite suite supervisor: starts or reuses the verified Work Stack native
3
+ // owner and the StackBite room backend in WorkBite's own state, then runs the
4
+ // Work Stack terminal UI in the foreground. Backends outlive the UI so agents
5
+ // keep running; nothing here stops, adopts or kills a process it cannot
6
+ // authenticate as its own.
7
+ const fs = require('node:fs/promises');
8
+ const fsSync = require('node:fs');
9
+ const http = require('node:http');
10
+ const net = require('node:net');
11
+ const path = require('node:path');
12
+ const { spawn, execFile } = require('node:child_process');
13
+ const { randomBytes, randomUUID } = require('node:crypto');
14
+ const { fail, exists, safeDirectory, writeAtomic, readJson, readSmall, sha256, UUID, BACKEND_ID } = require('./common');
15
+
16
+ const BACKEND_SCHEMA = 'stackbite.workstack-backend.v1';
17
+ const CONNECTION_SCHEMA = 'workstack.helm-connection.v1';
18
+ const OWNER_SCHEMA = 'workbite.owner.v2';
19
+ const STACKBITE_SCHEMA = 'workbite.stackbite-backend.v2';
20
+ const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
21
+ // Ports of the existing official StackBite and its previews are never chosen.
22
+ const RESERVED_PORTS = new Set([8089, 8090, 8091, 54036, 54039, 54236, 54239]);
23
+ // Display preferences a child may keep (as Work Stack's viewer environment does);
24
+ // every other STACKBITE_/HELM_/OFFICE_ variable is caller Room/Seat routing or auth.
25
+ const PREFERENCES = new Set(['STACKBITE_CHROME_PROFILE', 'STACKBITE_CLIPBOARD_OSC52', 'STACKBITE_FRONTEND',
26
+ 'STACKBITE_SCROLLBACK_LINES', 'STACKBITE_TERMINAL_CORE', 'STACKBITE_UPDATE_CHECK']);
27
+
28
+ function cleanEnvironment(source = process.env) {
29
+ const result = {};
30
+ for (const [key, value] of Object.entries(source)) {
31
+ const upper = key.toUpperCase();
32
+ if (!key || value === undefined) continue;
33
+ if (/^(STACKBITE_|HELM_|OFFICE_)/.test(upper) && !PREFERENCES.has(upper)) continue;
34
+ if (/^(WORKSTACK_|WORK_STACK_|WORKBITE_)/.test(upper) || upper === 'PYINSTALLER_RESET_ENVIRONMENT') continue;
35
+ result[key] = value;
36
+ }
37
+ return result;
38
+ }
39
+ function withPath(environment, directory) {
40
+ const key = Object.keys(environment).find(name => name.toUpperCase() === 'PATH') || 'PATH';
41
+ const current = environment[key];
42
+ return { ...environment, [key]: current ? directory + path.delimiter + current : directory };
43
+ }
44
+
45
+ function stateLayout(root) {
46
+ return { root, stackbite: path.join(root, 'stackbite'), workstackRuntime: path.join(root, 'workstack-runtime'),
47
+ journal: path.join(root, 'deploy-journal'), tuiState: path.join(root, 'workstack-tui'), owners: path.join(root, 'owners'),
48
+ logs: path.join(root, 'logs'), dock: path.join(root, 'execution-dock.json'),
49
+ stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock') };
50
+ }
51
+ async function prepareState(layout) {
52
+ for (const directory of [layout.root, layout.stackbite, layout.workstackRuntime, layout.journal, layout.tuiState,
53
+ layout.owners, layout.logs]) await safeDirectory(directory);
54
+ }
55
+
56
+ // The operator pin StackBite reads from <state>/workstack-backend.json; generated once, never replaced.
57
+ async function ensureBackendId(layout) {
58
+ const file = path.join(layout.stackbite, 'workstack-backend.json');
59
+ if (!await exists(file)) {
60
+ const backendId = `workbite-${randomBytes(12).toString('hex')}`;
61
+ const bytes = Buffer.from(JSON.stringify({ schema: BACKEND_SCHEMA, backend_id: backendId }) + '\n');
62
+ try {
63
+ const handle = await fs.open(file, 'wx', 0o600);
64
+ try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
65
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
66
+ }
67
+ let document;
68
+ try { document = await readJson(file, 4096); } catch { fail('WorkBite StackBite backend identity file is unreadable; inspect it rather than replacing it.'); }
69
+ if (document === null || typeof document !== 'object' || Array.isArray(document) || Object.keys(document).length !== 2
70
+ || document.schema !== BACKEND_SCHEMA || typeof document.backend_id !== 'string' || !BACKEND_ID.test(document.backend_id)) {
71
+ fail('WorkBite StackBite backend identity file is invalid; inspect it rather than replacing it.');
72
+ }
73
+ return document.backend_id;
74
+ }
75
+
76
+ function workspaceKey(workspace, platform = process.platform) {
77
+ return sha256(Buffer.from(platform === 'win32' ? workspace.toLowerCase() : workspace)).slice(0, 24);
78
+ }
79
+ function checkedWorkspace(value) {
80
+ if (typeof value !== 'string' || !path.isAbsolute(value) || /[\x00-\x1f]/.test(value)
81
+ || (process.platform === 'win32' && /^[\\/](?![\\/])/.test(value))) fail('--workspace must be an absolute path');
82
+ return path.resolve(value);
83
+ }
84
+ // The genuine Work Stack descriptor: workspace.json "id". Nothing is fabricated or repaired.
85
+ async function readWorkspaceUid(workspace) {
86
+ const descriptor = path.join(workspace, 'workspace.json');
87
+ if (!await exists(descriptor)) fail('This folder is not a Work Stack workspace (no workspace.json). Use --init only for a new or empty folder.');
88
+ let document;
89
+ try { document = await readJson(descriptor, 1024 * 1024); } catch { fail('workspace.json is unreadable or not JSON; WorkBite does not repair it.'); }
90
+ const uid = document?.id;
91
+ if (typeof uid !== 'string' || !UUID.test(uid)) fail('workspace.json has no canonical workspace UUID id.');
92
+ return uid;
93
+ }
94
+
95
+ function boundedText(text) {
96
+ const line = String(text).split(/\r?\n/).map(value => value.trim()).filter(Boolean).pop() || '';
97
+ return line.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 300);
98
+ }
99
+ function runOnce(file, args, environment, timeout = 120000, execImpl = execFile) {
100
+ return new Promise(resolve => {
101
+ execImpl(file, args, { env: environment, shell: false, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8' },
102
+ (error, stdout, stderr) => resolve({ error, stdout: stdout || '', stderr: stderr || '' }));
103
+ });
104
+ }
105
+ async function initializeWorkspace(commands, workspace, environment) {
106
+ if (await exists(path.join(workspace, 'workspace.json'))) fail('This folder is already a Work Stack workspace; open it without --init.');
107
+ if (!await exists(workspace)) {
108
+ try { await fs.mkdir(workspace, { mode: 0o700 }); } catch { fail('Cannot create the workspace folder; its parent folder must exist.'); }
109
+ }
110
+ const uid = randomUUID();
111
+ const [file, ...prefix] = commands.workstack_engine;
112
+ const result = await runOnce(file, [...prefix, 'init', '--workspace', workspace, '--workspace-uid', uid], environment);
113
+ let record;
114
+ try { record = JSON.parse(result.stdout.trim()); } catch { record = null; }
115
+ if (result.error || record?.state !== 'initialized' || record.workspace_uid !== uid) {
116
+ fail(`Work Stack init refused this folder: ${boundedText(result.stderr) || 'no ready record'}`);
117
+ }
118
+ return uid;
119
+ }
120
+
121
+ function request(url, { method = 'GET', headers = {}, body, timeout = 5000 } = {}) {
122
+ const match = LOOPBACK.exec(url.origin);
123
+ if (!match) return Promise.reject(new Error('not a loopback origin'));
124
+ return new Promise((resolve, reject) => {
125
+ const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
126
+ const req = http.request(url, { method, agent: false, timeout,
127
+ headers: { ...headers, ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}) } },
128
+ response => {
129
+ const chunks = [];
130
+ let size = 0;
131
+ response.on('data', chunk => { size += chunk.length; if (size > 1024 * 1024) req.destroy(); else chunks.push(chunk); });
132
+ response.on('end', () => {
133
+ let parsed = null;
134
+ try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { parsed = null; }
135
+ resolve({ status: response.statusCode, body: parsed });
136
+ });
137
+ response.on('error', reject);
138
+ });
139
+ req.on('timeout', () => req.destroy(new Error('request timed out')));
140
+ req.on('error', reject);
141
+ req.end(payload);
142
+ });
143
+ }
144
+
145
+ // Owner identity is proven only by the private lifecycle token this WorkBite generated.
146
+ async function engineIdentity(endpoint, token, workspaceUid) {
147
+ if (typeof endpoint !== 'string' || !LOOPBACK.test(endpoint) || typeof token !== 'string' || !token) return false;
148
+ try {
149
+ const answer = await request(new URL('/api/v1/native-runtime/identity', endpoint), { headers: { 'X-WorkStack-Native-Token': token } });
150
+ return answer.status === 200 && answer.body?.data?.workspace_uid === workspaceUid && answer.body.data.protocol_version === 1;
151
+ } catch { return false; }
152
+ }
153
+ async function stackbiteToken(stateDir) {
154
+ for (const name of ['.stackbite_secret_token', '.office_secret_token']) {
155
+ try {
156
+ const token = (await readSmall(path.join(stateDir, name), 4096)).toString('utf8').trim();
157
+ if (token && /^[\x20-\x7e]+$/.test(token)) return token;
158
+ } catch (error) { if (error.code !== 'ENOENT') return null; }
159
+ }
160
+ return null;
161
+ }
162
+ async function stackbiteIdentity(url, stateDir, backendId) {
163
+ if (typeof url !== 'string' || !LOOPBACK.test(url)) return false;
164
+ const token = await stackbiteToken(stateDir);
165
+ if (!token) return false;
166
+ try {
167
+ const answer = await request(new URL('/api/backend/status', url), { headers: { 'X-Office-Token': token } });
168
+ return answer.status === 200 && answer.body?.status === 'ok' && answer.body.backend_id === backendId;
169
+ } catch { return false; }
170
+ }
171
+
172
+ async function freePort(excluded = RESERVED_PORTS) {
173
+ for (let attempt = 0; attempt < 20; attempt++) {
174
+ const port = await new Promise((resolve, reject) => {
175
+ const server = net.createServer();
176
+ server.once('error', reject);
177
+ server.listen(0, '127.0.0.1', () => { const { port: chosen } = server.address(); server.close(() => resolve(chosen)); });
178
+ });
179
+ if (!excluded.has(port)) return port;
180
+ }
181
+ fail('No free loopback port is available');
182
+ }
183
+
184
+ function spawnDetached(command, args, { env, cwd, log, spawnImpl = spawn }) {
185
+ const output = fsSync.openSync(log, 'a', 0o600);
186
+ try {
187
+ const [file, ...prefix] = command;
188
+ const child = spawnImpl(file, [...prefix, ...args], { env, cwd, detached: true, windowsHide: true, shell: false,
189
+ stdio: ['ignore', output, output] });
190
+ const exited = new Promise(resolve => {
191
+ child.once('exit', code => resolve({ code }));
192
+ child.once('error', () => resolve({ code: null }));
193
+ });
194
+ child.unref();
195
+ return { child, exited };
196
+ } finally { fsSync.closeSync(output); }
197
+ }
198
+ async function waitFor(probe, exited, timeout, what, log) {
199
+ let ended = false;
200
+ exited.then(() => { ended = true; });
201
+ const deadline = Date.now() + timeout;
202
+ while (Date.now() < deadline) {
203
+ const value = await probe();
204
+ if (value) return value;
205
+ if (ended) fail(`${what} exited during startup; see ${log}`);
206
+ await new Promise(resolve => setTimeout(resolve, 150));
207
+ }
208
+ fail(`${what} did not become ready in time; it was left running. See ${log}`);
209
+ }
210
+ async function readyRecord(log) {
211
+ let text;
212
+ try { text = (await readSmall(log, 1024 * 1024)).toString('utf8'); } catch { return null; }
213
+ for (const line of text.split(/\r?\n/)) {
214
+ try {
215
+ const value = JSON.parse(line);
216
+ if (value?.state === 'ready') return value;
217
+ } catch { /* not the ready line */ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ // Startup intent (private token, launch id, port) is saved before any child is
223
+ // spawned, so an attempt that timed out or crashed is never followed by a second
224
+ // manager on the same state: a later open either authenticates that same attempt
225
+ // or refuses. Records are never cleared and nothing is stopped automatically.
226
+ const RECOVERY = 'WorkBite never starts a second one on the same state. If it is still starting, open again shortly; '
227
+ + 'if you have confirmed no process from this state is running, move that record aside and open again.';
228
+ const LAUNCH = /^[0-9a-f]{16}$/;
229
+ // Distinct from every JSON value (including null): only an absent file is "missing".
230
+ const MISSING = Symbol('missing record');
231
+ async function savedRecord(file, what) {
232
+ try { return await readJson(file); } catch (error) {
233
+ if (error.code === 'ENOENT') return MISSING;
234
+ fail(`The ${what} record ${file} is unreadable; WorkBite does not replace it. ${RECOVERY}`);
235
+ }
236
+ }
237
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value)
238
+ && (value.state === 'starting' || value.state === 'ready') && typeof value.launch === 'string' && LAUNCH.test(value.launch);
239
+ const recordBytes = value => Buffer.from(JSON.stringify(value, null, 2) + '\n');
240
+
241
+ async function ensureEngine({ commands, workspace, uid, layout, environment, spawnImpl, timeout = 60000 }) {
242
+ const key = workspaceKey(workspace);
243
+ const recordPath = path.join(layout.owners, `${key}.json`);
244
+ const logFor = launch => path.join(layout.logs, `workstack-engine-${key}-${launch}.log`);
245
+ const saved = await savedRecord(recordPath, 'Work Stack owner');
246
+ if (saved !== MISSING) {
247
+ if (!isRecord(saved) || saved.schema !== OWNER_SCHEMA || typeof saved.workspace !== 'string'
248
+ || workspaceKey(saved.workspace) !== key || saved.workspace_uid !== uid
249
+ || typeof saved.token !== 'string' || !/^[0-9a-f]{64}$/.test(saved.token)) {
250
+ fail(`The Work Stack owner record ${recordPath} is invalid for this workspace; WorkBite does not replace it. ${RECOVERY}`);
251
+ }
252
+ let endpoint = saved.state === 'ready' ? saved.endpoint : null;
253
+ if (saved.state === 'starting') {
254
+ const ready = await readyRecord(logFor(saved.launch));
255
+ if (ready?.workspace_uid === uid && ready.protocol_version === 1) endpoint = ready.endpoint;
256
+ }
257
+ if (!await engineIdentity(endpoint, saved.token, uid)) {
258
+ fail(`The Work Stack owner recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
259
+ }
260
+ if (saved.state === 'starting') await writeAtomic(recordPath, recordBytes({ ...saved, state: 'ready', endpoint }));
261
+ return { endpoint, reused: true, pid: saved.pid };
262
+ }
263
+ const intent = { schema: OWNER_SCHEMA, state: 'starting', workspace, workspace_uid: uid,
264
+ launch: randomBytes(8).toString('hex'), token: randomBytes(32).toString('hex') };
265
+ const log = logFor(intent.launch);
266
+ await writeAtomic(recordPath, recordBytes(intent));
267
+ const { child, exited } = spawnDetached(commands.workstack_engine,
268
+ ['serve', '--workspace', workspace, '--workspace-uid', uid, '--host', '127.0.0.1', '--port', '0', '--native-runtime-control'],
269
+ { env: { ...environment, WORKSTACK_NATIVE_RUNTIME_TOKEN: intent.token }, cwd: layout.root, log, spawnImpl });
270
+ await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
271
+ const ready = await waitFor(() => readyRecord(log), exited, timeout, 'Work Stack native owner', log);
272
+ if (ready.workspace_uid !== uid || ready.protocol_version !== 1 || !LOOPBACK.test(ready.endpoint)
273
+ || !await engineIdentity(ready.endpoint, intent.token, uid)) fail(`Work Stack native owner identity was not proven; see ${log}`);
274
+ await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready', endpoint: ready.endpoint }));
275
+ return { endpoint: ready.endpoint, reused: false, pid: child.pid };
276
+ }
277
+
278
+ async function ensureStackbite({ commands, layout, backendId, environment, spawnImpl, timeout = 120000 }) {
279
+ const recordPath = layout.stackbiteRecord;
280
+ const logFor = launch => path.join(layout.logs, `stackbite-backend-${launch}.log`);
281
+ const saved = await savedRecord(recordPath, 'StackBite backend');
282
+ if (saved !== MISSING) {
283
+ if (!isRecord(saved) || saved.schema !== STACKBITE_SCHEMA || saved.backend_id !== backendId
284
+ || typeof saved.url !== 'string' || LOOPBACK.exec(saved.url)?.[1] !== String(saved.port)) {
285
+ fail(`The StackBite backend record ${recordPath} is invalid for this state; WorkBite does not replace it. ${RECOVERY}`);
286
+ }
287
+ if (!await stackbiteIdentity(saved.url, layout.stackbite, backendId)) {
288
+ fail(`The StackBite backend recorded in ${recordPath} could not be authenticated (still starting, or stopped). ${RECOVERY}`);
289
+ }
290
+ if (saved.state === 'starting') await writeAtomic(recordPath, recordBytes({ ...saved, state: 'ready' }));
291
+ return { url: saved.url, reused: true, pid: saved.pid };
292
+ }
293
+ const port = await freePort();
294
+ const url = `http://127.0.0.1:${port}`;
295
+ const intent = { schema: STACKBITE_SCHEMA, state: 'starting', backend_id: backendId, launch: randomBytes(8).toString('hex'), url, port };
296
+ const log = logFor(intent.launch);
297
+ const home = path.dirname(commands.stackbite[commands.stackbite.length - 1]);
298
+ await writeAtomic(recordPath, recordBytes(intent));
299
+ // The private Python terminal core and this backend's own loopback bridge are
300
+ // mandatory for the room backend (taskrooms reads STACKBITE_BRIDGE_URL; the agent
301
+ // CLI and frontend read STACKBITE_ROOM_BRIDGE_URL); caller values are overridden here only.
302
+ const { child, exited } = spawnDetached(commands.stackbite, ['--room-backend', '--port', String(port)], {
303
+ env: withPath({ ...environment, STACKBITE_HOME: home, STACKBITE_STATE_DIR: layout.stackbite,
304
+ STACKBITE_PTY_BACKEND: 'private', STACKBITE_ROOM_BACKEND: '1', STACKBITE_TERMINAL_CORE: 'python',
305
+ STACKBITE_BRIDGE_URL: url, STACKBITE_ROOM_BRIDGE_URL: url, PYINSTALLER_RESET_ENVIRONMENT: '1' }, home),
306
+ cwd: home, log, spawnImpl });
307
+ await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid }));
308
+ await waitFor(() => stackbiteIdentity(url, layout.stackbite, backendId), exited, timeout, 'StackBite backend', log);
309
+ await writeAtomic(recordPath, recordBytes({ ...intent, pid: child.pid, state: 'ready' }));
310
+ return { url, reused: false, pid: child.pid };
311
+ }
312
+
313
+ async function writeDockConfig(layout, url) {
314
+ const bytes = Buffer.from(JSON.stringify({ schema: CONNECTION_SCHEMA, base_url: url, state_dir: layout.stackbite }, null, 2) + '\n');
315
+ let current = null;
316
+ try { current = await readSmall(layout.dock, 65536); } catch { current = null; }
317
+ if (!current || !current.equals(bytes)) await writeAtomic(layout.dock, bytes);
318
+ }
319
+
320
+ // One opener at a time per state; a lock older than the startup bound is set aside, never deleted.
321
+ async function withLock(file, action, { wait = 180000, stale = 300000 } = {}) {
322
+ const deadline = Date.now() + wait;
323
+ let handle;
324
+ for (;;) {
325
+ try { handle = await fs.open(file, 'wx', 0o600); break; } catch (error) {
326
+ if (error.code !== 'EEXIST') throw error;
327
+ try {
328
+ if (Date.now() - (await fs.stat(file)).mtimeMs > stale) {
329
+ await fs.rename(file, `${file}.stale-${randomBytes(6).toString('hex')}`);
330
+ continue;
331
+ }
332
+ } catch (inner) { if (inner.code !== 'ENOENT') throw inner; continue; }
333
+ if (Date.now() > deadline) fail('Another WorkBite open is still starting this state; try again shortly.');
334
+ await new Promise(resolve => setTimeout(resolve, 200));
335
+ }
336
+ }
337
+ try {
338
+ await handle.writeFile(`${process.pid}\n`);
339
+ return await action();
340
+ } finally {
341
+ await handle.close();
342
+ await fs.unlink(file).catch(() => {});
343
+ }
344
+ }
345
+
346
+ function uiEnvironment(base, { executables, layout, endpoint, uid, backendId }) {
347
+ const home = path.dirname(executables.stackbite);
348
+ return withPath({ ...base, WORKSTACK_TRANSPORT: 'native', WORKSTACK_NATIVE_CLIENT: executables.workstack_client,
349
+ WORKSTACK_ENDPOINT: endpoint, WORKSTACK_WORKSPACE_UID: uid, WORKSTACK_STATE_DIR: layout.tuiState,
350
+ WORKSTACK_EXECUTION_DOCK_CONFIG: layout.dock, WORKSTACK_EXECUTION_DOCK_BACKEND_ID: backendId,
351
+ WORKSTACK_DEPLOY_JOURNAL_DIR: layout.journal, WORKSTACK_STACKBITE_EXECUTABLE: executables.stackbite }, home);
352
+ }
353
+
354
+ function runForeground(command, args, environment, { spawnImpl = spawn, signalSource = process } = {}) {
355
+ return new Promise((resolve, reject) => {
356
+ const [file, ...prefix] = command;
357
+ const child = spawnImpl(file, [...prefix, ...args], { env: environment, stdio: 'inherit', shell: false });
358
+ const handlers = new Map();
359
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
360
+ const handler = () => child.kill(signal);
361
+ handlers.set(signal, handler);
362
+ signalSource.on(signal, handler);
363
+ }
364
+ const cleanup = () => { for (const [signal, handler] of handlers) signalSource.removeListener(signal, handler); };
365
+ child.once('error', () => { cleanup(); reject(new Error('Cannot start the verified Work Stack terminal UI')); });
366
+ child.once('exit', (code, signal) => { cleanup(); resolve({ code, signal }); });
367
+ });
368
+ }
369
+
370
+ // commands maps each bundle executable to its argv prefix; tests substitute fixtures.
371
+ async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
372
+ ui = true, spawnImpl = spawn, stdout = process.stdout, timeouts = {} }) {
373
+ workspace = checkedWorkspace(workspace);
374
+ commands = commands || Object.fromEntries(Object.entries(installed.executables).map(([name, file]) => [name, [file]]));
375
+ const layout = stateLayout(path.resolve(stateRoot));
376
+ await prepareState(layout);
377
+ const base = cleanEnvironment(environment);
378
+ const engineEnvironment = { ...base, WORK_STACK_RUNTIME: layout.workstackRuntime };
379
+ const session = await withLock(layout.lock, async () => {
380
+ const uid = init ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
381
+ const backendId = await ensureBackendId(layout);
382
+ const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl, timeout: timeouts.engine });
383
+ const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl, timeout: timeouts.stackbite });
384
+ await writeDockConfig(layout, stackbite.url);
385
+ return { uid, backendId, engine, stackbite };
386
+ });
387
+ const summary = { schema: 'workbite.open.v1', workspace, workspace_uid: session.uid, endpoint: session.engine.endpoint,
388
+ owner_reused: session.engine.reused, stackbite_url: session.stackbite.url, stackbite_reused: session.stackbite.reused,
389
+ backend_id: session.backendId, state: layout.root };
390
+ if (!ui) {
391
+ stdout.write(JSON.stringify(summary) + '\n');
392
+ return { code: 0, signal: null, summary };
393
+ }
394
+ const environmentForUi = uiEnvironment(base, { executables: installed.executables, layout, endpoint: session.engine.endpoint,
395
+ uid: session.uid, backendId: session.backendId });
396
+ const result = await runForeground(commands.workstack_tui, [], environmentForUi, { spawnImpl });
397
+ return { ...result, summary };
398
+ }
399
+
400
+ module.exports = { cleanEnvironment, stateLayout, prepareState, ensureBackendId, workspaceKey, readWorkspaceUid,
401
+ initializeWorkspace, engineIdentity, stackbiteIdentity, freePort, ensureEngine, ensureStackbite, writeDockConfig,
402
+ withLock, uiEnvironment, openSuite, RESERVED_PORTS };
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
- {
2
- "name": "workbite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "workbite",
3
+ "version": "0.1.0",
4
+ "description": "WorkBite (Work Stack + StackBite) verified native suite installer and launcher",
5
+ "license": "UNLICENSED",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "os": [
10
+ "win32",
11
+ "linux"
12
+ ],
13
+ "cpu": [
14
+ "x64"
15
+ ],
16
+ "bin": {
17
+ "workbite": "bin/workbite.js"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "lib/",
22
+ "release.json",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/*.test.js"
27
+ },
28
+ "homepage": "https://github.com/Shinick-Han/WorkBite-releases"
29
+ }
package/release.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema": "workbite.release.v1",
3
+ "product": "WorkBite",
4
+ "version": "0.1.0",
5
+ "repository": "Shinick-Han/WorkBite-releases",
6
+ "tag": "v0.1.0",
7
+ "platforms": {
8
+ "windows-x64": {
9
+ "asset": "workbite-0.1.0-windows-x64.zip",
10
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.0/workbite-0.1.0-windows-x64.zip",
11
+ "sha256": "345cc42f025203da75ac17d048f6103f098a3fa98f795a03857f53788b8e2a21",
12
+ "size": 92677172,
13
+ "bundleSha256": "f62351c975f153ae1d0843403150a4d6079ac39ab0d5ab7afcd739c72487c34b"
14
+ },
15
+ "linux-x64": {
16
+ "asset": "workbite-0.1.0-linux-x64.tar.gz",
17
+ "url": "https://github.com/Shinick-Han/WorkBite-releases/releases/download/v0.1.0/workbite-0.1.0-linux-x64.tar.gz",
18
+ "sha256": "e96e7c75bfce493ebf0ffd7009f67c44c1b312d5ebfda3467e8b47720a1aff32",
19
+ "size": 77250118,
20
+ "bundleSha256": "52764882937a214a7c2519ea542782abb55894d7660e0a1a6706a9e99de0392d"
21
+ }
22
+ }
23
+ }