workbite 0.1.11 → 0.1.13

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,234 @@
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
+ 'Inspect the selected record paths and private logs locally with credentials redacted; do not publish raw files.',
204
+ 'For native daemon logs, consult log_file and stderr_log_file in the retained native intent locally; untrusted saved paths are not followed here.',
205
+ 'For foreign-host records, inspect the recorded host. If ownership remains unknown, retain the state and seek operator review.',
206
+ 'A pending native intent may have applied already. Reauthenticate the original owner and resolve its operation status; do not replay it.'],
207
+ };
208
+ }
209
+ async function runDiagnostics(parsed, options = {}) {
210
+ const report = await inspectStartup(options);
211
+ report.command = parsed.command;
212
+ if (parsed.command === 'recover') report.recovery = { dryRun: true, mutated: false, repaired: false,
213
+ plan: ['Retain the original state and logs; review the diagnostic evidence and limitations.',
214
+ 'Establish the recorded host, boot and authenticated process ownership through operator review before considering any repair.',
215
+ 'Resolve retained native intent with the original owner; do not replay unknown outcomes.',
216
+ 'No automatic recovery action is supported by this command.'], guidance: NO_DELETE };
217
+ const stdout = options.stdout || process.stdout;
218
+ if (parsed.json) stdout.write(JSON.stringify(report) + '\n');
219
+ else {
220
+ const lines = [`WorkBite ${parsed.command}: ${report.stage} (read-only)`,
221
+ `WORKBITE_HOME: ${report.paths.workbiteHome} (${report.workbiteHomeSource})`,
222
+ ...Object.entries(report.paths).filter(([name]) => name !== 'workbiteHome').map(([name, file]) => `${name}: ${file}`),
223
+ `installation: ${report.installation.status}; integrity not checked (workbite check)`,
224
+ `owners: ${report.owners.status}; ${report.owners.entriesExamined} entries examined; limited=${report.owners.limited}`,
225
+ ...report.records.map(record => `${record.kind}: ${record.status}; stage=${record.stage || 'unknown'}; `
226
+ + `host=${record.provenance?.host || 'unknown'}; boot=${record.provenance?.boot || 'unknown'}; `
227
+ + `pid=${record.pid ?? 'unknown'} (${record.pidEvidence || 'not-probed'}); ${record.path}`
228
+ + (record.logHint ? `; log hint=${record.logHint}` : '')),
229
+ ...report.limitations, ...report.guidance, ...(report.recovery?.plan || [])];
230
+ stdout.write(lines.join('\n') + '\n');
231
+ }
232
+ return { code: 0, signal: null };
233
+ }
234
+ module.exports = { inspectStartup, runDiagnostics };
package/lib/install.js CHANGED
@@ -2,9 +2,10 @@
2
2
  const fs = require('node:fs/promises');
3
3
  const path = require('node:path');
4
4
  const os = require('node:os');
5
+ const { performance } = require('node:perf_hooks');
5
6
  const { LIMITS, fail, memberPath, inside, hashFile, readSmall, exists, safeDirectory, sha256 } = require('./common');
6
7
  const { download } = require('./download');
7
- const { extractArchive, TOP_DIRECTORIES, BUNDLE } = require('./archive');
8
+ const { extractArchive, diagnosticText, TOP_DIRECTORIES, BUNDLE } = require('./archive');
8
9
  const packageVersion = require('../package.json').version;
9
10
 
10
11
  const REPOSITORY = 'Shinick-Han/WorkBite-releases';
@@ -175,7 +176,7 @@ function checkPathBudget(target, bundle, spec) {
175
176
  }
176
177
 
177
178
  // A version directory is published once by rename and never rewritten; reuse revalidates it fully.
178
- async function ensureInstalled(spec, { apps, downloadImpl = download, extractImpl = extractArchive,
179
+ async function installVersion(spec, { apps, downloadImpl = download, extractImpl = extractArchive,
179
180
  checkOnly = false, progress = () => {} } = {}) {
180
181
  apps = path.resolve(apps);
181
182
  const target = path.join(apps, installName(spec));
@@ -189,6 +190,7 @@ async function ensureInstalled(spec, { apps, downloadImpl = download, extractImp
189
190
  if (checkOnly) fail('WorkBite is not installed for this package version; run workbite install.');
190
191
  await safeDirectory(apps);
191
192
  const staging = await fs.mkdtemp(path.join(apps, '.staging-'));
193
+ let cleanup = true;
192
194
  try {
193
195
  const archive = path.join(staging, spec.asset), extraction = path.join(staging, 'payload');
194
196
  await fs.mkdir(extraction, { mode: 0o700 });
@@ -210,10 +212,39 @@ async function ensureInstalled(spec, { apps, downloadImpl = download, extractImp
210
212
  const installed = await validateBundle(target, spec);
211
213
  progress('ready');
212
214
  return installed;
215
+ } catch (error) {
216
+ if (error.extractionMayBeRunning) cleanup = false;
217
+ throw error;
213
218
  } finally {
214
219
  // Only this invocation's mkdtemp staging directory is ever removed.
215
220
  if (!inside(apps, staging) || path.dirname(staging) !== apps) fail('Unsafe staging cleanup path');
216
- await fs.rm(staging, { recursive: true, force: true });
221
+ if (cleanup) await fs.rm(staging, { recursive: true, force: true });
222
+ }
223
+ }
224
+
225
+ // Keep progress on stderr through the existing reporter; stdout remains the
226
+ // workbite.install.v1 document. Every reuse still performs full hash validation.
227
+ async function ensureInstalled(spec, options = {}) {
228
+ const apps = path.resolve(options.apps);
229
+ const target = path.join(apps, installName(spec));
230
+ const started = performance.now();
231
+ let verifying, verificationMs = 0;
232
+ const progress = stage => {
233
+ const now = performance.now();
234
+ if (verifying !== undefined) { verificationMs += now - verifying; verifying = undefined; }
235
+ if (stage === 'validate' || stage === 'validate-published') verifying = now;
236
+ (options.progress || (() => {}))(stage, { elapsedMs: Math.round(now - started),
237
+ verificationMs: Math.round(verificationMs), home: target, apps });
238
+ };
239
+ try {
240
+ return await installVersion(spec, { ...options, apps, progress });
241
+ } catch (error) {
242
+ const failure = new Error(`${diagnosticText(error.message, 8192)} Installation path: ${diagnosticText(target)} (apps: ${diagnosticText(apps)}); elapsed: ${Math.round(performance.now() - started)} ms.`);
243
+ for (const key of ['code', 'exitCode', 'signal']) {
244
+ if (typeof error[key] === 'string') failure[key] = diagnosticText(error[key], 64);
245
+ else if (error[key] === null || typeof error[key] === 'number') failure[key] = error[key];
246
+ }
247
+ throw failure;
217
248
  }
218
249
  }
219
250
 
@@ -227,7 +258,11 @@ function stageReporter(stderr) {
227
258
  'validate-published': 'Validating published version...',
228
259
  ready: 'Verified WorkBite release is ready.',
229
260
  };
230
- return stage => stderr.write(`WorkBite: ${messages[stage]}\n`);
261
+ return (stage, details) => {
262
+ const timing = stage === 'ready' && details
263
+ ? ` Elapsed: ${details.elapsedMs} ms; file verification: ${details.verificationMs} ms; home: ${diagnosticText(details.home)}.` : '';
264
+ stderr.write(`WorkBite: ${messages[stage]}${timing}\n`);
265
+ };
231
266
  }
232
267
 
233
268
  module.exports = { REPOSITORY, TARGETS, EXECUTABLES, BUNDLE_V1, BUNDLE_V2, ROSTERS, locations, platformTarget, releaseSpec, expectedExecutables,
package/lib/mcp.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // behind the one message in flight, plus one partial line (LIMITS.message) and at most one
9
9
  // undelivered input chunk. When that is full stdin is paused (backpressure), never dropped;
10
10
  // the next response is not produced until stdout has drained.
11
+ const { archive: validateArchive } = require('./transfer');
11
12
 
12
13
  const SUPPORTED_VERSIONS = Object.freeze(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
13
14
  const LATEST_VERSION = SUPPORTED_VERSIONS[0];
@@ -16,12 +17,12 @@ const STRUCTURED_SINCE = '2025-06-18', ANNOTATIONS_SINCE = '2025-03-26';
16
17
  // Only the 2025-03-26 schema defines JSONRPCBatchRequest ("MUST support receiving JSON-RPC batches").
17
18
  // 2024-11-05 defines no batch message and 2025-06-18 removed them, so batches are refused there.
18
19
  const BATCH_VERSION = '2025-03-26';
19
- const LIMITS = Object.freeze({ message: 1024 * 1024, packet: 32768, stdout: 256 * 1024, stderr: 8 * 1024,
20
+ const LIMITS = Object.freeze({ message: 4 * 1024 * 1024, transfer: 1024 * 1024, packet: 32768, stdout: 256 * 1024, stderr: 8 * 1024,
20
21
  batch: 16, queue: 16, queueBytes: 4 * 1024 * 1024 });
21
22
  const MCP_CONTRACT = 'workbite.mcp.v1', CLI_CONTRACT = 'workstack.cli.v1';
22
- const WRITES = new Set(['agent.tasks.create', 'agent.tasks.update', 'agent.checkpoint']);
23
+ const WRITES = new Set(['agent.tasks.create', 'agent.tasks.update', 'agent.checkpoint', 'agent.import', 'agent.import-rollback']);
23
24
  // Owner error codes the native agent contract publishes; anything else is reported as internal_error.
24
- const KNOWN_ERRORS = new Set(['action_conflict', 'action_pending', 'backend_mismatch', 'capability_not_enabled',
25
+ const KNOWN_ERRORS = new Set(['action_conflict', 'action_pending', 'backend_mismatch', 'body_too_large', 'transfer_conflict', 'capability_not_enabled',
25
26
  'commit_unknown', 'context_too_large', 'generation_changed', 'idempotency_conflict', 'input_unreadable',
26
27
  'internal_error', 'invalid_authority', 'invalid_body', 'invalid_receipt', 'invalid_response', 'journal_invalid',
27
28
  'journal_unavailable', 'launcher_unavailable', 'not_configured', 'not_found', 'not_sent', 'offline',
@@ -54,7 +55,9 @@ const INSTRUCTIONS = 'WorkBite tools act on the one WorkBite workspace bound whe
54
55
  + 'need expected_revision from a fresh read. Calls run one at a time and are never retried automatically. After '
55
56
  + 'commit_unknown, first look the SAME intent_id up with workbite_operation_status: continue from a committed '
56
57
  + 'receipt; only unknown with reason no_receipt permits one resend with the SAME intent_id and byte-identical '
57
- + 'arguments; on any other answer stop and report it.';
58
+ + 'arguments; on any other answer stop and report it. Task transfer uses workbite_export and workbite_import '
59
+ + '(dry_run=true previews without writing). Apply needs its preview proofs. For any unknown import or rollback '
60
+ + 'outcome retain the SAME intent_id and inspect workbite_import_status; never resend a transfer mutation automatically.';
58
61
 
59
62
  const TASK_ID = { type: 'string', pattern: '^T-[0-9]{4,}$', maxLength: 64, description: 'Explicitly selected Task ID, for example T-0001' };
60
63
  const INTENT_ID = { type: 'string', pattern: '^[A-Za-z0-9._:-]{8,128}$',
@@ -63,6 +66,39 @@ const WORKSPACE_UID = { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a
63
66
  description: 'The bound workspace UID reported by workbite_status' };
64
67
  const ITEMS = { type: 'array', maxItems: 20, items: { type: 'string', maxLength: 1000 } };
65
68
  const object = (properties, required = []) => ({ type: 'object', additionalProperties: false, properties, required });
69
+ const nullable = schema => ({ anyOf: [schema, { type: 'null' }] });
70
+ const TEXT = { type: 'string', maxLength: 32768 };
71
+ const NONBLANK = { ...TEXT, minLength: 1, pattern: '^[\\s\\S]*\\S[\\s\\S]*$' };
72
+ const NESTED_TEXT = { ...NONBLANK, pattern: '^[^\\u0000]*[^\\u0000\\s][^\\u0000]*$' };
73
+ const DATE = { type: 'string', format: 'date', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' };
74
+ const STRINGS = { type: 'array', items: TEXT };
75
+ const TASK_STATUS = { type: 'string', enum: ['open', 'started', 'done', 'dropped'] };
76
+ const PRIORITY = { type: 'string', enum: ['P0', 'P1', 'P2', 'P3'] };
77
+ const SUBTASK_ID = { type: 'string', pattern: '^S-([1-9][0-9]{0,14}|[1-8][0-9]{15}|900[0-6][0-9]{12}|90070[0-9]{11}|90071[0-8][0-9]{10}|900719[0-8][0-9]{9}|9007199[0-1][0-9]{8}|90071992[0-4][0-9]{7}|900719925[0-3][0-9]{6}|9007199254[0-6][0-9]{5}|90071992547[0-3][0-9]{4}|9007199254740[0-8][0-9]{2}|90071992547409[0-8][0-9]{1}|9007199254740990|9007199254740991)$', maxLength: 18,
78
+ description: 'Parent-scoped display ID with an integer suffix at most 9007199254740991; unique when supplied' };
79
+ const NOTES = { type: 'array', maxItems: 128, items: object({ text: NESTED_TEXT, date: DATE }, ['text']),
80
+ description: 'Whole-array replacement; [] clears. Missing dates default to the owner current day; supplied dates are note data.' };
81
+ const SUBTASKS = { type: 'array', maxItems: 128, items: object({ title: NESTED_TEXT, id: SUBTASK_ID,
82
+ priority: { ...PRIORITY, default: 'P2' }, status: { ...TASK_STATUS, default: 'open' } }, ['title']),
83
+ description: 'Whole-array replacement; [] clears. The owner allocates omitted IDs above existing and supplied IDs. '
84
+ + 'Subtask status never changes parent Task status.' };
85
+ // Relationship existence, normalization and cycles are checked by the native owner.
86
+ const TASK_FIELDS = {
87
+ title: NONBLANK, detail: TEXT,
88
+ status: TASK_STATUS, priority: PRIORITY,
89
+ due: nullable(DATE), scheduled: nullable(DATE),
90
+ estimate_minutes: nullable({ type: 'integer', minimum: 1, maximum: 1440 }),
91
+ tags: STRINGS, parent_id: nullable(TASK_ID), dependencies: STRINGS,
92
+ objective_ids: STRINGS, notes: NOTES, subtasks: SUBTASKS,
93
+ key_result_refs: { type: 'array', items: object({ objective_id: { type: 'string', minLength: 1, maxLength: 64 },
94
+ key_result_id: { type: 'string', minLength: 1, maxLength: 64 } }, ['objective_id', 'key_result_id']) },
95
+ };
96
+ const DIGEST = { type: 'string', pattern: '^sha256:[0-9a-f]{64}$' };
97
+ const ARCHIVE = { type: 'object', additionalProperties: true, properties: {
98
+ format: { type: 'string', enum: ['workbite.task-transfer.v1'] }, source_workspace_uid: WORKSPACE_UID,
99
+ source_digest: DIGEST, tasks: { type: 'array', items: { type: 'object', additionalProperties: true, properties: {} } },
100
+ objectives: { type: 'array', items: { type: 'object', additionalProperties: true, properties: {} } },
101
+ }, required: ['format', 'source_workspace_uid', 'source_digest', 'tasks', 'objectives'] };
66
102
  const OUTPUT_SCHEMA = { type: 'object', required: ['contract', 'meta'], properties: {
67
103
  contract: { type: 'string', enum: [CLI_CONTRACT, MCP_CONTRACT] }, data: { type: 'object' },
68
104
  error: { type: 'object' }, meta: { type: 'object' }, workbite: { type: 'object' } } };
@@ -86,16 +122,14 @@ const TOOLS = [
86
122
  { name: 'workbite_tasks_create', command: 'agent.tasks.create', title: 'Create one WorkBite Task', readOnly: false,
87
123
  description: 'Create one Task. Durable per intent_id: the SAME intent_id with identical arguments replays the one created Task.',
88
124
  inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID,
89
- title: { type: 'string', minLength: 1, maxLength: 32768 }, detail: { type: 'string', maxLength: 32768 },
90
- follows_task_id: TASK_ID }, ['workspace_uid', 'intent_id', 'title']) },
125
+ ...TASK_FIELDS,
126
+ follows_task_id: nullable(TASK_ID) }, ['workspace_uid', 'intent_id', 'title']) },
91
127
  { name: 'workbite_tasks_update', command: 'agent.tasks.update', title: 'Update one WorkBite Task', readOnly: false,
92
- description: 'Change the detail and/or status of one Task read at expected_revision (compare-and-set). '
128
+ description: 'Change supported fields of one Task read at expected_revision (compare-and-set). '
93
129
  + 'A stale revision is revision_conflict and nothing is written.',
94
130
  inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID, task_id: TASK_ID,
95
- expected_revision: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
96
- changes: { type: 'object', additionalProperties: false, minProperties: 1, properties: {
97
- detail: { type: 'string', maxLength: 32768 },
98
- status: { type: 'string', enum: ['open', 'started', 'done', 'dropped'] } } } },
131
+ expected_revision: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER - 1 },
132
+ changes: { ...object(TASK_FIELDS), minProperties: 1 } },
99
133
  ['workspace_uid', 'intent_id', 'task_id', 'expected_revision', 'changes']) },
100
134
  { name: 'workbite_checkpoint', command: 'agent.checkpoint', title: 'Append a WorkBite checkpoint', readOnly: false,
101
135
  description: 'Append one Daily Review checkpoint to an explicitly selected Task. Idempotent per intent_id.',
@@ -106,22 +140,61 @@ const TOOLS = [
106
140
  { name: 'workbite_operation_status', command: 'agent.operation-status', title: 'Look up a WorkBite write receipt',
107
141
  readOnly: true, description: 'Read-only lookup of the committed receipt of one intent_id. unknown never means "did not commit".',
108
142
  inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID }, ['intent_id']) },
143
+ { name: 'workbite_export', command: 'agent.export', title: 'Export WorkBite Tasks', readOnly: true,
144
+ description: 'Read a bounded Task transfer archive from the bound running owner. Full workspace relocation uses official backup/restore.',
145
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, include_history: { type: 'boolean' }, redact: { type: 'boolean' } }) },
146
+ { name: 'workbite_import', command: 'agent.import', title: 'Preview or apply a Task transfer', readOnly: false,
147
+ description: 'Preview with dry_run=true (read-only). Apply with the SAME archive and preview proofs, explicit workspace_uid and stable intent_id. '
148
+ + 'Unknown outcomes require workbite_import_status; never replay automatically. The owner validates the whole graph atomically.',
149
+ inputSchema: { ...object({ workspace_uid: WORKSPACE_UID, archive: ARCHIVE,
150
+ mode: { type: 'string', enum: ['new', 'merge'] }, dry_run: { type: 'boolean' }, preserve_ids: { type: 'boolean' },
151
+ intent_id: INTENT_ID, plan_digest: DIGEST, expected_snapshot_digest: DIGEST }, ['archive', 'mode', 'dry_run']),
152
+ allOf: [{ if: { properties: { dry_run: { const: false } } },
153
+ then: { required: ['workspace_uid', 'intent_id', 'plan_digest', 'expected_snapshot_digest'] },
154
+ else: { not: { anyOf: [{ required: ['intent_id'] }, { required: ['plan_digest'] }, { required: ['expected_snapshot_digest'] }] } } }] } },
155
+ { name: 'workbite_import_status', command: 'agent.import-status', title: 'Look up a Task import', readOnly: true,
156
+ description: 'Read the receipt for the SAME import intent_id. unknown never means not committed.',
157
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID }, ['intent_id']) },
158
+ { name: 'workbite_import_rollback', command: 'agent.import-rollback', title: 'Roll back one owned Task import', readOnly: false,
159
+ description: 'Request rollback of one exact owned import under a fresh snapshot CAS. The owner refuses intervening changes.',
160
+ inputSchema: object({ workspace_uid: WORKSPACE_UID, intent_id: INTENT_ID, import_id: NONBLANK, expected_snapshot_digest: DIGEST },
161
+ ['workspace_uid', 'intent_id', 'import_id', 'expected_snapshot_digest']) },
109
162
  ];
110
163
  const BY_NAME = new Map(TOOLS.map(tool => [tool.name, tool]));
111
- const PACKET_KEYS = { 'agent.tasks.create': ['title', 'detail', 'follows_task_id'],
164
+ const PACKET_KEYS = { 'agent.tasks.create': [...Object.keys(TASK_FIELDS), 'follows_task_id'],
112
165
  'agent.tasks.update': ['task_id', 'expected_revision', 'changes'],
113
166
  'agent.checkpoint': ['task_id', 'date', 'done', 'next', 'blockers'] };
114
167
 
115
168
  const isPlain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
116
169
  // The schema subset above, enforced server side; the first violation is reported.
117
170
  function violation(value, schema, where) {
118
- switch (schema.type) {
171
+ if (Object.hasOwn(schema, 'const') && value !== schema.const) return `${where} has an invalid constant`;
172
+ if (schema.anyOf) return schema.anyOf.some(option => !violation(value, option, where)) ? null : `${where} has an invalid value`;
173
+ if (schema.not && !violation(value, schema.not, where)) return `${where} has forbidden properties`;
174
+ if (schema.allOf) {
175
+ for (const rule of schema.allOf) {
176
+ const problem = violation(value, rule, where);
177
+ if (problem) return problem;
178
+ }
179
+ }
180
+ if (schema.if) {
181
+ const branch = violation(value, schema.if, where) ? schema.else : schema.then;
182
+ if (branch) {
183
+ const problem = violation(value, branch, where);
184
+ if (problem) return problem;
185
+ }
186
+ }
187
+ switch (schema.type || (schema.properties || schema.required ? 'object' : undefined)) {
188
+ case 'null': return value === null ? null : `${where} must be null`;
119
189
  case 'object': {
120
190
  if (!isPlain(value)) return `${where} must be an object`;
121
191
  for (const key of schema.required || []) if (!Object.hasOwn(value, key)) return `${where}.${key} is required`;
122
192
  if (Object.keys(value).length < (schema.minProperties || 0)) return `${where} has too few properties`;
123
193
  for (const [key, item] of Object.entries(value)) {
124
- if (!Object.hasOwn(schema.properties, key)) return `${where} has unknown property ${JSON.stringify(key.slice(0, 64))}`;
194
+ if (!Object.hasOwn(schema.properties || {}, key)) {
195
+ if (schema.additionalProperties !== false) continue;
196
+ return `${where} has unknown property ${JSON.stringify(key.slice(0, 64))}`;
197
+ }
125
198
  const problem = violation(item, schema.properties[key], `${where}.${key}`);
126
199
  if (problem) return problem;
127
200
  }
@@ -134,6 +207,12 @@ function violation(value, schema, where) {
134
207
  const problem = violation(value[index], schema.items, `${where}[${index}]`);
135
208
  if (problem) return problem;
136
209
  }
210
+ // JSON Schema uniqueItems compares entire objects; supplied display IDs must be unique even
211
+ // when the other fields differ. Omitted IDs and defaults remain exclusively owner-assigned.
212
+ if (schema === SUBTASKS) {
213
+ const ids = value.filter(item => Object.hasOwn(item, 'id')).map(item => item.id);
214
+ if (new Set(ids).size !== ids.length) return `${where} has duplicate subtask IDs`;
215
+ }
137
216
  return null;
138
217
  }
139
218
  case 'integer':
@@ -148,9 +227,15 @@ function violation(value, schema, where) {
148
227
  if (length < (schema.minLength || 0) || length > (schema.maxLength ?? Infinity)) return `${where} has an invalid length`;
149
228
  if (schema.enum && !schema.enum.includes(value)) return `${where} is not an allowed value`;
150
229
  if (schema.pattern && !new RegExp(schema.pattern).test(value)) return `${where} has an invalid format`;
230
+ if (schema === SUBTASK_ID && (!Number.isSafeInteger(Number(value.slice(2)))
231
+ || String(Number(value.slice(2))) !== value.slice(2))) return `${where} must be a canonical safe subtask ID`;
232
+ if (schema.format === 'date') {
233
+ const date = new Date(`${value}T00:00:00.000Z`);
234
+ if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value || value.startsWith('0000-')) return `${where} must be a real ISO calendar date`;
235
+ }
151
236
  return null;
152
237
  }
153
- default: return `${where} has an unsupported schema`;
238
+ default: return schema.type === undefined ? null : `${where} has an unsupported schema`;
154
239
  }
155
240
  }
156
241
 
@@ -171,6 +256,21 @@ function nativeRequest(tool, args, uid) {
171
256
  if (args.if_fingerprint !== undefined) argv.push(`--if-fingerprint=${args.if_fingerprint}`);
172
257
  break;
173
258
  case 'agent.operation-status': argv.push(`--intent-id=${args.intent_id}`); break;
259
+ case 'agent.export':
260
+ if (args.include_history === true) argv.push('--include-history');
261
+ if (args.redact === true) argv.push('--redact');
262
+ break;
263
+ case 'agent.import':
264
+ argv.push('--stdin', `--mode=${args.mode}`);
265
+ if (args.dry_run === true) argv.push('--dry-run');
266
+ else argv.push(`--intent-id=${args.intent_id}`, `--plan-digest=${args.plan_digest}`, `--expected-snapshot-digest=${args.expected_snapshot_digest}`);
267
+ if (args.preserve_ids === true) argv.push('--preserve-ids');
268
+ stdin = JSON.stringify(args.archive);
269
+ break;
270
+ case 'agent.import-status': argv.push(`--intent-id=${args.intent_id}`); break;
271
+ case 'agent.import-rollback':
272
+ argv.push(`--intent-id=${args.intent_id}`, `--import-id=${args.import_id}`, `--expected-snapshot-digest=${args.expected_snapshot_digest}`);
273
+ break;
174
274
  default: {
175
275
  argv.push(`--intent-id=${args.intent_id}`, '--stdin');
176
276
  const packet = {};
@@ -194,11 +294,13 @@ function adapterError(command, code, args, extra = {}) {
194
294
  if (args && typeof args.task_id === 'string') meta.task_id = args.task_id;
195
295
  if (code === 'commit_unknown') meta.commit_state = 'unknown';
196
296
  const error = { code, message: MESSAGES[code], retryable: false };
297
+ if (code === 'commit_unknown' && command.startsWith('agent.import')) error.message =
298
+ 'Transfer outcome is unknown; retain the SAME intent_id and inspect workbite_import_status. Never automatically resend an import or rollback.';
197
299
  if (extra.reason) error.details = { reason: extra.reason };
198
300
  return { contract: MCP_CONTRACT, error, meta };
199
301
  }
200
- function parseEnvelope(text) {
201
- if (typeof text !== 'string' || Buffer.byteLength(text) > LIMITS.stdout || !text.endsWith('\n')
302
+ function parseEnvelope(text, maximum = LIMITS.stdout) {
303
+ if (typeof text !== 'string' || Buffer.byteLength(text) > maximum || !text.endsWith('\n')
202
304
  || text.indexOf('\n') !== text.length - 1) return null;
203
305
  try { const value = JSON.parse(text); return isPlain(value) ? value : null; } catch { return null; }
204
306
  }
@@ -207,6 +309,7 @@ function bound(envelope, tool, args, uid, success) {
207
309
  const meta = envelope.meta;
208
310
  if (envelope.contract !== CLI_CONTRACT || !isPlain(meta) || meta.command !== tool.command) return false;
209
311
  if (meta.intent_id !== undefined && args.intent_id !== undefined && meta.intent_id !== args.intent_id) return false;
312
+ if (tool.command.startsWith('agent.import') && args.intent_id !== undefined && success && meta.intent_id !== args.intent_id) return false;
210
313
  if (success) return isPlain(envelope.data) && envelope.error === undefined && meta.workspace_uid === uid;
211
314
  return isPlain(envelope.error) && typeof envelope.error.code === 'string' && envelope.data === undefined
212
315
  && (meta.workspace_uid === undefined || meta.workspace_uid === null || meta.workspace_uid === uid);
@@ -254,11 +357,12 @@ function createServer({ agent, bindOptions, stderr }) {
254
357
  return toolResult(adapterError(tool.command, 'workspace_mismatch', args), true);
255
358
  }
256
359
  const request = nativeRequest(tool, args, target.workspaceUid);
257
- if (request.stdin !== undefined && Buffer.byteLength(request.stdin) > LIMITS.packet) {
360
+ const maximum = tool.command === 'agent.import' ? LIMITS.transfer : LIMITS.packet;
361
+ if (request.stdin !== undefined && Buffer.byteLength(request.stdin) > maximum) {
258
362
  return toolResult(adapterError(tool.command, 'invalid_arguments', null,
259
- { reason: `packet exceeds ${LIMITS.packet} bytes` }), true);
363
+ { reason: `packet exceeds ${maximum} bytes` }), true);
260
364
  }
261
- const write = WRITES.has(tool.command);
365
+ const write = WRITES.has(tool.command) && !(tool.command === 'agent.import' && args.dry_run === true);
262
366
  let native;
263
367
  try {
264
368
  native = await agent().executeAgent(target, request.argv, request.stdin === undefined ? {} : { stdin: request.stdin });
@@ -276,7 +380,8 @@ function createServer({ agent, bindOptions, stderr }) {
276
380
  const invalid = () => toolResult(adapterError(tool.command, write ? 'commit_unknown' : 'cli_output_invalid', args), true);
277
381
  if (!isPlain(native) || ![0, 1, 2].includes(native.code)) return invalid();
278
382
  if (typeof native.stderr === 'string' && native.stderr && Buffer.byteLength(native.stderr) > LIMITS.stderr) return invalid();
279
- const envelope = parseEnvelope(native.stdout);
383
+ const envelope = parseEnvelope(native.stdout, tool.command.startsWith('agent.import') || tool.command === 'agent.export'
384
+ ? LIMITS.transfer + 8192 : LIMITS.stdout);
280
385
  if (!envelope) {
281
386
  // Exit 2 without an envelope is a usage refusal before any request was admitted.
282
387
  if (native.code === 2 && !native.stdout) return toolResult(adapterError(tool.command, 'cli_usage_refused', args), true);
@@ -284,6 +389,12 @@ function createServer({ agent, bindOptions, stderr }) {
284
389
  }
285
390
  if (!bound(envelope, tool, args, target.workspaceUid, native.code === 0)) return invalid();
286
391
  if (native.code === 0) {
392
+ // Redacting an archive here would invalidate its canonical digest. Refuse an unsafe native archive;
393
+ // export redaction belongs to the owner, which computes the digest after redacting.
394
+ if (tool.command === 'agent.export') {
395
+ try { validateArchive(envelope.data, target.workspaceUid); } catch { return invalid(); }
396
+ if (JSON.stringify(redact(envelope.data)) !== JSON.stringify(envelope.data)) return invalid();
397
+ }
287
398
  if (tool.command === 'agent.status') {
288
399
  envelope.workbite = { workspace: target.workspace, workspace_uid: target.workspaceUid,
289
400
  ...(typeof target.endpoint === 'string' ? { endpoint: target.endpoint } : {}) };
@@ -498,4 +609,4 @@ async function runMcp(options = {}) {
498
609
  return { code: 0, signal: null };
499
610
  }
500
611
 
501
- module.exports = { runMcp, TOOLS, SUPPORTED_VERSIONS, LIMITS, nativeRequest, violation };
612
+ module.exports = { runMcp, TOOLS, SUPPORTED_VERSIONS, LIMITS, nativeRequest, violation, PACKET_KEYS };