workbite 0.1.19 → 0.1.20

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.
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ // An inherited hint is discovery data, never Task write authority or a provider grant.
3
+ const fs = require('node:fs/promises');
4
+ const { constants } = require('node:fs');
5
+ const path = require('node:path');
6
+ const { execFile } = require('node:child_process');
7
+ const { promisify } = require('node:util');
8
+ const { createHash } = require('node:crypto');
9
+ const { UUID } = require('./common');
10
+ const execute = promisify(execFile);
11
+ const SCHEMA = 'stackbite.workstack-task-context.v1';
12
+ const MAX_CONTEXT = 1024 * 1024, MAX_SNAPSHOT = 32768;
13
+ const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value);
15
+ const refuse = reason => { throw Object.assign(new Error(reason), { code: 'task_context_unavailable', reason }); };
16
+ function absolute(value) {
17
+ if (typeof value !== 'string' || !path.isAbsolute(value) || /[\x00-\x1f]/.test(value)
18
+ || /^(?:\\\\|\/\/)/.test(value)
19
+ || (process.platform === 'win32' && /^[\\/](?![\\/])/.test(value))) refuse('unsafe_context_path');
20
+ return path.resolve(value);
21
+ }
22
+ function samePath(a, b) {
23
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
24
+ }
25
+ async function regularDirectory(directory) {
26
+ const parent = path.dirname(directory);
27
+ if (parent !== directory) await regularDirectory(parent);
28
+ const info = await fs.lstat(directory);
29
+ if (!info.isDirectory() || info.isSymbolicLink()) refuse('linked_context_path');
30
+ }
31
+ async function readRegular(filename, limit) {
32
+ const target = absolute(filename);
33
+ await regularDirectory(path.dirname(target));
34
+ const before = await fs.lstat(target);
35
+ if (!before.isFile() || before.isSymbolicLink() || before.size > limit) refuse('unsafe_or_oversized_context_file');
36
+ const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
37
+ try {
38
+ const info = await handle.stat();
39
+ if (!info.isFile() || info.size > limit || info.dev !== before.dev || info.ino !== before.ino) refuse('context_file_changed');
40
+ const buffer = Buffer.alloc(limit + 1);
41
+ let size = 0;
42
+ while (size < buffer.length) {
43
+ const next = await handle.read(buffer, size, buffer.length - size, null);
44
+ if (!next.bytesRead) break;
45
+ size += next.bytesRead;
46
+ }
47
+ if (size > limit) refuse('unsafe_or_oversized_context_file');
48
+ return buffer.subarray(0, size);
49
+ } finally { await handle.close(); }
50
+ }
51
+ // JSON.parse accepts duplicate keys. Walk the bounded document first so an identity
52
+ // cannot mean different things to the producer and this consumer. Depth is bounded too.
53
+ function strictJson(bytes) {
54
+ const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
55
+ let pos = 0;
56
+ const white = () => { while (/[ \t\r\n]/.test(source[pos] || '\u0000')) pos++; };
57
+ function string() {
58
+ const start = pos++;
59
+ while (pos < source.length) {
60
+ if (source[pos] === '\\') { pos += 2; continue; }
61
+ if (source[pos++] === '"') return JSON.parse(source.slice(start, pos));
62
+ }
63
+ refuse('invalid_context_json');
64
+ }
65
+ function value(depth) {
66
+ if (depth > 64) refuse('invalid_context_json');
67
+ white();
68
+ if (source[pos] === '"') { string(); return; }
69
+ if (source[pos] === '{' || source[pos] === '[') {
70
+ const object = source[pos++] === '{', end = object ? '}' : ']', keys = new Set();
71
+ white();
72
+ if (source[pos] === end) { pos++; return; }
73
+ for (;;) {
74
+ white();
75
+ if (object) {
76
+ if (source[pos] !== '"') refuse('invalid_context_json');
77
+ const key = string();
78
+ if (keys.has(key)) refuse('duplicate_context_key');
79
+ keys.add(key); white();
80
+ if (source[pos++] !== ':') refuse('invalid_context_json');
81
+ }
82
+ value(depth + 1); white();
83
+ const next = source[pos++];
84
+ if (next === end) return;
85
+ if (next !== ',') refuse('invalid_context_json');
86
+ }
87
+ }
88
+ const match = /^(?:null|true|false|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(source.slice(pos));
89
+ if (!match) refuse('invalid_context_json');
90
+ pos += match[0].length;
91
+ }
92
+ value(0); white();
93
+ if (pos !== source.length) refuse('invalid_context_json');
94
+ return JSON.parse(source);
95
+ }
96
+ function matchesTask(envelope, binding, fresh = false) {
97
+ const task = envelope?.data?.task;
98
+ return plain(envelope) && envelope.contract === 'workstack.cli.v1' && !Object.hasOwn(envelope, 'error')
99
+ && envelope.meta?.command === 'agent.context' && envelope.meta?.workspace_uid === binding.workspace_uid
100
+ && envelope.data?.workspace_uid === binding.workspace_uid && plain(task)
101
+ && task.id === binding.task_id && task.uid === binding.task_uid
102
+ && Number.isSafeInteger(task.revision)
103
+ && (fresh ? task.revision >= binding.task_revision : task.revision === binding.task_revision);
104
+ }
105
+ function generationOf(environment) {
106
+ const generation = environment.STACKBITE_SESSION_GENERATION;
107
+ if (!identifier(generation) || (environment.STACKBITE_SEAT_GENERATION !== undefined
108
+ && environment.STACKBITE_SEAT_GENERATION !== generation)) refuse('seat_generation_mismatch');
109
+ return generation;
110
+ }
111
+ function identity(hint, environment) {
112
+ if (!identifier(environment.STACKBITE_ROOM_ID) || !identifier(environment.STACKBITE_SEAT_KEY)
113
+ || hint.room_id !== environment.STACKBITE_ROOM_ID || hint.seat_key !== environment.STACKBITE_SEAT_KEY)
114
+ refuse('seat_identity_mismatch');
115
+ const generation = generationOf(environment);
116
+ if (hint.generation !== null && hint.generation !== generation) refuse('seat_generation_mismatch');
117
+ }
118
+ async function loadHint(environment) {
119
+ if (!environment.STACKBITE_CONTEXT_FILE) return null;
120
+ try {
121
+ const document = strictJson(await readRegular(environment.STACKBITE_CONTEXT_FILE, MAX_CONTEXT));
122
+ if (!plain(document)) refuse('invalid_launch_context');
123
+ if (!Object.hasOwn(document, 'workstack_task_context')) return null; // old / standalone Seat
124
+ const hint = document.workstack_task_context;
125
+ if (!plain(hint) || hint.schema !== SCHEMA || hint.status !== 'bound') refuse('task_context_unverified');
126
+ identity(hint, environment);
127
+ if (document.room_id !== hint.room_id || (document.seat_key ?? document.seat_id) !== hint.seat_key)
128
+ refuse('seat_identity_mismatch');
129
+ const b = hint.task_binding, s = hint.context_snapshot;
130
+ if (!plain(b) || b.schema !== 'workstack.task-binding.v1' || Object.keys(b).length !== 5
131
+ || !UUID.test(b.workspace_uid) || typeof b.task_uid !== 'string' || !b.task_uid || b.task_uid.length > 128
132
+ || /[\x00-\x1f\x7f-\x9f]/.test(b.task_uid) || !/^T-[0-9]{4,}$/.test(b.task_id)
133
+ || b.task_id.length > 64 || !Number.isSafeInteger(b.task_revision) || b.task_revision < 0
134
+ || !plain(s) || s.contract !== 'workstack.cli.v1' || !/^[0-9a-f]{64}$/.test(s.sha256)
135
+ || !UUID.test(hint.request_id) || typeof hint.backend_id !== 'string'
136
+ || !/^[A-Za-z0-9_-]{1,128}$/.test(hint.backend_id)) refuse('invalid_task_handoff');
137
+ const state = absolute(environment.STACKBITE_STATE_DIR);
138
+ if (!samePath(absolute(environment.STACKBITE_CONTEXT_FILE), path.join(state, 'agent-context', hint.room_id, hint.seat_key + '.json'))
139
+ || !samePath(absolute(s.path), path.join(state, 'task-workspaces', hint.request_id, 'context.json')))
140
+ refuse('context_path_mismatch');
141
+ const bytes = await readRegular(s.path, MAX_SNAPSHOT);
142
+ if (createHash('sha256').update(bytes).digest('hex') !== s.sha256) refuse('snapshot_digest_mismatch');
143
+ if (!matchesTask(strictJson(bytes), b)) refuse('snapshot_task_mismatch');
144
+ return hint;
145
+ } catch (error) {
146
+ if (error.code === 'task_context_unavailable') throw error;
147
+ refuse('context_file_unreadable');
148
+ }
149
+ }
150
+ function commandArgv(environment) {
151
+ let argv;
152
+ try { argv = JSON.parse(environment.STACKBITE_AGENT_COMMAND); } catch { refuse('seat_cli_unavailable'); }
153
+ if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string' && arg.length < 4096 && !/[\x00-\x1f]/.test(arg)))
154
+ refuse('seat_cli_unavailable');
155
+ const executable = argv[0];
156
+ if (!executable || !path.isAbsolute(executable)) refuse('seat_cli_unavailable');
157
+ const base = path.basename(executable).toLowerCase();
158
+ const frozen = argv.length === 2 && /^(?:stackbite|workbite-execution)(?:\.exe)?$/.test(base) && argv[1] === 'agent';
159
+ const python = argv.length === 3 && /^python(?:3(?:\.[0-9]+)?)?(?:\.exe)?$/.test(base)
160
+ && path.isAbsolute(argv[1]) && path.basename(argv[1]) === 'stackbite.py' && argv[2] === 'agent';
161
+ if (!frozen && !python) refuse('seat_cli_unavailable');
162
+ return argv;
163
+ }
164
+ async function readSeat(environment) {
165
+ const argv = commandArgv(environment);
166
+ try {
167
+ const result = await execute(argv[0], [...argv.slice(1), 'context', '--format', 'full'], {
168
+ env: environment, shell: false, windowsHide: true, timeout: 10000, maxBuffer: MAX_CONTEXT, encoding: 'buffer' });
169
+ return strictJson(result.stdout);
170
+ } catch { refuse('live_seat_unavailable'); }
171
+ }
172
+ async function verifySeat(hint, environment, reader = readSeat) {
173
+ identity(hint, environment);
174
+ const live = await reader(environment), seat = live?.seat;
175
+ if (live?.room?.room_id !== hint.room_id || seat?.seat_key !== hint.seat_key
176
+ || seat.generation !== generationOf(environment) || seat.is_alive !== true) refuse('live_seat_mismatch');
177
+ const current = live.onboarding?.workstack_task_context;
178
+ if (!plain(current) || current.schema !== SCHEMA || current.status !== 'bound'
179
+ || current.room_id !== hint.room_id || current.seat_key !== hint.seat_key
180
+ || current.backend_id !== hint.backend_id || current.request_id !== hint.request_id
181
+ || !plain(current.task_binding) || Object.keys(current.task_binding).length !== 5
182
+ || Object.keys(hint.task_binding).some(key => current.task_binding[key] !== hint.task_binding[key])
183
+ || current.context_snapshot?.sha256 !== hint.context_snapshot.sha256
184
+ || current.context_snapshot?.path !== hint.context_snapshot.path
185
+ || current.context_snapshot?.contract !== hint.context_snapshot.contract
186
+ || (current.generation !== null && current.generation !== generationOf(environment))) refuse('live_assignment_changed');
187
+ }
188
+ module.exports = { loadHint, verifySeat, matchesTask, readRegular, strictJson, commandArgv };