workbite 0.1.22 → 0.1.24

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/lib/common.js CHANGED
@@ -1,68 +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 };
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 };
@@ -1,238 +1,238 @@
1
- 'use strict';
2
- // Read-only snapshots, never lifecycle authority. Do not authenticate endpoints,
3
- // read token files, acquire locks, install binaries or replay pending intents here.
4
- const fs = require('node:fs/promises');
5
- const { constants } = require('node:fs');
6
- const path = require('node:path');
7
- const { locations, releaseSpec, installName } = require('./install');
8
- const { stateLayout } = require('./runtime');
9
- const { bootIdentity, validBoot, previousBoot } = require('./boot-provenance');
10
- const { UUID } = require('./common');
11
-
12
- const MAX_BYTES = 65536, MAX_OWNERS = 32, MAX_ENTRIES = 128;
13
- const LAUNCH = /^[0-9a-f]{16}$/;
14
- const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
15
- const pidValue = value => Number.isSafeInteger(value) && value > 0 && value <= 2147483647;
16
- const NO_DELETE = 'Do not auto-delete, move or replace locks, owner/backend records, native intent or flock files. '
17
- + 'Unverified ownership requires manual inspection on the recorded host; keep the original evidence.';
18
-
19
- // Reject links in ancestors as well as the leaf; never follow record-provided paths.
20
- // This is a snapshot, not a pinned filesystem authority: concurrent replacement
21
- // and NFS visibility can still make evidence incomplete.
22
- async function checkedStat(file) {
23
- const parents = [];
24
- for (let parent = path.dirname(file); ; parent = path.dirname(parent)) {
25
- if (parents.length >= 128) throw new Error('unsafe');
26
- parents.push(parent);
27
- if (parent === path.dirname(parent)) break;
28
- }
29
- for (const parent of parents.reverse()) {
30
- const info = await fs.lstat(parent);
31
- if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('unsafe');
32
- }
33
- const info = await fs.lstat(file);
34
- if (info.isSymbolicLink()) throw new Error('unsafe');
35
- return info;
36
- }
37
- async function bytes(file, maximum = MAX_BYTES) {
38
- const info = await checkedStat(file);
39
- if (!info.isFile() || info.size > maximum) throw new Error('unsafe');
40
- const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
41
- try {
42
- const opened = await handle.stat();
43
- if (!opened.isFile() || opened.size > maximum || opened.ino !== info.ino || opened.dev !== info.dev) throw new Error('unsafe');
44
- const buffer = Buffer.alloc(maximum + 1);
45
- let size = 0;
46
- while (size < buffer.length) {
47
- const result = await handle.read(buffer, size, buffer.length - size, null);
48
- if (!result.bytesRead) break;
49
- size += result.bytesRead;
50
- }
51
- if (size > maximum) throw new Error('unsafe');
52
- return buffer.subarray(0, size);
53
- } finally { await handle.close(); }
54
- }
55
- async function metadata(file, directory = false) {
56
- try {
57
- const info = await checkedStat(file);
58
- return { path: file, status: (directory ? info.isDirectory() : info.isFile()) ? 'present' : 'unsafe' };
59
- } catch (error) { return { path: file, status: error.code === 'ENOENT' ? 'absent' : 'unreadable-or-unsafe' }; }
60
- }
61
- async function readRecord(file, legacy = false) {
62
- try {
63
- const text = new TextDecoder('utf-8', { fatal: true }).decode(await bytes(file));
64
- if (legacy && /^[1-9][0-9]{0,9}\s*$/.test(text) && pidValue(Number(text))) {
65
- return { path: file, status: 'legacy-pid', value: { pid: Number(text) } };
66
- }
67
- const value = JSON.parse(text);
68
- if (!object(value)) return { path: file, status: 'malformed' };
69
- return { path: file, status: 'parsed', value };
70
- } catch (error) {
71
- return { path: file, status: error.code === 'ENOENT' ? 'absent'
72
- : error instanceof SyntaxError || error instanceof TypeError ? 'malformed' : 'unreadable-or-unsafe' };
73
- }
74
- }
75
- function provenance(saved, current) {
76
- if (!validBoot(saved) || !validBoot(current)) return { host: 'unknown', boot: 'unknown' };
77
- if (saved.host !== current.host) return { host: 'foreign', boot: 'not-comparable' };
78
- if (saved.source !== current.source) return { host: 'local', boot: 'unknown' };
79
- return { host: 'local', boot: saved.id === current.id ? 'current' : previousBoot(saved, current) ? 'previous' : 'unknown' };
80
- }
81
- function nativeBoot(value) {
82
- if (!object(value) || Object.keys(value).length !== 3 || !Object.hasOwn(value, 'boot')) return null;
83
- if (value.source === 'linux-boot-id') return { source: value.source, host: value.host, id: value.boot };
84
- if (value.source === 'windows-cim-boot-time' && typeof value.host === 'string'
85
- && /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.host)) {
86
- return { source: 'windows-wmi-boot-time', host: value.host.replace(/-/g, ''), id: value.boot };
87
- }
88
- return null;
89
- }
90
- function probePid(pid) {
91
- try { process.kill(pid, 0); return 'exists'; } catch (error) {
92
- return error.code === 'ESRCH' ? 'missing' : error.code === 'EPERM' ? 'exists-permission-denied' : 'unknown';
93
- }
94
- }
95
- function summarize(record, kind, current, pidProbe) {
96
- const { value, ...result } = record;
97
- result.kind = kind;
98
- if (!value) return result;
99
- // Only closed enums and validated integers leave the record. Never emit free
100
- // text errors, endpoints, descriptors, tokens, bindings or intent payloads.
101
- const knownSchema = kind === 'owner' ? value.schema === 'workbite.owner.v2'
102
- : kind === 'backend' ? value.schema === 'workbite.stackbite-backend.v2'
103
- : kind === 'native-daemon' ? value.schema === 'stackbite-rust-daemon-startup-v1'
104
- : kind === 'native-ui' ? value.schema === 'workstack.tui-intent.v1'
105
- : kind === 'selection' ? value.schema === 'workbite.workspace-selection.v1'
106
- : kind === 'backend-identity' ? value.schema === 'stackbite.workstack-backend.v1' : false;
107
- result.format = record.status === 'legacy-pid' ? 'legacy-pid' : knownSchema ? 'recognized-schema' : 'unknown-schema';
108
- const stage = kind === 'native-daemon' ? value.phase : value.state;
109
- result.stage = ['starting', 'ready', 'attempted', 'descriptor', 'selected', 'unknown'].includes(stage) ? stage : 'unknown';
110
- result.provenance = provenance(kind === 'native-daemon' ? nativeBoot(value.owner_boot) : value.boot, current);
111
- result.pid = pidValue(value.pid) ? value.pid : null;
112
- result.pidEvidence = 'not-probed';
113
- if (result.pid && result.provenance.host === 'local' && result.provenance.boot === 'current') {
114
- try {
115
- const evidence = pidProbe(result.pid);
116
- result.pidEvidence = ['exists', 'missing', 'exists-permission-denied'].includes(evidence) ? evidence : 'unknown';
117
- } catch { result.pidEvidence = 'unknown'; }
118
- }
119
- result.identity = 'unverified';
120
- result.cleanupSafe = false;
121
- if (['owner', 'backend'].includes(kind) && typeof value.launch === 'string' && LAUNCH.test(value.launch)) {
122
- result.logHint = path.join(path.dirname(kind === 'owner' ? path.dirname(record.path) : record.path),
123
- 'logs', `${kind === 'owner' ? 'workstack-engine-' + path.basename(record.path, '.json') : 'stackbite-backend'}-${value.launch}.log`);
124
- }
125
- if (kind === 'native-daemon') result.savedLogHints = typeof value.log_file === 'string' || typeof value.stderr_log_file === 'string';
126
- if (kind === 'native-ui') result.pendingIntent = knownSchema && UUID.test(value.intent || '') ? 'retained-do-not-replay' : 'unknown-do-not-replay';
127
- return result;
128
- }
129
- async function ownerRecords(directory) {
130
- const records = [];
131
- let handle, entries = 0, limited = false, status = 'present';
132
- try {
133
- if (!(await checkedStat(directory)).isDirectory()) throw new Error('unsafe');
134
- handle = await fs.opendir(directory, { bufferSize: 16 });
135
- while (entries < MAX_ENTRIES && records.length < MAX_OWNERS) {
136
- const entry = await handle.read();
137
- if (!entry) break;
138
- entries++;
139
- if (/^[0-9a-f]{24}\.json$/.test(entry.name)) records.push(await readRecord(path.join(directory, entry.name)));
140
- }
141
- limited = entries === MAX_ENTRIES || records.length === MAX_OWNERS;
142
- } catch (error) { status = error.code === 'ENOENT' ? 'absent' : 'unreadable-or-unsafe'; }
143
- finally { if (handle) await handle.close(); }
144
- return { path: directory, status, entriesExamined: entries, limited, records };
145
- }
146
-
147
- async function inspectStartup(options = {}) {
148
- const environment = options.environment || process.env, platform = options.platform || process.platform;
149
- const where = locations(environment, platform);
150
- const state = path.resolve(options.stateRoot || where.state), apps = path.resolve(options.apps || where.apps);
151
- const layout = stateLayout(state);
152
- const paths = { workbiteHome: path.dirname(where.apps), apps, state, logs: layout.logs,
153
- lock: layout.lock, owners: layout.owners, backend: layout.stackbiteRecord, selection: layout.selection,
154
- nativeIntent: path.join(layout.stackbite, '.stackbite-rust-daemon', 'intent.json'), uiIntent: layout.tuiIntent };
155
- // Linux bootIdentity is file-only. Windows' implementation invokes WMI through
156
- // PowerShell, so leave boot provenance unknown here to keep diagnostics process-free.
157
- let boot = null;
158
- try {
159
- if (options.bootImpl) boot = await options.bootImpl();
160
- else if (platform === 'linux') boot = await bootIdentity({ platform, environment,
161
- readImpl: async file => (await bytes(file, 4096)).toString('utf8') });
162
- } catch { /* unavailable provenance stays unknown */ }
163
- const pidProbe = options.pidProbe || probePid;
164
- const owners = await ownerRecords(layout.owners);
165
- const records = [
166
- summarize(await readRecord(layout.lock, true), 'open-lock', boot, pidProbe),
167
- ...owners.records.map(record => summarize(record, 'owner', boot, pidProbe)),
168
- summarize(await readRecord(layout.stackbiteRecord), 'backend', boot, pidProbe),
169
- summarize(await readRecord(path.join(layout.stackbite, 'workstack-backend.json')), 'backend-identity', boot, pidProbe),
170
- summarize(await readRecord(layout.selection), 'selection', boot, pidProbe),
171
- summarize(await readRecord(paths.nativeIntent), 'native-daemon', boot, pidProbe),
172
- summarize(await readRecord(layout.tuiIntent), 'native-ui', boot, pidProbe),
173
- ];
174
- let installation = { status: 'pinned-target-unavailable', integrity: 'not-checked' };
175
- try {
176
- const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
177
- paths.installation = path.join(apps, installName(spec));
178
- installation = { ...await metadata(paths.installation, true), integrity: 'not-checked',
179
- bundle: await metadata(path.join(paths.installation, 'bundle.json')) };
180
- } catch { /* Diagnostics must still work when release pins/platform are unavailable. */ }
181
- const stateInfo = await metadata(state, true);
182
- const present = records.filter(record => record.status !== 'absent');
183
- const uncertain = present.some(record => record.status !== 'parsed' || record.format !== 'recognized-schema'
184
- || (!['selection', 'backend-identity', 'native-ui'].includes(record.kind)
185
- && (record.stage === 'unknown' || record.provenance?.host !== 'local')));
186
- const stage = stateInfo.status === 'absent' ? 'state-absent'
187
- : stateInfo.status !== 'present' || uncertain || owners.limited || owners.status === 'unreadable-or-unsafe' ? 'inspection-required'
188
- : !present.length ? 'no-startup-records' : 'records-present-unverified';
189
- return { schema: 'workbite.startup-diagnostics.v1', readOnly: true, stage, paths,
190
- workbiteHomeSource: environment.WORKBITE_HOME ? 'WORKBITE_HOME' : 'platform-default', state: stateInfo,
191
- installation, bootIdentity: validBoot(boot) ? 'available' : 'unavailable',
192
- owners: { path: owners.path, status: owners.status, entriesExamined: owners.entriesExamined, limited: owners.limited }, records,
193
- limits: { bytesPerRecord: MAX_BYTES, ownerRecords: MAX_OWNERS, directoryEntries: MAX_ENTRIES },
194
- limitations: [
195
- 'Snapshot only: records may change concurrently and NFS visibility may lag. Schema recognition is not authentication or full validation.',
196
- 'No endpoints are probed. No credential files or log contents are read. Saved ready/selected stages do not prove current health.',
197
- 'Foreign or unknown host PIDs are never tested locally or declared dead. A local missing PID is an observation, not cleanup authority.',
198
- 'Local PID existence alone never proves process identity, ownership or safe cleanup. Prior-boot evidence does not authorize deletion.',
199
- 'Windows boot provenance remains unknown because diagnostics never launches the WMI helper process.',
200
- 'Install presence is not integrity verification; use workbite check for the existing integrity check.',
201
- ],
202
- guidance: [NO_DELETE, 'workbite open --no-ui starts or reuses backends only; it does not prove that a terminal UI can open.',
203
- 'doctor/status inspect saved startup records passively. inspection-required means provenance needs review; it is not an authenticated agent admission failure.',
204
- 'workbite agent status ready means authenticated workspace command admission at that observation, not provider readiness, turn completion or Task completion.',
205
- 'To check workspace admission, explicitly run workbite agent status in the intended workspace. To inspect an assigned execution seat, refresh stackbite-room context; compare its current generation and runtime evidence.',
206
- 'Configured provider labels, installed binaries and saved ready records do not verify the running provider or its lifecycle hooks. A Taskless standalone Room is normal; an unassigned seat must wait for assignment.',
207
- 'Inspect the selected record paths and private logs locally with credentials redacted; do not publish raw files.',
208
- 'For native daemon logs, consult log_file and stderr_log_file in the retained native intent locally; untrusted saved paths are not followed here.',
209
- 'For foreign-host records, inspect the recorded host. If ownership remains unknown, retain the state and seek operator review.',
210
- 'A pending native intent may have applied already. Reauthenticate the original owner and resolve its operation status; do not replay it.'],
211
- };
212
- }
213
- async function runDiagnostics(parsed, options = {}) {
214
- const report = await inspectStartup(options);
215
- report.command = parsed.command;
216
- if (parsed.command === 'recover') report.recovery = { dryRun: true, mutated: false, repaired: false,
217
- plan: ['Retain the original state and logs; review the diagnostic evidence and limitations.',
218
- 'Establish the recorded host, boot and authenticated process ownership through operator review before considering any repair.',
219
- 'Resolve retained native intent with the original owner; do not replay unknown outcomes.',
220
- 'No automatic recovery action is supported by this command.'], guidance: NO_DELETE };
221
- const stdout = options.stdout || process.stdout;
222
- if (parsed.json) stdout.write(JSON.stringify(report) + '\n');
223
- else {
224
- const lines = [`WorkBite ${parsed.command}: ${report.stage} (read-only)`,
225
- `WORKBITE_HOME: ${report.paths.workbiteHome} (${report.workbiteHomeSource})`,
226
- ...Object.entries(report.paths).filter(([name]) => name !== 'workbiteHome').map(([name, file]) => `${name}: ${file}`),
227
- `installation: ${report.installation.status}; integrity not checked (workbite check)`,
228
- `owners: ${report.owners.status}; ${report.owners.entriesExamined} entries examined; limited=${report.owners.limited}`,
229
- ...report.records.map(record => `${record.kind}: ${record.status}; stage=${record.stage || 'unknown'}; `
230
- + `host=${record.provenance?.host || 'unknown'}; boot=${record.provenance?.boot || 'unknown'}; `
231
- + `pid=${record.pid ?? 'unknown'} (${record.pidEvidence || 'not-probed'}); ${record.path}`
232
- + (record.logHint ? `; log hint=${record.logHint}` : '')),
233
- ...report.limitations, ...report.guidance, ...(report.recovery?.plan || [])];
234
- stdout.write(lines.join('\n') + '\n');
235
- }
236
- return { code: 0, signal: null };
237
- }
238
- module.exports = { inspectStartup, runDiagnostics };
1
+ 'use strict';
2
+ // Read-only snapshots, never lifecycle authority. Do not authenticate endpoints,
3
+ // read token files, acquire locks, install binaries or replay pending intents here.
4
+ const fs = require('node:fs/promises');
5
+ const { constants } = require('node:fs');
6
+ const path = require('node:path');
7
+ const { locations, releaseSpec, installName } = require('./install');
8
+ const { stateLayout } = require('./runtime');
9
+ const { bootIdentity, validBoot, previousBoot } = require('./boot-provenance');
10
+ const { UUID } = require('./common');
11
+
12
+ const MAX_BYTES = 65536, MAX_OWNERS = 32, MAX_ENTRIES = 128;
13
+ const LAUNCH = /^[0-9a-f]{16}$/;
14
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ const pidValue = value => Number.isSafeInteger(value) && value > 0 && value <= 2147483647;
16
+ const NO_DELETE = 'Do not auto-delete, move or replace locks, owner/backend records, native intent or flock files. '
17
+ + 'Unverified ownership requires manual inspection on the recorded host; keep the original evidence.';
18
+
19
+ // Reject links in ancestors as well as the leaf; never follow record-provided paths.
20
+ // This is a snapshot, not a pinned filesystem authority: concurrent replacement
21
+ // and NFS visibility can still make evidence incomplete.
22
+ async function checkedStat(file) {
23
+ const parents = [];
24
+ for (let parent = path.dirname(file); ; parent = path.dirname(parent)) {
25
+ if (parents.length >= 128) throw new Error('unsafe');
26
+ parents.push(parent);
27
+ if (parent === path.dirname(parent)) break;
28
+ }
29
+ for (const parent of parents.reverse()) {
30
+ const info = await fs.lstat(parent);
31
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('unsafe');
32
+ }
33
+ const info = await fs.lstat(file);
34
+ if (info.isSymbolicLink()) throw new Error('unsafe');
35
+ return info;
36
+ }
37
+ async function bytes(file, maximum = MAX_BYTES) {
38
+ const info = await checkedStat(file);
39
+ if (!info.isFile() || info.size > maximum) throw new Error('unsafe');
40
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
41
+ try {
42
+ const opened = await handle.stat();
43
+ if (!opened.isFile() || opened.size > maximum || opened.ino !== info.ino || opened.dev !== info.dev) throw new Error('unsafe');
44
+ const buffer = Buffer.alloc(maximum + 1);
45
+ let size = 0;
46
+ while (size < buffer.length) {
47
+ const result = await handle.read(buffer, size, buffer.length - size, null);
48
+ if (!result.bytesRead) break;
49
+ size += result.bytesRead;
50
+ }
51
+ if (size > maximum) throw new Error('unsafe');
52
+ return buffer.subarray(0, size);
53
+ } finally { await handle.close(); }
54
+ }
55
+ async function metadata(file, directory = false) {
56
+ try {
57
+ const info = await checkedStat(file);
58
+ return { path: file, status: (directory ? info.isDirectory() : info.isFile()) ? 'present' : 'unsafe' };
59
+ } catch (error) { return { path: file, status: error.code === 'ENOENT' ? 'absent' : 'unreadable-or-unsafe' }; }
60
+ }
61
+ async function readRecord(file, legacy = false) {
62
+ try {
63
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(await bytes(file));
64
+ if (legacy && /^[1-9][0-9]{0,9}\s*$/.test(text) && pidValue(Number(text))) {
65
+ return { path: file, status: 'legacy-pid', value: { pid: Number(text) } };
66
+ }
67
+ const value = JSON.parse(text);
68
+ if (!object(value)) return { path: file, status: 'malformed' };
69
+ return { path: file, status: 'parsed', value };
70
+ } catch (error) {
71
+ return { path: file, status: error.code === 'ENOENT' ? 'absent'
72
+ : error instanceof SyntaxError || error instanceof TypeError ? 'malformed' : 'unreadable-or-unsafe' };
73
+ }
74
+ }
75
+ function provenance(saved, current) {
76
+ if (!validBoot(saved) || !validBoot(current)) return { host: 'unknown', boot: 'unknown' };
77
+ if (saved.host !== current.host) return { host: 'foreign', boot: 'not-comparable' };
78
+ if (saved.source !== current.source) return { host: 'local', boot: 'unknown' };
79
+ return { host: 'local', boot: saved.id === current.id ? 'current' : previousBoot(saved, current) ? 'previous' : 'unknown' };
80
+ }
81
+ function nativeBoot(value) {
82
+ if (!object(value) || Object.keys(value).length !== 3 || !Object.hasOwn(value, 'boot')) return null;
83
+ if (value.source === 'linux-boot-id') return { source: value.source, host: value.host, id: value.boot };
84
+ if (value.source === 'windows-cim-boot-time' && typeof value.host === 'string'
85
+ && /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.host)) {
86
+ return { source: 'windows-wmi-boot-time', host: value.host.replace(/-/g, ''), id: value.boot };
87
+ }
88
+ return null;
89
+ }
90
+ function probePid(pid) {
91
+ try { process.kill(pid, 0); return 'exists'; } catch (error) {
92
+ return error.code === 'ESRCH' ? 'missing' : error.code === 'EPERM' ? 'exists-permission-denied' : 'unknown';
93
+ }
94
+ }
95
+ function summarize(record, kind, current, pidProbe) {
96
+ const { value, ...result } = record;
97
+ result.kind = kind;
98
+ if (!value) return result;
99
+ // Only closed enums and validated integers leave the record. Never emit free
100
+ // text errors, endpoints, descriptors, tokens, bindings or intent payloads.
101
+ const knownSchema = kind === 'owner' ? value.schema === 'workbite.owner.v2'
102
+ : kind === 'backend' ? value.schema === 'workbite.stackbite-backend.v2'
103
+ : kind === 'native-daemon' ? value.schema === 'stackbite-rust-daemon-startup-v1'
104
+ : kind === 'native-ui' ? value.schema === 'workstack.tui-intent.v1'
105
+ : kind === 'selection' ? value.schema === 'workbite.workspace-selection.v1'
106
+ : kind === 'backend-identity' ? value.schema === 'stackbite.workstack-backend.v1' : false;
107
+ result.format = record.status === 'legacy-pid' ? 'legacy-pid' : knownSchema ? 'recognized-schema' : 'unknown-schema';
108
+ const stage = kind === 'native-daemon' ? value.phase : value.state;
109
+ result.stage = ['starting', 'ready', 'attempted', 'descriptor', 'selected', 'unknown'].includes(stage) ? stage : 'unknown';
110
+ result.provenance = provenance(kind === 'native-daemon' ? nativeBoot(value.owner_boot) : value.boot, current);
111
+ result.pid = pidValue(value.pid) ? value.pid : null;
112
+ result.pidEvidence = 'not-probed';
113
+ if (result.pid && result.provenance.host === 'local' && result.provenance.boot === 'current') {
114
+ try {
115
+ const evidence = pidProbe(result.pid);
116
+ result.pidEvidence = ['exists', 'missing', 'exists-permission-denied'].includes(evidence) ? evidence : 'unknown';
117
+ } catch { result.pidEvidence = 'unknown'; }
118
+ }
119
+ result.identity = 'unverified';
120
+ result.cleanupSafe = false;
121
+ if (['owner', 'backend'].includes(kind) && typeof value.launch === 'string' && LAUNCH.test(value.launch)) {
122
+ result.logHint = path.join(path.dirname(kind === 'owner' ? path.dirname(record.path) : record.path),
123
+ 'logs', `${kind === 'owner' ? 'workstack-engine-' + path.basename(record.path, '.json') : 'stackbite-backend'}-${value.launch}.log`);
124
+ }
125
+ if (kind === 'native-daemon') result.savedLogHints = typeof value.log_file === 'string' || typeof value.stderr_log_file === 'string';
126
+ if (kind === 'native-ui') result.pendingIntent = knownSchema && UUID.test(value.intent || '') ? 'retained-do-not-replay' : 'unknown-do-not-replay';
127
+ return result;
128
+ }
129
+ async function ownerRecords(directory) {
130
+ const records = [];
131
+ let handle, entries = 0, limited = false, status = 'present';
132
+ try {
133
+ if (!(await checkedStat(directory)).isDirectory()) throw new Error('unsafe');
134
+ handle = await fs.opendir(directory, { bufferSize: 16 });
135
+ while (entries < MAX_ENTRIES && records.length < MAX_OWNERS) {
136
+ const entry = await handle.read();
137
+ if (!entry) break;
138
+ entries++;
139
+ if (/^[0-9a-f]{24}\.json$/.test(entry.name)) records.push(await readRecord(path.join(directory, entry.name)));
140
+ }
141
+ limited = entries === MAX_ENTRIES || records.length === MAX_OWNERS;
142
+ } catch (error) { status = error.code === 'ENOENT' ? 'absent' : 'unreadable-or-unsafe'; }
143
+ finally { if (handle) await handle.close(); }
144
+ return { path: directory, status, entriesExamined: entries, limited, records };
145
+ }
146
+
147
+ async function inspectStartup(options = {}) {
148
+ const environment = options.environment || process.env, platform = options.platform || process.platform;
149
+ const where = locations(environment, platform);
150
+ const state = path.resolve(options.stateRoot || where.state), apps = path.resolve(options.apps || where.apps);
151
+ const layout = stateLayout(state);
152
+ const paths = { workbiteHome: path.dirname(where.apps), apps, state, logs: layout.logs,
153
+ lock: layout.lock, owners: layout.owners, backend: layout.stackbiteRecord, selection: layout.selection,
154
+ nativeIntent: path.join(layout.stackbite, '.stackbite-rust-daemon', 'intent.json'), uiIntent: layout.tuiIntent };
155
+ // Linux bootIdentity is file-only. Windows' implementation invokes WMI through
156
+ // PowerShell, so leave boot provenance unknown here to keep diagnostics process-free.
157
+ let boot = null;
158
+ try {
159
+ if (options.bootImpl) boot = await options.bootImpl();
160
+ else if (platform === 'linux') boot = await bootIdentity({ platform, environment,
161
+ readImpl: async file => (await bytes(file, 4096)).toString('utf8') });
162
+ } catch { /* unavailable provenance stays unknown */ }
163
+ const pidProbe = options.pidProbe || probePid;
164
+ const owners = await ownerRecords(layout.owners);
165
+ const records = [
166
+ summarize(await readRecord(layout.lock, true), 'open-lock', boot, pidProbe),
167
+ ...owners.records.map(record => summarize(record, 'owner', boot, pidProbe)),
168
+ summarize(await readRecord(layout.stackbiteRecord), 'backend', boot, pidProbe),
169
+ summarize(await readRecord(path.join(layout.stackbite, 'workstack-backend.json')), 'backend-identity', boot, pidProbe),
170
+ summarize(await readRecord(layout.selection), 'selection', boot, pidProbe),
171
+ summarize(await readRecord(paths.nativeIntent), 'native-daemon', boot, pidProbe),
172
+ summarize(await readRecord(layout.tuiIntent), 'native-ui', boot, pidProbe),
173
+ ];
174
+ let installation = { status: 'pinned-target-unavailable', integrity: 'not-checked' };
175
+ try {
176
+ const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
177
+ paths.installation = path.join(apps, installName(spec));
178
+ installation = { ...await metadata(paths.installation, true), integrity: 'not-checked',
179
+ bundle: await metadata(path.join(paths.installation, 'bundle.json')) };
180
+ } catch { /* Diagnostics must still work when release pins/platform are unavailable. */ }
181
+ const stateInfo = await metadata(state, true);
182
+ const present = records.filter(record => record.status !== 'absent');
183
+ const uncertain = present.some(record => record.status !== 'parsed' || record.format !== 'recognized-schema'
184
+ || (!['selection', 'backend-identity', 'native-ui'].includes(record.kind)
185
+ && (record.stage === 'unknown' || record.provenance?.host !== 'local')));
186
+ const stage = stateInfo.status === 'absent' ? 'state-absent'
187
+ : stateInfo.status !== 'present' || uncertain || owners.limited || owners.status === 'unreadable-or-unsafe' ? 'inspection-required'
188
+ : !present.length ? 'no-startup-records' : 'records-present-unverified';
189
+ return { schema: 'workbite.startup-diagnostics.v1', readOnly: true, stage, paths,
190
+ workbiteHomeSource: environment.WORKBITE_HOME ? 'WORKBITE_HOME' : 'platform-default', state: stateInfo,
191
+ installation, bootIdentity: validBoot(boot) ? 'available' : 'unavailable',
192
+ owners: { path: owners.path, status: owners.status, entriesExamined: owners.entriesExamined, limited: owners.limited }, records,
193
+ limits: { bytesPerRecord: MAX_BYTES, ownerRecords: MAX_OWNERS, directoryEntries: MAX_ENTRIES },
194
+ limitations: [
195
+ 'Snapshot only: records may change concurrently and NFS visibility may lag. Schema recognition is not authentication or full validation.',
196
+ 'No endpoints are probed. No credential files or log contents are read. Saved ready/selected stages do not prove current health.',
197
+ 'Foreign or unknown host PIDs are never tested locally or declared dead. A local missing PID is an observation, not cleanup authority.',
198
+ 'Local PID existence alone never proves process identity, ownership or safe cleanup. Prior-boot evidence does not authorize deletion.',
199
+ 'Windows boot provenance remains unknown because diagnostics never launches the WMI helper process.',
200
+ 'Install presence is not integrity verification; use workbite check for the existing integrity check.',
201
+ ],
202
+ guidance: [NO_DELETE, 'workbite open --no-ui starts or reuses backends only; it does not prove that a terminal UI can open.',
203
+ 'doctor/status inspect saved startup records passively. inspection-required means provenance needs review; it is not an authenticated agent admission failure.',
204
+ 'workbite agent status ready means authenticated workspace command admission at that observation, not provider readiness, turn completion or Task completion.',
205
+ 'To check workspace admission, explicitly run workbite agent status in the intended workspace. To inspect an assigned execution seat, refresh stackbite-room context; compare its current generation and runtime evidence.',
206
+ 'Configured provider labels, installed binaries and saved ready records do not verify the running provider or its lifecycle hooks. A Taskless standalone Room is normal; an unassigned seat must wait for assignment.',
207
+ 'Inspect the selected record paths and private logs locally with credentials redacted; do not publish raw files.',
208
+ 'For native daemon logs, consult log_file and stderr_log_file in the retained native intent locally; untrusted saved paths are not followed here.',
209
+ 'For foreign-host records, inspect the recorded host. If ownership remains unknown, retain the state and seek operator review.',
210
+ 'A pending native intent may have applied already. Reauthenticate the original owner and resolve its operation status; do not replay it.'],
211
+ };
212
+ }
213
+ async function runDiagnostics(parsed, options = {}) {
214
+ const report = await inspectStartup(options);
215
+ report.command = parsed.command;
216
+ if (parsed.command === 'recover') report.recovery = { dryRun: true, mutated: false, repaired: false,
217
+ plan: ['Retain the original state and logs; review the diagnostic evidence and limitations.',
218
+ 'Establish the recorded host, boot and authenticated process ownership through operator review before considering any repair.',
219
+ 'Resolve retained native intent with the original owner; do not replay unknown outcomes.',
220
+ 'No automatic recovery action is supported by this command.'], guidance: NO_DELETE };
221
+ const stdout = options.stdout || process.stdout;
222
+ if (parsed.json) stdout.write(JSON.stringify(report) + '\n');
223
+ else {
224
+ const lines = [`WorkBite ${parsed.command}: ${report.stage} (read-only)`,
225
+ `WORKBITE_HOME: ${report.paths.workbiteHome} (${report.workbiteHomeSource})`,
226
+ ...Object.entries(report.paths).filter(([name]) => name !== 'workbiteHome').map(([name, file]) => `${name}: ${file}`),
227
+ `installation: ${report.installation.status}; integrity not checked (workbite check)`,
228
+ `owners: ${report.owners.status}; ${report.owners.entriesExamined} entries examined; limited=${report.owners.limited}`,
229
+ ...report.records.map(record => `${record.kind}: ${record.status}; stage=${record.stage || 'unknown'}; `
230
+ + `host=${record.provenance?.host || 'unknown'}; boot=${record.provenance?.boot || 'unknown'}; `
231
+ + `pid=${record.pid ?? 'unknown'} (${record.pidEvidence || 'not-probed'}); ${record.path}`
232
+ + (record.logHint ? `; log hint=${record.logHint}` : '')),
233
+ ...report.limitations, ...report.guidance, ...(report.recovery?.plan || [])];
234
+ stdout.write(lines.join('\n') + '\n');
235
+ }
236
+ return { code: 0, signal: null };
237
+ }
238
+ module.exports = { inspectStartup, runDiagnostics };