@logicseek/hello-cc 1.1.0-rc.3 → 1.1.0-rc.4

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.
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import http from 'node:http';
4
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
5
5
  import { CliError } from '../../shared/errors.mjs';
6
6
  import { redactSecrets } from '../../shared/redact.mjs';
7
7
  import { acquireFileLock } from '../../shared/file-lock.mjs';
@@ -108,10 +108,15 @@ export async function startNativeService(ctx, deps, options = {}) {
108
108
  assertPreviousOwnerExited();
109
109
  store = createNativeStore(ctx);
110
110
  mesh = deps.connect(ctx, { migrateRegistered: false });
111
+ mesh.exec(`CREATE TABLE IF NOT EXISTS native_user_submissions (
112
+ submission_id TEXT PRIMARY KEY, message_id INTEGER NOT NULL UNIQUE,
113
+ peer TEXT NOT NULL, payload_hash TEXT NOT NULL
114
+ )`);
111
115
  store.disconnected();
112
116
  } catch (error) { releaseAfterInitializationFailure(error); }
113
117
  const workers = new Map();
114
118
  const creating = new Set();
119
+ const forking = new Set();
115
120
  const operations = new Set();
116
121
  const generation = randomUUID();
117
122
  const token = randomBytes(32).toString('hex');
@@ -161,9 +166,32 @@ export async function startNativeService(ctx, deps, options = {}) {
161
166
  throw new CliError('NATIVE_OWNER_CHANGED', 'Native worker identity changed; refresh its current state before controlling it');
162
167
  }
163
168
  }
164
- function save(worker) {
165
- if (!worker.adapter || storeClosed) return;
169
+ function submissionHash(input) {
170
+ return createHash('sha256').update(JSON.stringify([
171
+ input.peer, input.from, input.taskId || null, input.body
172
+ ])).digest('hex');
173
+ }
174
+ function matchingUserSubmission(input, intent) {
175
+ const message = mesh.prepare('SELECT * FROM messages WHERE id=?').get(intent.message_id);
176
+ if (!message) throw new CliError('NATIVE_SUBMISSION_EXPIRED', 'Submission message is no longer available; do not retry it as new work');
177
+ if (intent.peer !== input.peer || intent.payload_hash !== submissionHash(input) ||
178
+ message.sender !== input.from || message.recipient !== input.peer ||
179
+ message.kind !== 'ask' || message.body !== input.body ||
180
+ (message.task_id || null) !== (input.taskId || null)) {
181
+ throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Submission ID is already used by a different message');
182
+ }
183
+ return message;
184
+ }
185
+ function workerSnapshot(worker, { checkSandbox = true } = {}) {
166
186
  const snapshot = worker.adapter.snapshot();
187
+ if (checkSandbox && snapshot.sandbox !== undefined && snapshot.sandbox !== worker.sandbox) {
188
+ throw new CliError('NATIVE_SANDBOX_MISMATCH', 'Provider sandbox does not match the native worker policy');
189
+ }
190
+ return { ...snapshot, sandbox: worker.sandbox };
191
+ }
192
+ function save(worker, options) {
193
+ if (!worker.adapter || storeClosed) return;
194
+ const snapshot = workerSnapshot(worker, options);
167
195
  tx(mesh, () => {
168
196
  assertOwner(worker);
169
197
  if (snapshot.sessionId && mesh.prepare('SELECT peer FROM peer_bindings WHERE provider=? AND (provider_session_id=? OR provider_session_name=?) AND peer<>?')
@@ -242,8 +270,15 @@ export async function startNativeService(ctx, deps, options = {}) {
242
270
  : mesh.prepare(`SELECT * FROM messages WHERE id>? AND sender<>? AND
243
271
  (recipient IS NULL OR recipient='' OR recipient='all' OR recipient=?) ORDER BY id LIMIT 1000`)
244
272
  .all(worker.lastMessageId, worker.peer, worker.peer);
273
+ const userIntent = mesh.prepare('SELECT peer,submission_id FROM native_user_submissions WHERE message_id=?');
245
274
  for (const message of messages) {
246
- if (message.sender !== worker.peer) store.queue(worker.peer, message.id, randomUUID());
275
+ if (message.sender !== worker.peer) {
276
+ const intent = userIntent.get(message.id);
277
+ if (intent && intent.peer !== worker.peer) {
278
+ throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Native submission target changed before ingestion');
279
+ }
280
+ store.queue(worker.peer, message.id, intent?.submission_id || randomUUID(), intent ? 'user' : 'peer');
281
+ }
247
282
  if (!initial) worker.lastMessageId = message.id;
248
283
  }
249
284
  }
@@ -266,7 +301,12 @@ export async function startNativeService(ctx, deps, options = {}) {
266
301
  throw new CliError('BAD_ARGS', `Worker ${key} must be a nonempty string`);
267
302
  }
268
303
  }
304
+ if (input.sandbox !== undefined && (provider !== 'codex' ||
305
+ !['read-only', 'workspace-write'].includes(input.sandbox))) {
306
+ throw new CliError('BAD_ARGS', 'Only Codex workers support read-only or workspace-write sandbox policies');
307
+ }
269
308
  if (provider === 'claude' && input.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
309
+ if (forking.has(peer)) throw new CliError('NATIVE_FORK_NOT_READY', 'This parent session is being forked');
270
310
  if (workers.has(peer) || creating.has(peer)) throw new CliError('NATIVE_WORKER_EXISTS', `Native worker is already open: ${peer}`);
271
311
  const previous = store.worker(peer);
272
312
  const fencedResume = input.resumeFence !== undefined;
@@ -288,6 +328,14 @@ export async function startNativeService(ctx, deps, options = {}) {
288
328
  sessionId = previous.session_id;
289
329
  } else if (previous?.session_id) throw new CliError('NATIVE_RESUME_REQUIRED', 'This peer has a saved session. Use --resume last or choose a new peer name.');
290
330
  if (previous && previous.provider !== provider) throw new CliError('NATIVE_SESSION_NOT_OWNED', 'Peer belongs to a different native provider');
331
+ const sandbox = provider === 'codex'
332
+ ? input.sandbox ?? (previous ? previous.sandbox : 'workspace-write') : null;
333
+ if (provider === 'codex' && !['read-only', 'workspace-write'].includes(sandbox)) {
334
+ throw new CliError('NATIVE_STATE_INVALID', 'Saved native sandbox policy is invalid');
335
+ }
336
+ if (sessionId && input.sandbox !== undefined && input.sandbox !== previous.sandbox) {
337
+ throw new CliError('NATIVE_SANDBOX_MISMATCH', 'Resume must retain the saved native session sandbox policy');
338
+ }
291
339
  if (input.cwd !== undefined && typeof input.cwd !== 'string') throw new CliError('BAD_ARGS', 'Worker cwd must be a directory');
292
340
  let cwd;
293
341
  try {
@@ -299,7 +347,7 @@ export async function startNativeService(ctx, deps, options = {}) {
299
347
  }
300
348
  const expectedCwdIdentity = captureSelectedCwdSnapshot(cwd);
301
349
  if (sessionId) assertSavedWorkerCwd(previous, expectedCwdIdentity);
302
- const worker = { peer, provider, cwd, cwdIdentity: expectedCwdIdentity,
350
+ const worker = { peer, provider, cwd, sandbox, cwdIdentity: expectedCwdIdentity,
303
351
  branch: deps.detectBranch(cwd), owner: `native:${generation}:${peer}:${randomUUID()}`,
304
352
  active: null, output: [], quarantined: false, closing: false, dispatch: null, closePromise: null };
305
353
  // Reserve identity before starting the child so installed hooks can only
@@ -327,17 +375,21 @@ export async function startNativeService(ctx, deps, options = {}) {
327
375
  worker.lastMessageId = mesh.prepare('SELECT COALESCE(MAX(id),0) AS id FROM messages').get().id;
328
376
  ingest(worker, true);
329
377
  });
330
- store.saveWorker({ peer, provider, cwd, cwdIdentity: expectedCwdIdentity, sessionId, status: 'opening' });
378
+ store.saveWorker({ peer, provider, cwd, cwdIdentity: expectedCwdIdentity, sandbox, sessionId, status: 'opening' });
331
379
  creating.add(peer);
332
380
  workers.set(peer, worker);
333
381
  try {
334
- worker.mcpCapability = (options.mcpConfigFactory || createScopedMcpConfig)({ root: ctx.root, dbPath: ctx.dbPath,
335
- peer, executorId: worker.owner, ownerIdentity: identity,
336
- binding: { transport: 'native', runtimeSessionId: peer, runtimeTarget: worker.owner } });
382
+ // A provider file sandbox cannot restrict a mutating MCP server.
383
+ if (sandbox !== 'read-only') {
384
+ worker.mcpCapability = (options.mcpConfigFactory || createScopedMcpConfig)({ root: ctx.root, dbPath: ctx.dbPath,
385
+ peer, executorId: worker.owner, ownerIdentity: identity,
386
+ binding: { transport: 'native', runtimeSessionId: peer, runtimeTarget: worker.owner } });
387
+ }
337
388
  assertSelectedCwdSnapshot(expectedCwdIdentity);
338
389
  worker.adapter = await adapterFactory(provider, { binary: input.binary || undefined, cwd,
339
390
  expectedCwdIdentity,
340
- executorId: worker.owner, interactive: true, mcpServers: { hello_cc_scoped: worker.mcpCapability.config },
391
+ executorId: worker.owner, interactive: true,
392
+ ...(worker.mcpCapability ? { mcpServers: { hello_cc_scoped: worker.mcpCapability.config } } : {}),
341
393
  env: nativeWorkerEnv(process.env, ctx, peer, worker.owner), onEvent: (event) => {
342
394
  if (worker.adapter) record(worker, event);
343
395
  } });
@@ -346,13 +398,18 @@ export async function startNativeService(ctx, deps, options = {}) {
346
398
  requireRunning();
347
399
  if (worker.closing) throw new CliError('NATIVE_WORKER_CLOSING', 'Worker was closed while opening');
348
400
  assertOwner(worker);
349
- await worker.adapter.open({ sessionId, model: input.model || undefined });
401
+ if (input.forkSessionId && !worker.adapter.capabilities.fork) throw new CliError('NATIVE_CAPABILITY_UNSUPPORTED', 'Provider does not support native fork');
402
+ await worker.adapter.open({ sessionId, ...(input.forkSessionId ? { forkSessionId: input.forkSessionId } : {}),
403
+ model: input.model || undefined, ...(sandbox ? { sandbox } : {}) });
404
+ if (input.forkSessionId && (!worker.adapter.snapshot().sessionId || worker.adapter.snapshot().sessionId === input.forkSessionId)) {
405
+ throw new CliError('NATIVE_PROTOCOL_ERROR', 'Fork did not create a distinct provider session');
406
+ }
350
407
  requireRunning();
351
408
  assertSelectedCwdSnapshot(expectedCwdIdentity);
352
409
  assertSelectedCwdSnapshot(serviceRootIdentity);
353
410
  save(worker);
354
411
  deps.addEvent(mesh, 'native.worker.opened', peer, null, { provider, session_id: worker.adapter.snapshot().sessionId || null, resumed: Boolean(sessionId) });
355
- return { peer, ...worker.adapter.snapshot() };
412
+ return { peer, ...workerSnapshot(worker) };
356
413
  } catch (error) {
357
414
  worker.closing = true;
358
415
  worker.mcpCapability?.dispose();
@@ -364,10 +421,10 @@ export async function startNativeService(ctx, deps, options = {}) {
364
421
  }
365
422
  if (!cleanupError) workers.delete(peer);
366
423
  try {
367
- if (worker.adapter) save(worker);
424
+ if (worker.adapter) save(worker, { checkSandbox: false });
368
425
  else tx(mesh, () => { assertOwner(worker); mesh.prepare("UPDATE peers SET status='blocked' WHERE id=?").run(peer); });
369
426
  } catch {}
370
- store.saveWorker({ peer, provider, cwd, ...(worker.adapter?.snapshot() || {}), status: 'error' });
427
+ store.saveWorker({ peer, provider, cwd, cwdIdentity: expectedCwdIdentity, ...(worker.adapter?.snapshot() || {}), sandbox, status: 'error' });
371
428
  if (cleanupError) throw new CliError('NATIVE_CLOSE_FAILED', 'Worker initialization failed and its owned provider exit was not confirmed',
372
429
  { uncertain: true, initialization: publicError(error), cleanup: publicError(cleanupError) });
373
430
  throw error;
@@ -379,8 +436,10 @@ export async function startNativeService(ctx, deps, options = {}) {
379
436
  const tasks = mesh.prepare("SELECT id,status,owner,title FROM tasks WHERE status NOT IN ('done','abandoned') ORDER BY id DESC LIMIT 8").all();
380
437
  const locks = mesh.prepare('SELECT resource,owner,task_id FROM locks WHERE expires_at>? ORDER BY resource LIMIT 8').all(Math.floor(Date.now() / 1000));
381
438
  return ['[hello-cc native coordination]', `peer: ${worker.peer}`, `project: ${ctx.root}`,
382
- 'Use the scoped hello_cc_scoped MCP tools for HCC state, tasks, locks, results and handoffs. Continue your owned task until done, blocked or handed off.',
383
- `Use this exact prefix for HCC commands: ${command}`,
439
+ ...(worker.sandbox === 'read-only'
440
+ ? ['This worker uses the Codex read-only file sandbox. Inspect and report only; do not modify files or execute mutating coordination commands. HCC coordination MCP tools are not attached.']
441
+ : ['Use the scoped hello_cc_scoped MCP tools for HCC state, tasks, locks, results and handoffs. Continue your owned task until done, blocked or handed off.',
442
+ `Use this exact prefix for HCC commands: ${command}`]),
384
443
  'Only the message below is newly delivered from the inbox in this turn. You may use previous turns already present in this same provider session as context. Do not independently consume or acknowledge other inbox messages.',
385
444
  delivery.origin === 'user'
386
445
  ? 'The request below was submitted by the local user through authenticated HCC CLI or Web controls. Follow it within the user scope; tool permissions still require explicit approval.'
@@ -413,12 +472,81 @@ export async function startNativeService(ctx, deps, options = {}) {
413
472
  }
414
473
  if (!worker.closing) { try { save(worker); } catch (error) { record(worker, { type: 'error', ...publicError(error) }); } }
415
474
  }
475
+ async function forkWorker(input) {
476
+ requireRunning();
477
+ if (typeof input.parent !== 'string' || !input.parent || input.parent === input.peer ||
478
+ Object.keys(input).some(key => !['parent', 'peer', 'model', 'binary', 'generation', 'owner', 'sessionId'].includes(key))) {
479
+ throw new CliError('BAD_ARGS', 'Fork requires an owned parent and a different new peer');
480
+ }
481
+ const saved = store.worker(input.parent);
482
+ const binding = mesh.prepare('SELECT * FROM peer_bindings WHERE peer=?').get(input.parent);
483
+ const parent = workers.get(input.parent);
484
+ if (!saved?.session_id || !binding || binding.transport !== 'native' || binding.provider !== saved.provider ||
485
+ binding.provider_session_id !== saved.session_id || binding.runtime_session_id !== input.parent || !binding.runtime_target) {
486
+ throw new CliError('NATIVE_SESSION_NOT_OWNED', 'Only a saved HCC-owned native session can be forked');
487
+ }
488
+ if (!['codex', 'claude'].includes(saved.provider) || (parent && !parent.adapter?.capabilities.fork)) {
489
+ throw new CliError('NATIVE_CAPABILITY_UNSUPPORTED', 'This provider does not support native fork');
490
+ }
491
+ if (forking.has(input.parent) || creating.has(input.parent) || (parent
492
+ ? parent.closing || parent.quarantined || parent.active || parent.dispatch ||
493
+ !['idle', 'ready', 'completed'].includes(parent.adapter.snapshot().status) ||
494
+ parent.adapter.snapshot().pendingApprovals?.length
495
+ : saved.status !== 'closed')) {
496
+ throw new CliError('NATIVE_FORK_NOT_READY', 'Finish or resolve the parent operation before forking');
497
+ }
498
+ if (parent) { assertRequestedOwner(parent, input); assertSelectedCwdSnapshot(parent.cwdIdentity); ingest(parent); }
499
+ else if ((input.owner !== undefined && input.owner !== binding.runtime_target) ||
500
+ (input.sessionId !== undefined && input.sessionId !== saved.session_id) ||
501
+ (input.generation !== undefined && input.generation !== generation)) {
502
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Saved parent identity changed');
503
+ }
504
+ if (store.db.prepare("SELECT id FROM deliveries WHERE peer=? AND state NOT IN ('completed','failed') LIMIT 1").get(input.parent)) {
505
+ throw new CliError('NATIVE_FORK_NOT_READY', 'Parent has queued or unresolved deliveries');
506
+ }
507
+ if (saved.provider === 'codex' && !['read-only', 'workspace-write'].includes(saved.sandbox)) {
508
+ throw new CliError('NATIVE_STATE_INVALID', 'Saved parent sandbox policy is invalid');
509
+ }
510
+ if (parent && workerSnapshot(parent).sandbox !== saved.sandbox) {
511
+ throw new CliError('NATIVE_SANDBOX_MISMATCH', 'Parent sandbox policy changed');
512
+ }
513
+ const cwdIdentity = captureSelectedCwdSnapshot(saved.cwd);
514
+ assertSavedWorkerCwd(saved, cwdIdentity);
515
+ forking.add(input.parent);
516
+ try {
517
+ const child = await createWorker({ peer: input.peer, provider: saved.provider, cwd: saved.cwd,
518
+ model: input.model, binary: input.binary, forkSessionId: saved.session_id,
519
+ ...(saved.provider === 'codex' ? { sandbox: saved.sandbox } : {}) });
520
+ try {
521
+ requireRunning();
522
+ const current = mesh.prepare('SELECT * FROM peer_bindings WHERE peer=?').get(input.parent);
523
+ if (!current || current.transport !== 'native' || current.provider !== saved.provider ||
524
+ current.runtime_session_id !== input.parent || current.runtime_target !== binding.runtime_target ||
525
+ current.provider_session_id !== saved.session_id) {
526
+ throw new CliError('NATIVE_OWNER_CHANGED', 'Parent identity changed while forking');
527
+ }
528
+ if (saved.provider === 'codex' && store.worker(input.parent)?.sandbox !== saved.sandbox) {
529
+ throw new CliError('NATIVE_SANDBOX_MISMATCH', 'Parent sandbox policy changed while forking');
530
+ }
531
+ assertSelectedCwdSnapshot(cwdIdentity);
532
+ deps.addEvent(mesh, 'native.worker.forked', input.peer, null,
533
+ { provider: saved.provider, parent_peer: input.parent, parent_session_id: saved.session_id, session_id: child.sessionId });
534
+ return { ...child, parent: input.parent, parentSessionId: saved.session_id };
535
+ } catch (error) {
536
+ try { await closeWorker(input.peer); }
537
+ catch (cleanup) {
538
+ throw aggregateCleanupFailure(error, cleanup, 'Native fork validation and child cleanup both failed', { phase: 'native-fork' });
539
+ }
540
+ throw error;
541
+ }
542
+ } finally { forking.delete(input.parent); }
543
+ }
416
544
  async function poll() {
417
545
  if (stopping) return;
418
546
  try { assertOwnership(); } catch { return; }
419
547
  for (const worker of workers.values()) {
420
548
  try {
421
- if (worker.closing || creating.has(worker.peer)) continue;
549
+ if (worker.closing || creating.has(worker.peer) || forking.has(worker.peer)) continue;
422
550
  save(worker);
423
551
  ingest(worker);
424
552
  if (worker.active || worker.dispatch || worker.quarantined) continue;
@@ -454,7 +582,7 @@ export async function startNativeService(ctx, deps, options = {}) {
454
582
  // Factories can resolve after close begins. Confirm their cleanup even
455
583
  // when initialization itself rejected; its cleanup may also have failed.
456
584
  if (worker.adapter && worker.adapter !== adapterAtClose) await worker.adapter.close();
457
- try { save(worker); } catch (error) { if (!['NATIVE_PEER_IN_USE', 'NATIVE_RUNTIME_OWNERSHIP_LOST'].includes(error.code)) throw error; }
585
+ try { save(worker, { checkSandbox: false }); } catch (error) { if (!['NATIVE_PEER_IN_USE', 'NATIVE_RUNTIME_OWNERSHIP_LOST'].includes(error.code)) throw error; }
458
586
  if (!creating.has(peer)) workers.delete(peer);
459
587
  return { peer, status: 'closed' };
460
588
  })();
@@ -463,7 +591,7 @@ export async function startNativeService(ctx, deps, options = {}) {
463
591
  worker.quarantined = true;
464
592
  worker.closePromise = null;
465
593
  if (!storeClosed) store.saveWorker({ peer: worker.peer, provider: worker.provider, cwd: worker.cwd,
466
- ...worker.adapter?.snapshot(), status: 'uncertain' });
594
+ ...worker.adapter?.snapshot(), sandbox: worker.sandbox, status: 'uncertain' });
467
595
  console.error(JSON.stringify(publicError(error)));
468
596
  });
469
597
  return worker.closePromise;
@@ -480,10 +608,11 @@ export async function startNativeService(ctx, deps, options = {}) {
480
608
  let result;
481
609
  if (request.method === 'POST' && !['/down', '/close'].includes(url.pathname)) requireRunning();
482
610
  if (request.method === 'GET' && url.pathname === '/status') {
483
- result = { generation, root: fs.realpathSync(ctx.root), meshDb: path.resolve(ctx.dbPath), pid: process.pid, stopping, shutdown_error: shutdownError,
611
+ result = { generation, sandboxPolicyVersion: 1, root: fs.realpathSync(ctx.root), meshDb: path.resolve(ctx.dbPath), pid: process.pid, stopping, shutdown_error: shutdownError,
484
612
  workers: store.workers().map((row) => ({ ...row, owned: workers.has(row.peer),
485
613
  owner: workers.get(row.peer)?.owner || null,
486
614
  ...(workers.has(row.peer) && workers.get(row.peer).adapter ? { capabilities: workers.get(row.peer).adapter.snapshot().capabilities,
615
+ sandboxVerified: workers.get(row.peer).adapter.snapshot().sandboxVerified,
487
616
  quarantined: workers.get(row.peer).quarantined } : {}) })) };
488
617
  } else if (request.method === 'GET' && /^\/workers\/[^/]+\/(state|account)$/.test(url.pathname)) {
489
618
  const peer = decodeURIComponent(url.pathname.split('/')[2]);
@@ -506,9 +635,9 @@ export async function startNativeService(ctx, deps, options = {}) {
506
635
  await worker.adapter.readAccount();
507
636
  assertRequestedOwner(worker, identity);
508
637
  }
509
- const snapshot = worker.adapter.snapshot();
638
+ const snapshot = workerSnapshot(worker);
510
639
  result = { root: fs.realpathSync(ctx.root), meshDb: path.resolve(ctx.dbPath), generation,
511
- peer, provider: worker.provider, owner: worker.owner, cwd: worker.cwd, snapshot,
640
+ peer, provider: worker.provider, owner: worker.owner, cwd: worker.cwd, sandbox: worker.sandbox, snapshot,
512
641
  quarantined: worker.quarantined, closing: worker.closing,
513
642
  active_delivery: worker.active ? { message_id: worker.active.message.id,
514
643
  submission_id: worker.active.delivery.submission_id, turn_id: worker.active.turnId } : null,
@@ -521,11 +650,15 @@ export async function startNativeService(ctx, deps, options = {}) {
521
650
  } else if (request.method === 'POST' && url.pathname === '/workers') {
522
651
  const input = await jsonBody(request);
523
652
  requireRunning();
653
+ if (input.forkSessionId !== undefined) throw new CliError('BAD_ARGS', 'Use the guarded /fork endpoint');
524
654
  result = await createWorker(input);
655
+ } else if (request.method === 'POST' && url.pathname === '/fork') {
656
+ result = await track(forkWorker(await jsonBody(request)));
525
657
  } else if (request.method === 'POST' && url.pathname === '/send') {
526
658
  const input = await jsonBody(request);
527
659
  requireRunning();
528
660
  const worker = workers.get(input.peer);
661
+ if (forking.has(input.peer)) throw new CliError('NATIVE_FORK_NOT_READY', 'Wait for the parent fork to finish');
529
662
  if (!worker || worker.closing || creating.has(input.peer)) throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Target native worker is not open');
530
663
  if (worker.quarantined) throw new CliError('NATIVE_WORKER_UNCERTAIN', 'Worker needs inspection and an explicit close/resume before more submissions');
531
664
  if (typeof input.body !== 'string' || !input.body.trim() || typeof input.from !== 'string' || !input.from.trim() ||
@@ -549,11 +682,28 @@ export async function startNativeService(ctx, deps, options = {}) {
549
682
  return;
550
683
  }
551
684
  }
552
- const messageId = deps.sendMessage(mesh, input.from, input.peer, input.taskId || null, 'ask', input.body);
553
- // The authenticated control endpoint supplies user intent; bus ingestion
554
- // always queues peer origin. Caller-provided names/fields cannot promote bus data.
555
- const delivery = store.queue(input.peer, messageId, input.submissionId || randomUUID(), 'user');
556
- result = { message_id: messageId, submission_id: delivery.submission_id, state: delivery.state };
685
+ result = (() => {
686
+ const submissionId = input.submissionId || randomUUID();
687
+ let intent = mesh.prepare('SELECT * FROM native_user_submissions WHERE submission_id=?').get(submissionId);
688
+ if (intent) matchingUserSubmission(input, intent);
689
+ else {
690
+ // The message and its retry key commit together in mesh.db. The
691
+ // state.db delivery is a repairable projection, not a cross-DB commit.
692
+ const messageId = tx(mesh, () => {
693
+ const id = deps.sendMessage(mesh, input.from, input.peer, input.taskId || null, 'ask', input.body);
694
+ mesh.prepare(`INSERT INTO native_user_submissions(submission_id,message_id,peer,payload_hash)
695
+ VALUES (?,?,?,?)`).run(submissionId, id, input.peer, submissionHash(input));
696
+ return id;
697
+ });
698
+ intent = { submission_id: submissionId, message_id: messageId };
699
+ options.afterUserSubmissionMeshCommit?.({ submissionId, messageId });
700
+ }
701
+ const delivery = store.queue(input.peer, intent.message_id, submissionId, 'user');
702
+ if (!delivery || delivery.submission_id !== submissionId || delivery.origin !== 'user') {
703
+ throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Submission delivery conflicts with the durable message intent');
704
+ }
705
+ return { message_id: intent.message_id, submission_id: submissionId, state: delivery.state };
706
+ })();
557
707
  } else if (request.method === 'POST' && url.pathname === '/interrupt') {
558
708
  const input = await jsonBody(request);
559
709
  requireRunning();
@@ -568,13 +718,24 @@ export async function startNativeService(ctx, deps, options = {}) {
568
718
  const worker = workers.get(input.peer);
569
719
  if (!worker?.adapter || worker.closing || creating.has(input.peer)) throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Target native worker is not open');
570
720
  assertRequestedOwner(worker, input);
571
- if (input.executorId !== worker.owner || !worker.adapter.capabilities.approvals || !worker.adapter.respond) {
721
+ const snapshot = workerSnapshot(worker);
722
+ const matches = (snapshot.pendingApprovals || []).filter(pending => pending.requestId === input.requestId &&
723
+ pending.executorId === worker.owner && pending.sessionId === input.sessionId && pending.turnId === input.turnId);
724
+ const pending = matches.length === 1 ? matches[0] : null;
725
+ // Select permission from the adapter's exact pending request, never a
726
+ // caller-supplied kind. Read-only questions do not grant tool approvals.
727
+ const canRespond = pending?.kind === 'userInput'
728
+ ? worker.adapter.capabilities.userInput : worker.adapter.capabilities.approvals;
729
+ if (input.executorId !== worker.owner || !pending || !canRespond || !worker.adapter.respond ||
730
+ (input.kind !== undefined && input.kind !== pending.kind) ||
731
+ (pending.kind === 'userInput' && ['permissions', 'scope', 'content'].some(key => input[key] !== undefined))) {
572
732
  throw new CliError('NATIVE_APPROVAL_MISMATCH', 'Interaction belongs to another executor or provider has no responder');
573
733
  }
574
734
  result = await track(Promise.resolve(worker.adapter.respond(input)));
575
735
  save(worker);
576
736
  } else if (request.method === 'POST' && url.pathname === '/close') {
577
737
  const input = await jsonBody(request);
738
+ if (forking.has(input.peer)) throw new CliError('NATIVE_FORK_NOT_READY', 'Wait for the parent fork to finish');
578
739
  if (input.owner !== undefined || input.generation !== undefined || input.provider !== undefined || input.sessionId !== undefined) {
579
740
  const worker = workers.get(input.peer);
580
741
  if (!worker) throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Target native worker is not open');
@@ -130,7 +130,7 @@ export function createNativeStore(ctx) {
130
130
  PRAGMA foreign_keys = ON;
131
131
  CREATE TABLE IF NOT EXISTS workers (
132
132
  peer TEXT PRIMARY KEY, provider TEXT NOT NULL, session_id TEXT,
133
- cwd TEXT NOT NULL, cwd_identity TEXT, status TEXT NOT NULL, updated_at INTEGER NOT NULL
133
+ cwd TEXT NOT NULL, cwd_identity TEXT, status TEXT NOT NULL, updated_at INTEGER NOT NULL, sandbox TEXT
134
134
  );
135
135
  CREATE TABLE IF NOT EXISTS deliveries (
136
136
  id INTEGER PRIMARY KEY, peer TEXT NOT NULL, message_id INTEGER NOT NULL,
@@ -150,6 +150,19 @@ export function createNativeStore(ctx) {
150
150
  if (!db.prepare('PRAGMA table_info(workers)').all().some(column => column.name === 'cwd_identity')) {
151
151
  db.exec('ALTER TABLE workers ADD COLUMN cwd_identity TEXT');
152
152
  }
153
+ db.exec('BEGIN IMMEDIATE');
154
+ try {
155
+ if (!db.prepare('PRAGMA table_info(workers)').all().some(column => column.name === 'sandbox')) {
156
+ // Only the legacy schema implies the former workspace-write default.
157
+ // A missing value in the new schema must never escalate on reopen.
158
+ db.exec(`ALTER TABLE workers ADD COLUMN sandbox TEXT;
159
+ UPDATE workers SET sandbox='workspace-write' WHERE provider='codex';`);
160
+ }
161
+ db.exec('COMMIT');
162
+ } catch (error) {
163
+ try { db.exec('ROLLBACK'); } catch { /* Preserve the migration failure. */ }
164
+ throw error;
165
+ }
153
166
  } catch (error) {
154
167
  try { db.close(); } catch { /* Preserve the initialization failure. */ }
155
168
  throw error;
@@ -160,14 +173,25 @@ export function createNativeStore(ctx) {
160
173
  worker(peer) { return db.prepare('SELECT * FROM workers WHERE peer = ?').get(peer) || null; },
161
174
  workers() { return db.prepare('SELECT * FROM workers ORDER BY peer').all(); },
162
175
  saveWorker(worker) {
176
+ const previous = this.worker(worker.peer);
177
+ const sandbox = worker.provider === 'codex'
178
+ ? worker.sandbox === undefined ? previous ? previous.sandbox : 'workspace-write' : worker.sandbox
179
+ : worker.sandbox ?? null;
180
+ if ((worker.provider === 'codex' && !['read-only', 'workspace-write'].includes(sandbox)) ||
181
+ (worker.provider !== 'codex' && sandbox !== null)) {
182
+ throw new CliError('BAD_ARGS', 'Only Codex workers support read-only or workspace-write sandbox policies');
183
+ }
184
+ if (previous?.provider === worker.provider && previous.session_id && previous.sandbox !== sandbox) {
185
+ throw new CliError('NATIVE_SANDBOX_MISMATCH', 'A saved native session cannot change its sandbox policy');
186
+ }
163
187
  const cwdIdentity = worker.cwdIdentity ? JSON.stringify({ version: 1,
164
188
  requested: worker.cwdIdentity.requested, canonical: worker.cwdIdentity.canonical,
165
189
  identity: worker.cwdIdentity.identity }) : null;
166
- db.prepare(`INSERT INTO workers(peer, provider, session_id, cwd, cwd_identity, status, updated_at) VALUES (?,?,?,?,?,?,?)
190
+ db.prepare(`INSERT INTO workers(peer, provider, session_id, cwd, cwd_identity, status, updated_at, sandbox) VALUES (?,?,?,?,?,?,?,?)
167
191
  ON CONFLICT(peer) DO UPDATE SET provider=excluded.provider, session_id=COALESCE(excluded.session_id,workers.session_id),
168
192
  cwd=excluded.cwd, cwd_identity=COALESCE(excluded.cwd_identity,workers.cwd_identity),
169
- status=excluded.status, updated_at=excluded.updated_at`)
170
- .run(worker.peer, worker.provider, worker.sessionId || null, worker.cwd, cwdIdentity, worker.status, now());
193
+ status=excluded.status, updated_at=excluded.updated_at, sandbox=excluded.sandbox`)
194
+ .run(worker.peer, worker.provider, worker.sessionId || null, worker.cwd, cwdIdentity, worker.status, now(), sandbox);
171
195
  },
172
196
  queue(peer, messageId, submissionId, origin = 'peer') {
173
197
  if (!['peer', 'user'].includes(origin)) throw new CliError('BAD_ARGS', 'Unknown native message origin');
@@ -198,6 +198,13 @@ import { TERMINAL_THEMES } from '../ui-preferences.mjs';
198
198
  const searchAddon = new SearchAddon.SearchAddon({ highlightLimit: 1000 });
199
199
  term.loadAddon(searchAddon);
200
200
  term.open(document.getElementById('terminal'));
201
+ // xterm creates this field at open; identify it and keep autofill disabled.
202
+ const terminalInput = term.textarea;
203
+ if (terminalInput) {
204
+ terminalInput.id = 'terminalInput';
205
+ terminalInput.name = 'terminalInput';
206
+ terminalInput.autocomplete = 'off';
207
+ }
201
208
 
202
209
  function resizeTerm() {
203
210
  const el = document.getElementById('terminal');
@@ -1934,7 +1941,8 @@ import { TERMINAL_THEMES } from '../ui-preferences.mjs';
1934
1941
  };
1935
1942
  installCommandPalette();
1936
1943
  window.hccWorkspaceHost = {
1937
- embedded:paneMode, app:appEl, primary:document.getElementById('workspacePrimaryPane'),
1944
+ embedded:paneMode, paneAllowed:document.body.dataset.workspacePaneAllowed === 'true',
1945
+ app:appEl, primary:document.getElementById('workspacePrimaryPane'),
1938
1946
  project:() => currentProject, projectIdentity:() => projectRequests.rootIdentity(),
1939
1947
  active:() => active, sessions:() => sessions, ready:() => sessionsLoaded,
1940
1948
  esc, showTerminal:() => setMobileView('terminal'), layoutChanged:() => { syncPanelAccess(); requestAnimationFrame(resizeTerm); }
@@ -156,7 +156,7 @@ export function createHttpRoutes(deps) {
156
156
  sendWebHtml(res, renderWebLogin);
157
157
  return;
158
158
  }
159
- sendWebHtml(res, renderWebIndex);
159
+ sendWebHtml(res, nonce => renderWebIndex({ nonce, paneAllowed: hasCookie }));
160
160
  return;
161
161
  }
162
162
  if (req.method === 'GET' && url.pathname === '/assets/xterm.js') {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { randomUUID } from 'node:crypto';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import { CliError } from '../shared/errors.mjs';
6
6
  import { redactSecrets } from '../shared/redact.mjs';
@@ -32,6 +32,17 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
32
32
 
33
33
  function projectKey(ctx) { return `${ctx.root}\0${path.resolve(ctx.dbPath)}`; }
34
34
 
35
+ function sandboxRuntimeOptions(ctx, provider, runtime) {
36
+ if (provider !== 'codex') return undefined;
37
+ if (runtime?.sandboxPolicyVersion !== 1) {
38
+ throw new CliError('NATIVE_SANDBOX_RUNTIME_UNSUPPORTED',
39
+ 'This native runtime cannot preserve Codex sandbox policies. Stop it after its work has finished, then start it with this HCC version. No worker was created.');
40
+ }
41
+ assertProject(ctx, runtime);
42
+ if (!runtime.generation.trim()) throw new CliError('NATIVE_SCOPE_CHANGED', 'Native runtime has no verifiable generation');
43
+ return { expectedGeneration: runtime.generation };
44
+ }
45
+
35
46
  function workerCwd(ctx, value) {
36
47
  if (value !== undefined && (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value))) {
37
48
  throw new CliError('BAD_REQUEST', 'Native worker cwd must be a nonempty directory path');
@@ -149,14 +160,15 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
149
160
  if (!entry.resumable) throw new CliError('NATIVE_RESUME_NOT_READY', entry.resumeDetail);
150
161
  const existing = sessions.get(key);
151
162
  if (existing && existing.type !== 'native') throw new CliError('NATIVE_PEER_IN_USE', 'Another session owns this peer');
152
- await ensureRuntime(ctx);
163
+ const runtime = await ensureRuntime(ctx);
153
164
  if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped before worker restore');
165
+ const requestOptions = sandboxRuntimeOptions(ctx, saved.provider, runtime);
154
166
  entry = historyWorker(ctx, readNativeWorkerHistory(ctx, peer).worker, await historyRuntime(ctx));
155
167
  if (entry.owner !== input.owner || entry.sessionId !== input.sessionId) throw new CliError('NATIVE_OWNER_CHANGED', 'Saved worker identity changed; refresh history');
156
168
  if (!entry.resumable) throw new CliError('NATIVE_RESUME_NOT_READY', entry.resumeDetail);
157
169
  try {
158
170
  receipt = await nativeApi(ctx, 'POST', '/workers', { peer, provider: saved.provider, cwd: workerCwd(ctx, saved.cwd),
159
- resume: 'last', resumeFence: { owner: input.owner, sessionId: input.sessionId } });
171
+ resume: 'last', resumeFence: { owner: input.owner, sessionId: input.sessionId } }, requestOptions);
160
172
  } catch (error) {
161
173
  let ownerChanged = true;
162
174
  try { ownerChanged = historyWorker(ctx, saved, null).owner !== input.owner; } catch {}
@@ -212,15 +224,16 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
212
224
  assertNewPeer(ctx, peer);
213
225
  creating.add(key);
214
226
  try {
215
- await ensureRuntime(ctx);
227
+ const runtime = await ensureRuntime(ctx);
216
228
  if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web stopped before worker creation');
217
229
  assertNewPeer(ctx, peer);
218
230
  workerCwd(ctx, cwd);
231
+ const requestOptions = sandboxRuntimeOptions(ctx, input.kind, runtime);
219
232
  let receipt;
220
233
  try {
221
234
  // Exactly one create attempt. A lost response is not safe to replay.
222
235
  receipt = await nativeApi(ctx, 'POST', '/workers', { peer, provider: input.kind, cwd,
223
- ...(input.model === undefined ? {} : { model: input.model }) });
236
+ ...(input.model === undefined ? {} : { model: input.model }) }, requestOptions);
224
237
  } catch (error) {
225
238
  if (error.extra?.uncertain) throw new CliError('NATIVE_CREATE_UNCONFIRMED',
226
239
  `Creation of ${peer} is unconfirmed. Refresh the Agent list or inspect native status before creating another worker`,
@@ -308,6 +321,8 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
308
321
  !value.snapshot || value.snapshot.provider !== value.provider ||
309
322
  typeof value.snapshot.status !== 'string' ||
310
323
  (value.snapshot.sessionId != null && typeof value.snapshot.sessionId !== 'string') ||
324
+ (value.snapshot.sandbox != null && !['read-only', 'workspace-write'].includes(value.snapshot.sandbox)) ||
325
+ (value.snapshot.sandboxVerified !== undefined && typeof value.snapshot.sandboxVerified !== 'boolean') ||
311
326
  !Array.isArray(value.events) || !Array.isArray(value.deliveries) ||
312
327
  value.events.some((event) => event.peer !== peer || !Number.isSafeInteger(event.id) || event.id < 1) ||
313
328
  value.deliveries.some((delivery) => delivery.peer !== peer)) {
@@ -393,6 +408,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
393
408
  status: value.snapshot.status, cwd: value.cwd, capabilities: value.snapshot.capabilities || {},
394
409
  metrics: value.snapshot.metrics || null, runtimeMetadata: value.snapshot.runtimeMetadata || null,
395
410
  account: value.snapshot.account || null, pendingApprovals: value.snapshot.pendingApprovals || [],
411
+ sandbox: value.snapshot.sandbox || null, sandboxVerified: value.snapshot.sandboxVerified === true,
396
412
  quarantined: Boolean(value.quarantined), closing: Boolean(value.closing),
397
413
  activeDelivery: value.active_delivery || null, events: history.slice(-HISTORY_LIMIT),
398
414
  deliveries: value.deliveries.slice(0, 100), eventCursor: cursor,
@@ -516,6 +532,9 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
516
532
  if (!text || text.length > 100000 || typeof submissionId !== 'string' || !/^[A-Za-z0-9_-]{8,100}$/.test(submissionId)) {
517
533
  throw new CliError('BAD_REQUEST', 'A message and a unique submissionId are required');
518
534
  }
535
+ if (input.retry !== undefined && typeof input.retry !== 'boolean') {
536
+ throw new CliError('BAD_REQUEST', 'Native submission retry must be explicit');
537
+ }
519
538
  }
520
539
  try { await readState(session); }
521
540
  catch (error) {
@@ -535,6 +554,15 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
535
554
  return applyState(session, validateState(session.ctx, session.peerId, value));
536
555
  }
537
556
  if (session.status !== 'running') throw new CliError('NATIVE_WORKER_NOT_FOUND', 'Native worker is not open');
557
+ if (session.kind === 'codex' && ['send', 'respond'].includes(action)) {
558
+ if (!['read-only', 'workspace-write'].includes(session.nativeState.sandbox)) {
559
+ throw new CliError('NATIVE_SANDBOX_RUNTIME_UNSUPPORTED',
560
+ 'This native worker does not report its Codex sandbox policy. Finish its work and restart the native runtime with this HCC version before sending or responding.');
561
+ }
562
+ if (session.nativeState.sandbox === 'read-only' && !session.nativeState.sandboxVerified) {
563
+ throw new CliError('NATIVE_SANDBOX_UNVERIFIED', 'The Codex provider has not confirmed this read-only worker policy');
564
+ }
565
+ }
538
566
  if (typeof input.authorizeMutation === 'function') input.authorizeMutation();
539
567
  const identity = { peer: session.peerId, generation: session.nativeIdentity.generation,
540
568
  owner: session.nativeIdentity.owner, provider: session.kind, sessionId: session.nativeIdentity.sessionId };
@@ -544,17 +572,29 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
544
572
  if (action === 'interrupt' && !session.nativeState.capabilities.interrupt) {
545
573
  throw new CliError('NATIVE_CAPABILITY_UNSUPPORTED', 'Provider does not support interrupt');
546
574
  }
547
- const pending = { submission_id: submissionId, owner: identity.owner, generation: identity.generation,
548
- provider: identity.provider, provider_session_id: identity.sessionId };
575
+ const pending = { submission_id: submissionId, peer: identity.peer,
576
+ owner: identity.owner, generation: identity.generation,
577
+ provider: identity.provider, provider_session_id: identity.sessionId,
578
+ ...(action === 'send' ? { body_hash: createHash('sha256').update(text).digest('hex') } : {}) };
549
579
  if (action === 'send') {
550
580
  const db = connectWebProject(session.ctx);
551
581
  try {
552
582
  tx(db, () => {
553
- if (db.prepare(`SELECT id FROM events WHERE type='native.web.submission.pending'
554
- AND json_extract(payload, '$.submission_id')=? LIMIT 1`).get(submissionId)) {
555
- throw new CliError('SUBMISSION_EXISTS', 'This submission was already attempted; inspect native deliveries before sending again');
583
+ const previous = db.prepare(`SELECT payload FROM events WHERE type='native.web.submission.pending'
584
+ AND json_extract(payload, '$.submission_id')=? ORDER BY id DESC LIMIT 1`).get(submissionId);
585
+ if (previous) {
586
+ if (input.retry !== true) {
587
+ throw new CliError('SUBMISSION_EXISTS', 'This submission was already attempted; inspect native deliveries before sending again');
588
+ }
589
+ let saved;
590
+ try { saved = JSON.parse(previous.payload); } catch { /* Reject below. */ }
591
+ if (!saved || Object.keys(pending).some(key => saved[key] !== pending[key])) {
592
+ throw new CliError('NATIVE_SUBMISSION_MISMATCH', 'Retry must keep the original worker, executor and message');
593
+ }
594
+ } else {
595
+ if (input.retry === true) throw new CliError('NATIVE_SUBMISSION_UNVERIFIED', 'No durable pending submission exists for this retry');
596
+ addEvent(db, 'native.web.submission.pending', session.peerId, null, pending);
556
597
  }
557
- addEvent(db, 'native.web.submission.pending', session.peerId, null, pending);
558
598
  });
559
599
  } finally { db.close(); }
560
600
  }
@@ -566,12 +606,12 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
566
606
  : action === 'interrupt' ? { turnId: input.turnId || session.nativeState.turnId || undefined }
567
607
  : action === 'respond' ? { executorId: input.executorId, requestId: input.requestId, turnId: input.turnId,
568
608
  decision: input.decision, permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content } : {})
569
- });
609
+ }, { expectedGeneration: identity.generation });
570
610
  if (action === 'send') {
571
611
  if (!Number.isSafeInteger(result?.message_id) || result.submission_id !== submissionId || typeof result.state !== 'string') {
572
612
  throw new CliError('NATIVE_RESPONSE_INVALID', 'Native queue receipt is invalid', { uncertain: true });
573
613
  }
574
- receiptEvent(session, 'native.web.submission.queued', { ...pending, message_id: result.message_id, state: result.state });
614
+ receiptEvent(session, 'native.web.submission.queued', { ...pending, message_id: result.message_id, state: result.state, ...(input.retry === true ? { retry: true } : {}) });
575
615
  }
576
616
  current(session);
577
617
  if (action === 'respond') { await readState(session); current(session); }