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/README.md CHANGED
@@ -3,16 +3,15 @@
3
3
  WorkBite installs and opens **Work Stack** (organizes the work) and
4
4
  **StackBite** (gets it moving) as one native suite.
5
5
 
6
- Launcher 0.1.22 has a **qualified Windows x64 and Linux x64 native candidate**;
7
- TypeScript TUI is 1.0.38. Both actual artifacts,
8
- `workbite-0.1.22-windows-x64.zip` and `workbite-0.1.22-linux-x64.tar.gz`,
9
- use build source `dd0e08d5d7d241177a482d4b8d1d92448b724a82`, tree
10
- `37ba32d6033c6e26612036d6f883ccbc0349d50b`. Their reviewed archive hashes,
11
- embedded bundle digests and sizes are sealed in `release.json` for intended tag
12
- `v0.1.22` in `Shinick-Han/WorkBite-releases`. Publication, registry acceptance,
13
- installation and live handover remain pending. The operator selected manual
14
- publication because hosted Actions quota is exhausted; no hosted CI or OIDC
15
- success is claimed and the existing CI publication helper is unchanged.
6
+ Launcher 0.1.24 pins qualified Windows x64 and Linux x64 native archives.
7
+ TypeScript TUI remains 1.0.38 and the embedded StackBite is 0.5.39.
8
+ Both archives use build source `ba44d63cb89c9f979c236a2df6a7dcf73228a129`,
9
+ tree `15c07e0d5de33440422210675855c73c8de8a0b9`. Their actual archive hashes,
10
+ embedded bundle digests and sizes are sealed in `release.json` for `v0.1.24`
11
+ in `Shinick-Han/WorkBite-releases`. Publication, registry delivery and installed
12
+ runtime handover are separately verified maintainer operations. The maintainer
13
+ uses the selected manual publication path because hosted Actions quota is
14
+ exhausted; no hosted CI/OIDC success is claimed.
16
15
  It opens the familiar TypeScript Work Stack UI by default, which
17
16
  has one Tasks workbench: switch Focus/Table/Board without losing your selected
18
17
  Task, open its persistent Room's plain terminal, and control it beside or below
@@ -30,23 +29,22 @@ an explicit alternative per open: `workbite open --frontend rust` (or bare
30
29
  StackBite backend; the choice is not saved. Leaving the view or closing the UI
31
30
  keeps the agent running, and Task completion stays an explicit action.
32
31
 
33
- The 0.1.22 pair passed independent archive/runtime review: all 17 Windows probes,
32
+ The 0.1.24 pair passed independent archive/runtime review: all 17 Windows probes,
34
33
  all 12 Linux ELF version-needs audits at GLIBC <= 2.28, and 29 AlmaLinux 8.10
35
- userspace probes. Both packaged TypeScript TUIs returned 1.0.38. Fresh source QA
36
- includes Windows arrow 364 pass / one existing QR skip and typecheck, Linux arrow
37
- 70 pass, bounded backend/UX tests, and native Task Room client 19 / assembly 2 on
38
- each platform. Existing Windows-only/native opt-in skips remain explicit. All nine
39
- mandatory Linux frozen modules are present; two unwired libraries remain documented
40
- exclusions. Prior 0.1.21 artifacts do not qualify this pair.
41
- See the [0.1.22 release notes](../../docs/RELEASE-0.1.22.md) and
42
- [qualification record](../../docs/releases/0.1.22/README.md) for exact pins and limits.
43
- Configured disposable fake-connector Windows bridge hello returned interactive=true
44
- and wrote zero state files; blank-profile false is UNCONFIGURED. This is capability
45
- smoke, with no actual attach, native input or provider proof. Source fixtures and
46
- Alma8 bwrap userspace on WSL do not prove a booted AlmaLinux kernel, baseline CPU,
47
- SSH/VM, NFS or physical Phone behavior. Updating an existing native bundle requires
48
- an operator handover of existing backend processes; the launcher does not stop them
49
- or clear startup records automatically.
34
+ userspace probes. Fresh bounded lifecycle source checks passed on Windows and
35
+ Linux; Windows-only checks skipped on Linux remain explicit. These checks do
36
+ not claim a real provider turn, interactive-screen acceptance, booted AlmaLinux,
37
+ baseline CPU, SSH/VM, NFS or physical Phone behavior. Linux Phone is unsupported.
38
+ See the [0.1.24 release notes](../../docs/RELEASE-0.1.24.md) and
39
+ [qualification record](../../docs/releases/0.1.24/README.md) for pins and limits.
40
+
41
+ The Rust frontend highlights the active Room and Seat with a background.
42
+ Optional Claude/Codex lifecycle hooks add observed work status for CLIs started
43
+ manually inside shell Seats. Stop means idle, never verified completion. Hook
44
+ installation and an existing CLI's normal restart/resume may be required; Codex
45
+ retains its normal hook trust review. Current owners and Seats remain on their
46
+ existing runtime until a safe operator handover; updates do not stop them or
47
+ clear unknown startup records automatically.
50
48
 
51
49
  The candidate keeps a stable Seat across explicit shell restarts, with a fresh
52
50
  generation and no inferred previous CLI/provider resume. Stop retains the Seat;
@@ -89,7 +87,7 @@ new one. A failure can leave the launcher installed without its suite: inspect
89
87
  `workbite check`. Never replay an unknown npm install without inspecting it.
90
88
 
91
89
  ```sh
92
- npm install -g workbite@0.1.22 # only after registry publication and public-asset verification
90
+ npm install -g workbite@0.1.24 # only after registry publication and public-asset verification
93
91
  workbite install
94
92
  workbite open --workspace /abs/path/new-folder --init # first time, once
95
93
  workbite # afterwards (TypeScript UI)
@@ -175,12 +173,12 @@ assignment refuses discovery. Standalone/unbound terminals retain the explicit
175
173
  workspace/context path. WorkBite does not enable MCP or configure providers
176
174
  automatically; source fixture checks do not prove an actual provider turn.
177
175
 
178
- The 0.1.22 candidate also repairs initial Task Room capability discovery and
176
+ The candidate retains the repair for initial Task Room capability discovery and
179
177
  recovery notices after overlapping reads. Task Room inventory is filtered to
180
178
  the requested Task's Rooms before runtime inventory is read. Selecting a Task
181
179
  continues to create no Room, Seat or provider.
182
180
 
183
- The 0.1.22 candidate retains **Connect agent** in Task Execution (`a`),
181
+ The candidate retains **Connect agent** in Task Execution (`a`),
184
182
  the global 9 Execution monitor and launch picker (`+`), clickable controls and
185
183
  scoped command palettes (`:`). Claude Code, Codex, Cursor and Grok Build use
186
184
  their official CLI login and credential storage. Missing CLIs offer install
@@ -202,7 +200,7 @@ Source checks used disposable fixtures, with no live account login, browser
202
200
  completion, account-entitlement proof or paid provider turn acceptance. See the
203
201
  [source acceptance record](../../docs/WORKBITE-AGENT-CONNECTION-ACCEPTANCE-2026-10-16.md).
204
202
 
205
- The Windows 0.1.22 candidate retains the read-only phone companion pilot from 0.1.14.
203
+ The Windows candidate retains the read-only phone companion pilot from 0.1.14.
206
204
  Linux Phone support remains unsupported; paired releases do not imply identical
207
205
  platform capabilities.
208
206
  Phone actions preserve their selected target during status reads, show pending
package/bin/workbite.js CHANGED
@@ -1,13 +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
- });
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/agent.js CHANGED
@@ -1,240 +1,240 @@
1
- 'use strict';
2
- // WorkBite agent interface: binds one already-running, WorkBite-owned Work Stack
3
- // owner and forwards curated agent commands to it through the pinned native
4
- // client's v1 `cli.execute` RPC. The native client is the only mapper to the
5
- // owner's versioned APIs. Nothing here starts, downloads, initializes or adopts
6
- // an owner, falls back to another workspace or tool, or replays a write.
7
- const path = require('node:path');
8
- const { spawn } = require('node:child_process');
9
- const { randomBytes } = require('node:crypto');
10
- const { fail, exists, readSmall, sha256, UUID } = require('./common');
11
- const { cleanEnvironment, stateLayout, workspaceKey, readWorkspaceUid, rememberedWorkspace, engineIdentity } = require('./runtime');
12
-
13
- const OWNER_SCHEMA = 'workbite.owner.v2';
14
- const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
15
- const LAUNCH = /^[0-9a-f]{16}$/;
16
- const MAX_PACKET_BYTES = 32768;
17
- const MAX_TRANSFER_BYTES = 1024 * 1024;
18
- const MAX_ARGV = 128;
19
- const MAX_STDOUT = 1024 * 1024 + 8192;
20
- const MAX_STDERR = 65536;
21
- const DEFAULT_DEADLINE = 30000;
22
- // Placeholder authority for the static capability table only; the client never contacts it for that command.
23
- const NO_ENDPOINT = 'http://127.0.0.1:9';
24
- const NO_UID = '00000000-0000-4000-8000-000000000000';
25
-
26
- // Curated command paths; every other native command (raw rpc, legacy, deploy, delete, migration) is refused.
27
- const AGENT_COMMANDS = Object.freeze(['capabilities', 'status', 'tasks list', 'tasks create', 'tasks update', 'context',
28
- 'checkpoint', 'operation-status', 'export', 'import', 'import-status', 'import-rollback']);
29
- const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint', 'import', 'import-rollback']);
30
- const FORBIDDEN_FLAGS = new Set(['--data-dir', '--endpoint', '--token-env', '--token', '--workspace', '--read-retries',
31
- '--timeout-ms', '--request-id', '--generation', '--packet-file']);
32
-
33
- const EXPLICIT = 'Name it with --workspace ABS --workspace-uid UUID, or open it once with: workbite open --workspace ABS';
34
- const bindings = new WeakMap();
35
-
36
- // Only the bound --workspace-uid (once, exactly that UID) may precede the command words.
37
- function commandOf(argv, uid) {
38
- if (!Array.isArray(argv) || argv[0] !== 'agent') return null;
39
- let start = 1;
40
- if (typeof uid === 'string' && uid) {
41
- if (argv[1] === '--workspace-uid' && argv[2] === uid) start = 3;
42
- else if (argv[1] === `--workspace-uid=${uid}`) start = 2;
43
- }
44
- const words = [];
45
- for (const item of argv.slice(start)) { if (item.startsWith('-')) break; words.push(item); }
46
- const name = words.join(' ');
47
- return AGENT_COMMANDS.includes(name) ? name : null;
48
- }
49
- function checkedArgv(argv, uid) {
50
- if (!Array.isArray(argv) || !argv.length || argv.length > MAX_ARGV
51
- || argv.some(item => typeof item !== 'string' || item.includes('\0'))
52
- || argv.reduce((total, item) => total + Buffer.byteLength(item), 0) > MAX_PACKET_BYTES) fail('Invalid WorkBite agent arguments');
53
- const command = commandOf(argv, uid);
54
- if (!command) fail('Unsupported WorkBite agent command; supported: ' + AGENT_COMMANDS.join(', '));
55
- argv.forEach((item, index) => {
56
- const key = item.split('=')[0];
57
- if (FORBIDDEN_FLAGS.has(key)) fail(`WorkBite agent commands do not accept ${key}; authority comes from the binding`);
58
- if (key === '--workspace-uid') {
59
- const value = item.includes('=') ? item.slice(item.indexOf('=') + 1) : argv[index + 1];
60
- if (value !== uid) fail('--workspace-uid does not match the bound WorkBite workspace');
61
- }
62
- });
63
- return command;
64
- }
65
- function checkedStdin(stdin, command) {
66
- if (stdin === undefined || stdin === null) return null;
67
- const maximum = command === 'import' ? MAX_TRANSFER_BYTES : MAX_PACKET_BYTES;
68
- if (typeof stdin !== 'string' || Buffer.byteLength(stdin) > maximum || stdin.includes('\0') || stdin.isWellFormed?.() === false) {
69
- fail(`WorkBite agent input must be UTF-8 text of at most ${maximum} bytes`);
70
- }
71
- return stdin;
72
- }
73
- // Capture recovery identity before launch; never reconstruct it from native error text.
74
- function checkedIntent(argv, command, write) {
75
- const flags = argv.filter(item => item.split('=')[0] === '--intent-id');
76
- if (!flags.length && !write) return null;
77
- if (flags.length !== 1 || (!WRITE_COMMANDS.includes(command)
78
- && !['operation-status', 'import-status'].includes(command)) || argv.includes('--')) {
79
- fail('Invalid or repeated --intent-id');
80
- }
81
- const flag = flags[0];
82
- const value = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : argv[argv.indexOf(flag) + 1];
83
- if (typeof value !== 'string' || (!flag.includes('=') && value.startsWith('--'))
84
- || value.length < 8 || value.length > 128 || /[^A-Za-z0-9._:-]/.test(value)) fail('Invalid --intent-id');
85
- return value;
86
- }
87
- function clientCommand(installed, commands) {
88
- const command = commands?.workstack_client || [installed?.executables?.workstack_client];
89
- if (!Array.isArray(command) || typeof command[0] !== 'string' || !path.isAbsolute(command[0])) {
90
- fail('The verified WorkBite native client is not available; run workbite check.');
91
- }
92
- return command;
93
- }
94
-
95
- function explicitWorkspace(workspace, workspaceUid) {
96
- if ((workspace === undefined) !== (workspaceUid === undefined)) fail('--workspace and --workspace-uid must be given together');
97
- if (typeof workspace !== 'string' || !path.isAbsolute(workspace) || /[\x00-\x1f]/.test(workspace)
98
- || (process.platform === 'win32' && /^[\\/](?![\\/])/.test(workspace))) fail('--workspace must be an absolute path');
99
- if (typeof workspaceUid !== 'string' || !UUID.test(workspaceUid)) fail('--workspace-uid must be a canonical workspace UUID');
100
- return { workspace: path.resolve(workspace), uid: workspaceUid };
101
- }
102
- // Only the saved selection (written after a fully proven open) names a default; never a guess.
103
- async function selectedWorkspace(layout) {
104
- if (!await exists(layout.selection)) fail(`No WorkBite workspace is selected. ${EXPLICIT}`);
105
- return rememberedWorkspace(layout);
106
- }
107
-
108
- async function ownerRecord(layout, workspace, uid) {
109
- const file = path.join(layout.owners, `${workspaceKey(workspace)}.json`);
110
- let bytes, record;
111
- try { bytes = await readSmall(file, 65536); record = JSON.parse(bytes.toString('utf8')); } catch {
112
- fail(`No ready WorkBite-owned Work Stack owner is recorded for ${workspace}. Start it with: workbite open --workspace ABS`);
113
- }
114
- const same = typeof record?.workspace === 'string' && path.isAbsolute(record.workspace)
115
- && workspaceKey(path.resolve(record.workspace)) === workspaceKey(workspace);
116
- if (record === null || typeof record !== 'object' || Array.isArray(record) || record.schema !== OWNER_SCHEMA
117
- || record.state !== 'ready' || !same || record.workspace_uid !== uid || typeof record.launch !== 'string'
118
- || !LAUNCH.test(record.launch) || typeof record.token !== 'string' || !/^[0-9a-f]{64}$/.test(record.token)
119
- || typeof record.endpoint !== 'string' || !LOOPBACK.test(record.endpoint)) {
120
- fail(`The Work Stack owner record for ${workspace} is not a ready WorkBite owner for that workspace UID. Open it with: workbite open --workspace ABS`);
121
- }
122
- return { file, digest: sha256(bytes), record };
123
- }
124
- async function proveOwner(layout, workspace, uid) {
125
- if (await readWorkspaceUid(workspace) !== uid) fail(`The workspace.json id of ${workspace} does not match --workspace-uid`);
126
- const owner = await ownerRecord(layout, workspace, uid);
127
- if (!await engineIdentity(owner.record.endpoint, owner.record.token, uid)) {
128
- fail(`The WorkBite Work Stack owner for ${workspace} could not be authenticated (stopped or replaced). Start it with: workbite open --workspace ABS`);
129
- }
130
- return owner;
131
- }
132
-
133
- async function bindAgent({ installed, stateRoot, environment = process.env, workspace, workspaceUid, commands, timeouts = {} } = {}) {
134
- if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) fail('WorkBite state directory must be an absolute path');
135
- const layout = stateLayout(path.resolve(stateRoot));
136
- const command = clientCommand(installed, commands);
137
- const chosen = workspace === undefined && workspaceUid === undefined
138
- ? await selectedWorkspace(layout) : explicitWorkspace(workspace, workspaceUid);
139
- const owner = await proveOwner(layout, chosen.workspace, chosen.uid);
140
- const binding = Object.freeze({ workspace: chosen.workspace, workspaceUid: chosen.uid, endpoint: owner.record.endpoint,
141
- stateRoot: layout.root });
142
- bindings.set(binding, Object.freeze({ layout, command, environment: { ...environment }, digest: owner.digest,
143
- launch: owner.record.launch, deadline: timeouts.agent || DEFAULT_DEADLINE }));
144
- return binding;
145
- }
146
-
147
- // The bound owner must be the very same proven generation for every call.
148
- async function recheck(binding, state) {
149
- const owner = await proveOwner(state.layout, binding.workspace, binding.workspaceUid);
150
- if (owner.digest !== state.digest || owner.record.launch !== state.launch || owner.record.endpoint !== binding.endpoint) {
151
- fail('The bound WorkBite Work Stack owner changed since binding; bind again (nothing was sent).');
152
- }
153
- }
154
-
155
- // JSON quoting retains the exact path without letting terminal controls become diagnostics.
156
- const diagnosticJson = value => JSON.stringify(value).replace(/[\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g,
157
- character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
158
- // One request, one response, no retry. `write` decides how an uncertain outcome is reported.
159
- function rpc(command, { endpoint, uid, workspace, environment, argv, stdin, deadline, write, intent, name }) {
160
- const requestId = `workbite-agent-${randomBytes(8).toString('hex')}`;
161
- const request = JSON.stringify({ version: 1, request_id: requestId, workspace_uid: uid, generation: 0,
162
- method: 'cli.execute', params: { argv, stdin } }) + '\n';
163
- const nativeTimeout = Math.max(1000, Math.min(300000, deadline));
164
- const [file, ...prefix] = command;
165
- // cli.execute wraps escaped stdout in JSON; a bounded archive may expand at both layers.
166
- const transfer = ['export', 'import', 'import-status', 'import-rollback'].includes(name);
167
- const stdoutLimit = transfer ? 8 * MAX_TRANSFER_BYTES : MAX_STDOUT;
168
- const lookup = transfer ? 'import-status' : 'operation-status';
169
- const intentFlag = intent?.startsWith('--') ? `--intent-id=${intent}` : `--intent-id ${intent}`;
170
- const uncertain = what => ({ code: 1, stdout: '', stderr: write
171
- ? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent ${lookup} ${intentFlag}\n`
172
- + `Use the same bound workspace: ${diagnosticJson({ workspace, workspace_uid: uid })}\n`
173
- : `error: ${what}; nothing was retried\n`,
174
- ...(write ? { meta: { command: `agent.${name.replace(/ /g, '.')}`, intent_id: intent, commit_state: 'unknown' },
175
- workbite: { schema: 'workbite.agent.binding.v1', workspace, workspace_uid: uid } } : {}) });
176
- return new Promise(resolve => {
177
- let child;
178
- try {
179
- child = spawn(file, [...prefix, '--endpoint', endpoint, '--workspace-uid', uid, '--timeout-ms', String(nativeTimeout),
180
- '--read-retries', '0', 'rpc'], { env: cleanEnvironment(environment), shell: false, windowsHide: true,
181
- stdio: ['pipe', 'pipe', 'pipe'] });
182
- } catch { resolve({ code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }); return; }
183
- const out = [], err = [];
184
- let outSize = 0, errSize = 0, done = false, overflow = false, started = false;
185
- const finish = result => { if (!done) { done = true; clearTimeout(timer); resolve(result); } };
186
- const timer = setTimeout(() => { child.kill(); finish(uncertain('native client deadline exceeded')); }, nativeTimeout + 5000);
187
- child.stdout.on('data', chunk => {
188
- outSize += chunk.length;
189
- if (outSize > stdoutLimit) { overflow = true; child.kill(); } else out.push(chunk);
190
- });
191
- child.stderr.on('data', chunk => { errSize += chunk.length; if (errSize <= MAX_STDERR) err.push(chunk); });
192
- child.stdin.on('error', () => {});
193
- child.once('spawn', () => { started = true; });
194
- child.once('error', () => finish(started ? uncertain('native client failed')
195
- : { code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }));
196
- child.once('close', () => {
197
- if (overflow) { finish(uncertain('native response exceeded its bound')); return; }
198
- const lines = Buffer.concat(out).toString('utf8').split('\n').filter(line => line.trim());
199
- let response = null;
200
- try { response = lines.length === 1 ? JSON.parse(lines[0]) : null; } catch { response = null; }
201
- if (response === null || typeof response !== 'object' || response.version !== 1 || response.request_id !== requestId) {
202
- finish(uncertain('no valid native response')); return;
203
- }
204
- if (response.error) {
205
- const code = ['InvalidRequest', 'UnsupportedVersion', 'Unavailable', 'Timeout', 'Conflict', 'Unauthorized', 'Io', 'UnknownCommit']
206
- .includes(response.error.code) ? response.error.code : 'Error';
207
- if (code === 'UnknownCommit') { finish(uncertain('owner response was lost')); return; }
208
- finish({ code: 1, stdout: '', stderr: `error: ${code}: native request failed\n` });
209
- return;
210
- }
211
- const result = response.result;
212
- if (result === null || typeof result !== 'object' || !Number.isInteger(result.exit_code)
213
- || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') { finish(uncertain('invalid native result')); return; }
214
- finish({ code: result.exit_code, stdout: result.stdout, stderr: result.stderr });
215
- });
216
- child.stdin.end(request);
217
- });
218
- }
219
-
220
- async function executeAgent(binding, argv, { stdin } = {}) {
221
- const state = bindings.get(binding);
222
- if (!state) fail('Not a WorkBite agent binding; use bindAgent');
223
- const command = checkedArgv(argv, binding.workspaceUid);
224
- const args = [...argv];
225
- const input = checkedStdin(stdin, command);
226
- const write = WRITE_COMMANDS.includes(command) && !(command === 'import' && args.includes('--dry-run'));
227
- const intent = checkedIntent(args, command, write);
228
- await recheck(binding, state);
229
- return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid, workspace: binding.workspace,
230
- environment: state.environment, argv: args, stdin: input, deadline: state.deadline, write, intent, name: command });
231
- }
232
-
233
- // The static capability table needs no workspace authority; it never reaches an owner.
234
- async function agentCapabilities({ installed, commands, environment = process.env, argv = ['agent', 'capabilities'], timeouts = {} } = {}) {
235
- if (checkedArgv(argv, NO_UID) !== 'capabilities') fail('Only agent capabilities is authority-free');
236
- return rpc(clientCommand(installed, commands), { endpoint: NO_ENDPOINT, uid: NO_UID, environment, argv: [...argv], stdin: null,
237
- deadline: timeouts.agent || DEFAULT_DEADLINE, write: false, name: 'capabilities' });
238
- }
239
-
240
- module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, MAX_TRANSFER_BYTES, commandOf };
1
+ 'use strict';
2
+ // WorkBite agent interface: binds one already-running, WorkBite-owned Work Stack
3
+ // owner and forwards curated agent commands to it through the pinned native
4
+ // client's v1 `cli.execute` RPC. The native client is the only mapper to the
5
+ // owner's versioned APIs. Nothing here starts, downloads, initializes or adopts
6
+ // an owner, falls back to another workspace or tool, or replays a write.
7
+ const path = require('node:path');
8
+ const { spawn } = require('node:child_process');
9
+ const { randomBytes } = require('node:crypto');
10
+ const { fail, exists, readSmall, sha256, UUID } = require('./common');
11
+ const { cleanEnvironment, stateLayout, workspaceKey, readWorkspaceUid, rememberedWorkspace, engineIdentity } = require('./runtime');
12
+
13
+ const OWNER_SCHEMA = 'workbite.owner.v2';
14
+ const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
15
+ const LAUNCH = /^[0-9a-f]{16}$/;
16
+ const MAX_PACKET_BYTES = 32768;
17
+ const MAX_TRANSFER_BYTES = 1024 * 1024;
18
+ const MAX_ARGV = 128;
19
+ const MAX_STDOUT = 1024 * 1024 + 8192;
20
+ const MAX_STDERR = 65536;
21
+ const DEFAULT_DEADLINE = 30000;
22
+ // Placeholder authority for the static capability table only; the client never contacts it for that command.
23
+ const NO_ENDPOINT = 'http://127.0.0.1:9';
24
+ const NO_UID = '00000000-0000-4000-8000-000000000000';
25
+
26
+ // Curated command paths; every other native command (raw rpc, legacy, deploy, delete, migration) is refused.
27
+ const AGENT_COMMANDS = Object.freeze(['capabilities', 'status', 'tasks list', 'tasks create', 'tasks update', 'context',
28
+ 'checkpoint', 'operation-status', 'export', 'import', 'import-status', 'import-rollback']);
29
+ const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint', 'import', 'import-rollback']);
30
+ const FORBIDDEN_FLAGS = new Set(['--data-dir', '--endpoint', '--token-env', '--token', '--workspace', '--read-retries',
31
+ '--timeout-ms', '--request-id', '--generation', '--packet-file']);
32
+
33
+ const EXPLICIT = 'Name it with --workspace ABS --workspace-uid UUID, or open it once with: workbite open --workspace ABS';
34
+ const bindings = new WeakMap();
35
+
36
+ // Only the bound --workspace-uid (once, exactly that UID) may precede the command words.
37
+ function commandOf(argv, uid) {
38
+ if (!Array.isArray(argv) || argv[0] !== 'agent') return null;
39
+ let start = 1;
40
+ if (typeof uid === 'string' && uid) {
41
+ if (argv[1] === '--workspace-uid' && argv[2] === uid) start = 3;
42
+ else if (argv[1] === `--workspace-uid=${uid}`) start = 2;
43
+ }
44
+ const words = [];
45
+ for (const item of argv.slice(start)) { if (item.startsWith('-')) break; words.push(item); }
46
+ const name = words.join(' ');
47
+ return AGENT_COMMANDS.includes(name) ? name : null;
48
+ }
49
+ function checkedArgv(argv, uid) {
50
+ if (!Array.isArray(argv) || !argv.length || argv.length > MAX_ARGV
51
+ || argv.some(item => typeof item !== 'string' || item.includes('\0'))
52
+ || argv.reduce((total, item) => total + Buffer.byteLength(item), 0) > MAX_PACKET_BYTES) fail('Invalid WorkBite agent arguments');
53
+ const command = commandOf(argv, uid);
54
+ if (!command) fail('Unsupported WorkBite agent command; supported: ' + AGENT_COMMANDS.join(', '));
55
+ argv.forEach((item, index) => {
56
+ const key = item.split('=')[0];
57
+ if (FORBIDDEN_FLAGS.has(key)) fail(`WorkBite agent commands do not accept ${key}; authority comes from the binding`);
58
+ if (key === '--workspace-uid') {
59
+ const value = item.includes('=') ? item.slice(item.indexOf('=') + 1) : argv[index + 1];
60
+ if (value !== uid) fail('--workspace-uid does not match the bound WorkBite workspace');
61
+ }
62
+ });
63
+ return command;
64
+ }
65
+ function checkedStdin(stdin, command) {
66
+ if (stdin === undefined || stdin === null) return null;
67
+ const maximum = command === 'import' ? MAX_TRANSFER_BYTES : MAX_PACKET_BYTES;
68
+ if (typeof stdin !== 'string' || Buffer.byteLength(stdin) > maximum || stdin.includes('\0') || stdin.isWellFormed?.() === false) {
69
+ fail(`WorkBite agent input must be UTF-8 text of at most ${maximum} bytes`);
70
+ }
71
+ return stdin;
72
+ }
73
+ // Capture recovery identity before launch; never reconstruct it from native error text.
74
+ function checkedIntent(argv, command, write) {
75
+ const flags = argv.filter(item => item.split('=')[0] === '--intent-id');
76
+ if (!flags.length && !write) return null;
77
+ if (flags.length !== 1 || (!WRITE_COMMANDS.includes(command)
78
+ && !['operation-status', 'import-status'].includes(command)) || argv.includes('--')) {
79
+ fail('Invalid or repeated --intent-id');
80
+ }
81
+ const flag = flags[0];
82
+ const value = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : argv[argv.indexOf(flag) + 1];
83
+ if (typeof value !== 'string' || (!flag.includes('=') && value.startsWith('--'))
84
+ || value.length < 8 || value.length > 128 || /[^A-Za-z0-9._:-]/.test(value)) fail('Invalid --intent-id');
85
+ return value;
86
+ }
87
+ function clientCommand(installed, commands) {
88
+ const command = commands?.workstack_client || [installed?.executables?.workstack_client];
89
+ if (!Array.isArray(command) || typeof command[0] !== 'string' || !path.isAbsolute(command[0])) {
90
+ fail('The verified WorkBite native client is not available; run workbite check.');
91
+ }
92
+ return command;
93
+ }
94
+
95
+ function explicitWorkspace(workspace, workspaceUid) {
96
+ if ((workspace === undefined) !== (workspaceUid === undefined)) fail('--workspace and --workspace-uid must be given together');
97
+ if (typeof workspace !== 'string' || !path.isAbsolute(workspace) || /[\x00-\x1f]/.test(workspace)
98
+ || (process.platform === 'win32' && /^[\\/](?![\\/])/.test(workspace))) fail('--workspace must be an absolute path');
99
+ if (typeof workspaceUid !== 'string' || !UUID.test(workspaceUid)) fail('--workspace-uid must be a canonical workspace UUID');
100
+ return { workspace: path.resolve(workspace), uid: workspaceUid };
101
+ }
102
+ // Only the saved selection (written after a fully proven open) names a default; never a guess.
103
+ async function selectedWorkspace(layout) {
104
+ if (!await exists(layout.selection)) fail(`No WorkBite workspace is selected. ${EXPLICIT}`);
105
+ return rememberedWorkspace(layout);
106
+ }
107
+
108
+ async function ownerRecord(layout, workspace, uid) {
109
+ const file = path.join(layout.owners, `${workspaceKey(workspace)}.json`);
110
+ let bytes, record;
111
+ try { bytes = await readSmall(file, 65536); record = JSON.parse(bytes.toString('utf8')); } catch {
112
+ fail(`No ready WorkBite-owned Work Stack owner is recorded for ${workspace}. Start it with: workbite open --workspace ABS`);
113
+ }
114
+ const same = typeof record?.workspace === 'string' && path.isAbsolute(record.workspace)
115
+ && workspaceKey(path.resolve(record.workspace)) === workspaceKey(workspace);
116
+ if (record === null || typeof record !== 'object' || Array.isArray(record) || record.schema !== OWNER_SCHEMA
117
+ || record.state !== 'ready' || !same || record.workspace_uid !== uid || typeof record.launch !== 'string'
118
+ || !LAUNCH.test(record.launch) || typeof record.token !== 'string' || !/^[0-9a-f]{64}$/.test(record.token)
119
+ || typeof record.endpoint !== 'string' || !LOOPBACK.test(record.endpoint)) {
120
+ fail(`The Work Stack owner record for ${workspace} is not a ready WorkBite owner for that workspace UID. Open it with: workbite open --workspace ABS`);
121
+ }
122
+ return { file, digest: sha256(bytes), record };
123
+ }
124
+ async function proveOwner(layout, workspace, uid) {
125
+ if (await readWorkspaceUid(workspace) !== uid) fail(`The workspace.json id of ${workspace} does not match --workspace-uid`);
126
+ const owner = await ownerRecord(layout, workspace, uid);
127
+ if (!await engineIdentity(owner.record.endpoint, owner.record.token, uid)) {
128
+ fail(`The WorkBite Work Stack owner for ${workspace} could not be authenticated (stopped or replaced). Start it with: workbite open --workspace ABS`);
129
+ }
130
+ return owner;
131
+ }
132
+
133
+ async function bindAgent({ installed, stateRoot, environment = process.env, workspace, workspaceUid, commands, timeouts = {} } = {}) {
134
+ if (typeof stateRoot !== 'string' || !path.isAbsolute(stateRoot)) fail('WorkBite state directory must be an absolute path');
135
+ const layout = stateLayout(path.resolve(stateRoot));
136
+ const command = clientCommand(installed, commands);
137
+ const chosen = workspace === undefined && workspaceUid === undefined
138
+ ? await selectedWorkspace(layout) : explicitWorkspace(workspace, workspaceUid);
139
+ const owner = await proveOwner(layout, chosen.workspace, chosen.uid);
140
+ const binding = Object.freeze({ workspace: chosen.workspace, workspaceUid: chosen.uid, endpoint: owner.record.endpoint,
141
+ stateRoot: layout.root });
142
+ bindings.set(binding, Object.freeze({ layout, command, environment: { ...environment }, digest: owner.digest,
143
+ launch: owner.record.launch, deadline: timeouts.agent || DEFAULT_DEADLINE }));
144
+ return binding;
145
+ }
146
+
147
+ // The bound owner must be the very same proven generation for every call.
148
+ async function recheck(binding, state) {
149
+ const owner = await proveOwner(state.layout, binding.workspace, binding.workspaceUid);
150
+ if (owner.digest !== state.digest || owner.record.launch !== state.launch || owner.record.endpoint !== binding.endpoint) {
151
+ fail('The bound WorkBite Work Stack owner changed since binding; bind again (nothing was sent).');
152
+ }
153
+ }
154
+
155
+ // JSON quoting retains the exact path without letting terminal controls become diagnostics.
156
+ const diagnosticJson = value => JSON.stringify(value).replace(/[\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g,
157
+ character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
158
+ // One request, one response, no retry. `write` decides how an uncertain outcome is reported.
159
+ function rpc(command, { endpoint, uid, workspace, environment, argv, stdin, deadline, write, intent, name }) {
160
+ const requestId = `workbite-agent-${randomBytes(8).toString('hex')}`;
161
+ const request = JSON.stringify({ version: 1, request_id: requestId, workspace_uid: uid, generation: 0,
162
+ method: 'cli.execute', params: { argv, stdin } }) + '\n';
163
+ const nativeTimeout = Math.max(1000, Math.min(300000, deadline));
164
+ const [file, ...prefix] = command;
165
+ // cli.execute wraps escaped stdout in JSON; a bounded archive may expand at both layers.
166
+ const transfer = ['export', 'import', 'import-status', 'import-rollback'].includes(name);
167
+ const stdoutLimit = transfer ? 8 * MAX_TRANSFER_BYTES : MAX_STDOUT;
168
+ const lookup = transfer ? 'import-status' : 'operation-status';
169
+ const intentFlag = intent?.startsWith('--') ? `--intent-id=${intent}` : `--intent-id ${intent}`;
170
+ const uncertain = what => ({ code: 1, stdout: '', stderr: write
171
+ ? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent ${lookup} ${intentFlag}\n`
172
+ + `Use the same bound workspace: ${diagnosticJson({ workspace, workspace_uid: uid })}\n`
173
+ : `error: ${what}; nothing was retried\n`,
174
+ ...(write ? { meta: { command: `agent.${name.replace(/ /g, '.')}`, intent_id: intent, commit_state: 'unknown' },
175
+ workbite: { schema: 'workbite.agent.binding.v1', workspace, workspace_uid: uid } } : {}) });
176
+ return new Promise(resolve => {
177
+ let child;
178
+ try {
179
+ child = spawn(file, [...prefix, '--endpoint', endpoint, '--workspace-uid', uid, '--timeout-ms', String(nativeTimeout),
180
+ '--read-retries', '0', 'rpc'], { env: cleanEnvironment(environment), shell: false, windowsHide: true,
181
+ stdio: ['pipe', 'pipe', 'pipe'] });
182
+ } catch { resolve({ code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }); return; }
183
+ const out = [], err = [];
184
+ let outSize = 0, errSize = 0, done = false, overflow = false, started = false;
185
+ const finish = result => { if (!done) { done = true; clearTimeout(timer); resolve(result); } };
186
+ const timer = setTimeout(() => { child.kill(); finish(uncertain('native client deadline exceeded')); }, nativeTimeout + 5000);
187
+ child.stdout.on('data', chunk => {
188
+ outSize += chunk.length;
189
+ if (outSize > stdoutLimit) { overflow = true; child.kill(); } else out.push(chunk);
190
+ });
191
+ child.stderr.on('data', chunk => { errSize += chunk.length; if (errSize <= MAX_STDERR) err.push(chunk); });
192
+ child.stdin.on('error', () => {});
193
+ child.once('spawn', () => { started = true; });
194
+ child.once('error', () => finish(started ? uncertain('native client failed')
195
+ : { code: 1, stdout: '', stderr: 'error: cannot start the verified Work Stack native client\n' }));
196
+ child.once('close', () => {
197
+ if (overflow) { finish(uncertain('native response exceeded its bound')); return; }
198
+ const lines = Buffer.concat(out).toString('utf8').split('\n').filter(line => line.trim());
199
+ let response = null;
200
+ try { response = lines.length === 1 ? JSON.parse(lines[0]) : null; } catch { response = null; }
201
+ if (response === null || typeof response !== 'object' || response.version !== 1 || response.request_id !== requestId) {
202
+ finish(uncertain('no valid native response')); return;
203
+ }
204
+ if (response.error) {
205
+ const code = ['InvalidRequest', 'UnsupportedVersion', 'Unavailable', 'Timeout', 'Conflict', 'Unauthorized', 'Io', 'UnknownCommit']
206
+ .includes(response.error.code) ? response.error.code : 'Error';
207
+ if (code === 'UnknownCommit') { finish(uncertain('owner response was lost')); return; }
208
+ finish({ code: 1, stdout: '', stderr: `error: ${code}: native request failed\n` });
209
+ return;
210
+ }
211
+ const result = response.result;
212
+ if (result === null || typeof result !== 'object' || !Number.isInteger(result.exit_code)
213
+ || typeof result.stdout !== 'string' || typeof result.stderr !== 'string') { finish(uncertain('invalid native result')); return; }
214
+ finish({ code: result.exit_code, stdout: result.stdout, stderr: result.stderr });
215
+ });
216
+ child.stdin.end(request);
217
+ });
218
+ }
219
+
220
+ async function executeAgent(binding, argv, { stdin } = {}) {
221
+ const state = bindings.get(binding);
222
+ if (!state) fail('Not a WorkBite agent binding; use bindAgent');
223
+ const command = checkedArgv(argv, binding.workspaceUid);
224
+ const args = [...argv];
225
+ const input = checkedStdin(stdin, command);
226
+ const write = WRITE_COMMANDS.includes(command) && !(command === 'import' && args.includes('--dry-run'));
227
+ const intent = checkedIntent(args, command, write);
228
+ await recheck(binding, state);
229
+ return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid, workspace: binding.workspace,
230
+ environment: state.environment, argv: args, stdin: input, deadline: state.deadline, write, intent, name: command });
231
+ }
232
+
233
+ // The static capability table needs no workspace authority; it never reaches an owner.
234
+ async function agentCapabilities({ installed, commands, environment = process.env, argv = ['agent', 'capabilities'], timeouts = {} } = {}) {
235
+ if (checkedArgv(argv, NO_UID) !== 'capabilities') fail('Only agent capabilities is authority-free');
236
+ return rpc(clientCommand(installed, commands), { endpoint: NO_ENDPOINT, uid: NO_UID, environment, argv: [...argv], stdin: null,
237
+ deadline: timeouts.agent || DEFAULT_DEADLINE, write: false, name: 'capabilities' });
238
+ }
239
+
240
+ module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, MAX_TRANSFER_BYTES, commandOf };