@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.
- package/CHANGELOG.md +39 -0
- package/docs/native.md +81 -2
- package/docs/native.zh-CN.md +68 -2
- package/lib/cli/commands/native.mjs +42 -8
- package/lib/integrations/native/claude.mjs +29 -3
- package/lib/integrations/native/codex.mjs +38 -9
- package/lib/integrations/native/dsh-acp.mjs +10 -0
- package/lib/integrations/native/interactions.mjs +16 -3
- package/lib/runtime/native/client.mjs +8 -1
- package/lib/runtime/native/service.mjs +189 -28
- package/lib/runtime/native/store.mjs +28 -4
- package/lib/web/browser/core.mjs +9 -1
- package/lib/web/http-routes.mjs +1 -1
- package/lib/web/native-sessions.mjs +53 -13
- package/lib/web/ui-i18n.mjs +6 -0
- package/lib/web/ui-native.mjs +22 -2
- package/lib/web/ui-template.mjs +2 -2
- package/lib/web/ui-workspace.mjs +6 -2
- package/package.json +1 -1
- package/scripts/helpers/gc-clock-observation.mjs +33 -0
- package/scripts/native-installed-acceptance.mjs +80 -19
- package/scripts/regression.mjs +18 -2
- package/scripts/web-native-retry-checks.mjs +177 -0
- package/scripts/web-workbench-acceptance.mjs +7 -4
|
@@ -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
|
|
165
|
-
|
|
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)
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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');
|
package/lib/web/browser/core.mjs
CHANGED
|
@@ -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,
|
|
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); }
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -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,
|
|
548
|
-
|
|
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
|
-
|
|
554
|
-
AND json_extract(payload, '$.submission_id')=? LIMIT 1`).get(submissionId)
|
|
555
|
-
|
|
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); }
|