@milpa/comal 0.1.6 → 0.2.0

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.
Files changed (137) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/backend/contracts/remote-workspace-v1.schema.json +287 -0
  4. package/governance/chrome-task-v10.json +323 -0
  5. package/governance/chrome-task-v11.json +355 -0
  6. package/governance/chrome-task-v12.json +387 -0
  7. package/governance/chrome-task-v13.json +419 -0
  8. package/governance/chrome-task-v3.json +99 -0
  9. package/governance/chrome-task-v4.json +131 -0
  10. package/governance/chrome-task-v5.json +163 -0
  11. package/governance/chrome-task-v6.json +195 -0
  12. package/governance/chrome-task-v7.json +227 -0
  13. package/governance/chrome-task-v8.json +259 -0
  14. package/governance/chrome-task-v9.json +291 -0
  15. package/governance/source-task-v1.json +1 -0
  16. package/governance/source-task-v2.json +1 -0
  17. package/package.json +15 -1
  18. package/src/agent-recovery-proof.mjs +12 -1
  19. package/src/agent.mjs +94 -30
  20. package/src/audit.mjs +21 -6
  21. package/src/auto-guard.mjs +48 -1
  22. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  23. package/src/chrome-recovery-proof.mjs +6 -2
  24. package/src/chrome-source-syntax.mjs +93 -0
  25. package/src/cli.mjs +161 -56
  26. package/src/codex-provider.mjs +3 -2
  27. package/src/connection-settings.mjs +204 -0
  28. package/src/context-pressure.mjs +2 -1
  29. package/src/durable-agent-runs.mjs +5 -11
  30. package/src/event-channels.mjs +8 -0
  31. package/src/file-recovery-proof.mjs +11 -3
  32. package/src/file-recovery.mjs +24 -0
  33. package/src/gemini-provider.mjs +6 -1
  34. package/src/git-agent-revision.mjs +12 -0
  35. package/src/git-agent-workspaces.mjs +143 -0
  36. package/src/git-integration-lineage.mjs +20 -0
  37. package/src/git-integration-operations.mjs +200 -0
  38. package/src/git-integration-recovery-proof.mjs +70 -0
  39. package/src/git-page-cli-bridge.mjs +61 -0
  40. package/src/git-page-evidence.mjs +30 -0
  41. package/src/git-page-identity.mjs +10 -0
  42. package/src/git-page-proof.mjs +96 -0
  43. package/src/git-page-settlement.mjs +21 -0
  44. package/src/git-page-verification.mjs +134 -0
  45. package/src/git-plumbing.mjs +212 -0
  46. package/src/git-process-worker.mjs +50 -0
  47. package/src/git-source.mjs +110 -0
  48. package/src/git-workspaces.mjs +519 -0
  49. package/src/goals.mjs +13 -7
  50. package/src/house.mjs +246 -20
  51. package/src/i18n/cli-es.mjs +4 -0
  52. package/src/i18n/es.mjs +35 -0
  53. package/src/judges.mjs +27 -11
  54. package/src/mcp-runtime.mjs +15 -3
  55. package/src/model-controls.mjs +11 -1
  56. package/src/model-deadline.mjs +4 -3
  57. package/src/native-refusal.mjs +38 -0
  58. package/src/onboarding.mjs +88 -24
  59. package/src/openai-provider.mjs +14 -7
  60. package/src/operations.mjs +9 -2
  61. package/src/output-recovery.mjs +48 -0
  62. package/src/peer-tools.mjs +18 -8
  63. package/src/permission-controls.mjs +232 -0
  64. package/src/permission-rules.mjs +312 -0
  65. package/src/permissions.mjs +113 -30
  66. package/src/plugin-catalog.mjs +84 -0
  67. package/src/plugin-config.mjs +48 -6
  68. package/src/plugin-controls.mjs +332 -0
  69. package/src/plugin-notices.mjs +88 -0
  70. package/src/plugin-trust.mjs +104 -0
  71. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  72. package/src/plugins/chrome-lab/client.mjs +103 -25
  73. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  74. package/src/plugins/chrome-lab/index.mjs +116 -26
  75. package/src/plugins/chrome-lab/project.mjs +30 -2
  76. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  77. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  78. package/src/plugins/open-relay/actions.mjs +205 -0
  79. package/src/plugins/open-relay/bound.mjs +47 -0
  80. package/src/plugins/open-relay/client.mjs +158 -0
  81. package/src/plugins/open-relay/dossier.mjs +119 -0
  82. package/src/plugins/open-relay/index.mjs +153 -0
  83. package/src/plugins/open-relay/lookup.mjs +45 -0
  84. package/src/plugins/open-relay/notices.mjs +79 -0
  85. package/src/plugins/open-relay/watch.mjs +127 -0
  86. package/src/plugins.mjs +72 -29
  87. package/src/private-fs.mjs +40 -0
  88. package/src/process-lock.mjs +24 -13
  89. package/src/provider-auth.mjs +14 -3
  90. package/src/provider.mjs +11 -2
  91. package/src/read-only-check.mjs +7 -3
  92. package/src/remote-control.mjs +29 -4
  93. package/src/remote-workspace.mjs +50 -0
  94. package/src/scope-ceiling.mjs +56 -0
  95. package/src/scoped-policy.mjs +39 -0
  96. package/src/secret-login.mjs +68 -0
  97. package/src/session-bus.mjs +41 -7
  98. package/src/session-projections-candidate.mjs +119 -0
  99. package/src/session-projections.mjs +119 -0
  100. package/src/session-read-cache.mjs +23 -3
  101. package/src/sessions.mjs +35 -11
  102. package/src/source-editor-role.mjs +13 -0
  103. package/src/source-ownership.mjs +73 -0
  104. package/src/source-proposal-proof.mjs +55 -0
  105. package/src/source-proposals.mjs +118 -0
  106. package/src/source-requests.mjs +148 -0
  107. package/src/source-task-proof.mjs +24 -0
  108. package/src/source-task-revision-proof.mjs +44 -0
  109. package/src/subagents.mjs +187 -87
  110. package/src/tool-packs.mjs +5 -5
  111. package/src/tools.mjs +82 -15
  112. package/src/trusted-context.mjs +5 -1
  113. package/src/tui/connection-transition.mjs +1 -1
  114. package/src/tui/controller.mjs +8 -4
  115. package/src/tui/discovery.mjs +24 -12
  116. package/src/tui/inbound-blocks.mjs +55 -0
  117. package/src/tui/present.mjs +504 -0
  118. package/src/tui/screen.mjs +49 -13
  119. package/src/windows-private.ps1 +61 -0
  120. package/src/work-assertions.mjs +6 -2
  121. package/src/work-plan.mjs +34 -8
  122. package/src/work-state.mjs +15 -9
  123. package/src/workflows/chrome-page-cases.mjs +156 -0
  124. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  125. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  126. package/src/workflows/chrome-page-law.mjs +48 -0
  127. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  128. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  129. package/src/workflows/chrome-page-task.mjs +429 -0
  130. package/src/workflows/chrome-page-verifier.mjs +123 -0
  131. package/src/workflows/chrome-source.mjs +73 -0
  132. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  133. package/src/workflows/chrome-task-adoption.mjs +122 -0
  134. package/src/workflows/chrome-task.mjs +379 -74
  135. package/src/workflows/source-task-v1.mjs +124 -0
  136. package/src/workflows/source-task-v2.mjs +227 -0
  137. package/src/workflows/source-task.mjs +13 -0
@@ -1,4 +1,4 @@
1
- import { readFile, realpath } from 'node:fs/promises';
1
+ import { readFile, realpath, stat, lstat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import Ajv from 'ajv';
4
4
  import { createHash, randomUUID } from 'node:crypto';
@@ -6,27 +6,37 @@ import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { compileGovernance } from '../governance.mjs';
7
7
  import { digest, immutable } from '../operations.mjs';
8
8
  import { registerWorkOperations, workProjection } from '../work-plan.mjs';
9
- import { safeRelative, snapshotProject } from '../plugins/chrome-lab/project.mjs';
9
+ import { CHROME_SOURCE_NAMES, bindChromeSource } from './chrome-source.mjs';
10
+ import { PAGE_PREVIEW_RECOVERY_V13 } from './chrome-page-recovery-v13.mjs';
11
+ import { suppliedInputsSchema, normalizeSuppliedInputs, verifySuppliedInputs, isSuppliedPath, assertSuppliedSnapshot } from './chrome-supplied-inputs.mjs';
12
+ import { safeRelative, snapshotProject, chromeProjectNameSchema } from '../plugins/chrome-lab/project.mjs';
10
13
 
11
14
  const prefix = 'plugin__chrome-lab__';
12
- const PUBLIC = new Set(['Read', 'Edit', 'Write', 'ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover']);
13
- const OWN = ['ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover'];
15
+ const PUBLIC = new Set(['Read', 'Edit', 'Write', 'ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover', 'ChromeInspect', 'ChromeDeliver']);
16
+ const OWN = ['ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover', 'ChromeInspect', 'ChromeDeliver'];
14
17
  const INTERNAL = new Set(['WorkStepRun', 'WorkTaskUpdate']);
15
18
  const empty = { type: 'object', properties: {}, additionalProperties: false };
16
19
  const failure = (message, code = 'CHROME_TASK_CONFLICT') => Object.assign(new Error(message), { code });
17
- const schema = { type: 'object', additionalProperties: false, required: ['project','directory','popup','selector','equals'], properties: {
18
- project: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$' },
20
+ export const chromeTaskSchema = { type: 'object', additionalProperties: false, required: ['project','directory'], properties: {
21
+ surface: { const: 'page' },
22
+ publication_recovery: {const:PAGE_PREVIEW_RECOVERY_V13},
23
+ verification: { type: 'object', required: ['implementation_sha256', 'contract_digest', 'criteria_digest', 'criteria'], additionalProperties: false, properties: { implementation_sha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, contract_digest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, criteria_digest: { type: 'string', pattern: '^[a-f0-9]{64}$' }, criteria: { type: 'object' }, cases: { type: 'array', minItems: 1, maxItems: 24, uniqueItems: true, items: { type: 'string', pattern: '^[a-z][a-z0-9_-]{0,95}$' } } } },
24
+ project: chromeProjectNameSchema,
19
25
  directory: { type: 'string', minLength: 1, maxLength: 220 }, popup: { type: 'string', minLength: 1, maxLength: 220 },
20
26
  selector: { type: 'string', minLength: 1, maxLength: 1000 }, equals: { type: 'string', maxLength: 4000 },
21
- tests: { type: 'array', minItems: 1, maxItems: 24, items: { type: 'object', additionalProperties: false, required: ['action'], properties: {
22
- action: { enum: ['click','fill','reload','assert'] }, selector: { type: 'string', minLength: 1, maxLength: 1000 }, text: { type: 'string', maxLength: 2000 }, equals: { type: 'string', maxLength: 2000 }, count: { type: 'integer', minimum: 0, maximum: 100 }
27
+ fixture: {type:'object',additionalProperties:false,required:['file','selector','equals'],properties:{file:{type:'string',minLength:1,maxLength:220},selector:{type:'string',minLength:1,maxLength:1000},equals:{type:'string',maxLength:2000}},description:'An existing immutable HTML test page served on Win11 localhost. The house binds its exact target and checks content-script behavior; residents cannot edit the fixture.'},
28
+ supplied_inputs: suppliedInputsSchema,
29
+ baseline: {type:'boolean',description:'Only when the user requests an initial observation before edits. The house publishes and observes the accepted initial bytes first; this grants no permission.'},
30
+ tests: { type: 'array', minItems: 1, maxItems: 32, items: { type: 'object', additionalProperties: false, required: ['action'], properties: {
31
+ surface:{enum:['popup','page']}, action: { enum: ['click','fill','reload','assert','message'] }, request:{type:'string',minLength:1,maxLength:128}, selector: { type: 'string', minLength: 1, maxLength: 1000 }, text: { type: 'string', maxLength: 2000 }, equals: { type: 'string', maxLength: 2000 }, count: { type: 'integer', minimum: 0, maximum: 100 }
23
32
  }, allOf: [
24
33
  { if: { properties: { action: { enum: ['click','fill','assert'] } } }, then: { required: ['selector'] } },
25
34
  { if: { properties: { action: { const: 'fill' } } }, then: { required: ['text'] } },
35
+ { if: { properties: { action: { const: 'message' } } }, then: { required: ['request','equals'] } },
26
36
  { if: { properties: { action: { const: 'assert' } } }, then: { anyOf: [{ required: ['equals'] }, { required: ['count'] }] } }
27
37
  ] } }
28
- } };
29
- const validate = new Ajv({ strict: false }).compile(schema);
38
+ }, allOf: [{ if: { required: ['surface'], properties: { surface: { const: 'page' } } }, then: { required: ['fixture', 'supplied_inputs', 'verification'], not: { anyOf: ['popup', 'selector', 'equals', 'tests', 'baseline'].map(key => ({ required: [key] })) } }, else: { required: ['popup', 'selector', 'equals'], not: { anyOf:[{required:['verification']},{required:['publication_recovery']}] } } }] };
39
+ const validate = new Ajv({ strict: false }).compile(chromeTaskSchema);
30
40
  const manifestHash = snapshot => digest(snapshot.files.map(({ path, sha256 }) => ({ path, sha256 })));
31
41
  export const savedChromeTask = session => session.rows.findLast(row => row.type === 'chrome_task_accepted' && row.source === 'workflow_host');
32
42
  export async function readChromeTask(file) {
@@ -34,32 +44,81 @@ export async function readChromeTask(file) {
34
44
  let task; try { task = JSON.parse(bytes); } catch { throw failure('Chrome task must be JSON.'); }
35
45
  return normalizeTask(task);
36
46
  }
37
- function normalizeTask(task) {
47
+ export function normalizeTask(task) {
48
+ if (Buffer.byteLength(JSON.stringify(task)) > 16384) throw failure('Chrome task exceeds 16 KiB. Keep the contract bounded; preserve its functional criteria.');
38
49
  if (!validate(task)) throw failure('Invalid Chrome task: '+validate.errors.map(e => e.instancePath+' '+e.message).join('; '));
39
- const value = structuredClone(task); value.directory = safeRelative(value.directory); value.popup = safeRelative(value.popup);
50
+ const value = structuredClone(task); value.directory = safeRelative(value.directory); if(value.popup)value.popup = safeRelative(value.popup); if(value.fixture)value.fixture.file=safeRelative(value.fixture.file);if(value.supplied_inputs)normalizeSuppliedInputs(value.supplied_inputs); if(value.tests?.some(t=>t.surface==='page')&&!value.fixture)throw failure('Page steps require an immutable fixture.'); if(value.tests?.some(t=>t.surface==='page'&&t.action==='message'))throw failure('Typed worker messages require the popup surface.');
51
+ if (value.surface === 'page' && (value.verification.criteria_digest !== digest(value.verification.criteria) || Buffer.byteLength(JSON.stringify(value.verification.criteria)) > 4000)) throw failure('Page criteria identity changed or exceeds 4 KiB.');
40
52
  return immutable(value);
41
53
  }
54
+ export async function validateTaskDirectory(cwd, relative) {
55
+ const directory = path.resolve(cwd, safeRelative(relative));
56
+ if (path.relative(cwd, directory).split(path.sep).includes('..')) throw failure('Task directory must stay inside the workspace.');
57
+ let ancestor = directory;
58
+ for (;;) {
59
+ try {
60
+ if (await realpath(ancestor) !== ancestor || !(await stat(ancestor)).isDirectory()) throw failure('Task directory must use real directories inside the workspace.');
61
+ break;
62
+ } catch (error) { if (error.code !== 'ENOENT' || ancestor === cwd) throw error; ancestor = path.dirname(ancestor); }
63
+ }
64
+ if (path.relative(cwd, directory).split(path.sep).some(part => ['.git','.comal','.harness','.ssh','node_modules'].includes(part) || /^\.env(?:\.|$)/i.test(part))) throw failure('Protected task directory.');
65
+ return directory;
66
+ }
67
+
68
+ // A fixture can predate the extension. Reading its identity must not require
69
+ // manifest.json or a complete publishable candidate at conversational entry.
70
+ export async function chromeFixtureDigest(cwd, task) {
71
+ const directory = await validateTaskDirectory(cwd, task.directory);
72
+ const file = path.join(directory, safeRelative(task.fixture.file));
73
+ const before = await lstat(file);
74
+ if (!before.isFile() || await realpath(file) !== file || before.size > 10000000) throw failure('The immutable fixture must be a bounded regular file without symlink aliases.', 'CHROME_TASK_FIXTURE_CHANGED');
75
+ const bytes = await readFile(file), after = await lstat(file);
76
+ if (!after.isFile() || await realpath(file) !== file || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || bytes.length !== before.size) throw failure('The immutable fixture changed while reading.', 'CHROME_TASK_FIXTURE_CHANGED');
77
+ return createHash('sha256').update(bytes).digest('hex');
78
+ }
42
79
 
43
80
  // Host-owned coordinator. No executor or trust-management API is exposed to plugins.
44
- export async function prepareChromeTask({ cwd, house, store, sessionId, executor, policy, task, source = 'explicit_cli_file', observeIdentity = true }) {
81
+ export async function prepareChromeTask({ cwd, house, store, sessionId, executor, policy, task, source = 'explicit_cli_file', provenance, observeIdentity = true, sourceEditFacts = false, verifier, publicationRecoveryGuard, checkpointProjection }) {
45
82
  const loaded = await store.load(sessionId);
46
83
  if (!loaded.journalAudit.consistent) throw failure('Invalid session journal.', 'AUDIT_INVALID');
47
84
  const saved = savedChromeTask(loaded);
48
85
  if (!task && !saved) return null;
49
86
  const brief = normalizeTask(task || saved.task), taskDigest = digest(brief);
87
+ if (brief.surface === 'page') return (await import('./chrome-page-task.mjs')).prepareChromePageTask({ cwd, house, store, sessionId, executor, policy, task: brief, source, provenance, verifier, publicationRecoveryGuard, checkpointProjection });
50
88
  const checkOperation = brief.tests ? 'ChromeTest' : 'ChromeVerify';
51
89
  if (saved && saved.task_digest !== taskDigest) throw failure('El encargo guardado es inmutable; usa una sesión nueva para otro criterio.');
52
- const directory = path.resolve(cwd, brief.directory);
53
- if (await realpath(directory) !== directory || path.relative(cwd, directory).split(path.sep).includes('..')) throw failure('Task directory must be a real directory inside the workspace.');
90
+ const directory = await validateTaskDirectory(cwd, brief.directory);
54
91
  const catalogue = ['Deploy','Deployment','Open','Targets','Assert','Contexts', ...(brief.tests ? ['Interact'] : [])].map(name => prefix+name);
55
92
  if (catalogue.some(name => !house.registry.get(name))) throw failure('chrome-lab no está activo. Usa npm run lab:chrome -- --chrome-task ARCHIVO.json.');
56
93
  const mechanisms = ['runtime:chrome-task.scope','runtime:chrome-task.close','runtime:chrome-task.artifact-hash'];
57
94
  const compile = async version => compileGovernance(JSON.parse(await readFile(new URL(`../../governance/chrome-task-v${version}.json`, import.meta.url), 'utf8')), mechanisms);
58
- const currentPlan = await compile(2), legacyPlan = saved ? await compile(1) : null;
59
- const legacy = Boolean(saved && saved.governance_plan_hash === legacyPlan.plan_hash);
60
- const plan = legacy ? legacyPlan : currentPlan;
95
+ const currentPlan = await compile(brief.supplied_inputs?10:9), historical = saved ? await Promise.all([compile(1), compile(2), compile(3), compile(4), compile(5), compile(6), compile(7), compile(8), compile(9), compile(10)]) : [];
96
+ const plan = historical.find(plan => saved?.governance_plan_hash === plan.plan_hash) || currentPlan;
97
+ const legacy = plan.profile.version === '1', inspection = Number(plan.profile.version) >= 3, currency = Number(plan.profile.version) >= 4, pipeline = Number(plan.profile.version) >= 5, pages = Number(plan.profile.version) >= 6, documentProof=Number(plan.profile.version)>=7, workerInspection=Number(plan.profile.version)>=8, retainedSource=Number(plan.profile.version)>=9,suppliedInputs=Number(plan.profile.version)>=10;
98
+ if(brief.supplied_inputs&&!suppliedInputs)throw failure('Supplied inputs require their original v10 task policy.');
99
+ const suppliedDigest=brief.supplied_inputs?digest(brief.supplied_inputs):null;
100
+ if(suppliedDigest){
101
+ if(saved&&saved.supplied_inputs_digest!==suppliedDigest||provenance?.supplied_inputs_digest&&provenance.supplied_inputs_digest!==suppliedDigest)throw failure('The accepted supplied-input manifest changed.','CHROME_TASK_INPUT_CHANGED');
102
+ await verifySuppliedInputs(directory,brief.supplied_inputs);
103
+ }
104
+ let fixtureSource=null;
105
+ if(brief.fixture){
106
+ if(!pages||!house.registry.get(prefix+'Preview')||!house.registry.get(prefix+'CloseTab'))throw failure('Fixture coordination requires the current policy and Preview/CloseTab operations.');
107
+ fixtureSource=await chromeFixtureDigest(cwd,brief);
108
+ if(saved&&saved.fixture_source_digest!==fixtureSource || provenance?.fixture_source_digest&&provenance.fixture_source_digest!==fixtureSource)throw failure('The immutable test fixture changed.','CHROME_TASK_FIXTURE_CHANGED');
109
+ }
110
+ let baselineSource = null;
111
+ if(brief.baseline){
112
+ if(!pipeline)throw failure('Initial baseline coordination requires the current task policy.');
113
+ baselineSource=saved?.baseline_source_digest || provenance?.baseline_source_digest || manifestHash(await snapshotProject(cwd,brief.directory));
114
+ if(!/^[a-f0-9]{64}$/.test(baselineSource)||!saved&&manifestHash(await snapshotProject(cwd,brief.directory))!==baselineSource)throw failure('The original baseline candidate changed before adoption.','CHROME_TASK_STALE_SOURCE');
115
+ }
116
+ if (brief.tests?.some(step=>step.action==='message') && (!pipeline || !house.registry.get(prefix+'Message'))) throw failure('Typed worker messaging requires the current Chrome task policy and Message operation.');
117
+ if (inspection && ['ReadDOM', 'Console'].some(name => !house.registry.get(prefix + name))) throw failure('Chrome Lab DOM/console observations are unavailable.');
118
+ if(workerInspection&&!house.registry.get(prefix+'WorkerDiagnostics'))throw failure('Scoped native worker diagnostics are unavailable.');
61
119
  if (saved && saved.governance_plan_hash !== plan.plan_hash) throw failure('The accepted workflow policy changed; it cannot be reactivated silently.');
62
- // A new acceptance explicitly adopts v2. Historical v1 approvals retain native
120
+ // Existing task semantics retain their original policy; supplied inputs explicitly opt into v10. Historical approvals keep their original policy.
121
+ // Historical v1 approvals retain native
63
122
  // text Read and never gain FileInfo access just by resuming on a newer client.
64
123
  const artifactOperation = legacy ? 'Read' : 'FileInfo';
65
124
  const hashOperation = house.registry.get('FileInfo');
@@ -84,73 +143,188 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
84
143
  const checkpoint = session.rows.findLast(row => row.type === 'chrome_task_checkpoint' && row.task_digest === taskDigest);
85
144
  return { ...(checkpoint?.state || { stage: 'new', status: 'ready' }) };
86
145
  };
87
- const snapshot = signal => snapshotProject(cwd, brief.directory, { signal });
146
+ const snapshot = async signal => {if(suppliedDigest)await verifySuppliedInputs(directory,brief.supplied_inputs,{signal});const result=await snapshotProject(cwd, brief.directory, { signal }); if(fixtureSource&&result.files.find(f=>f.path===brief.fixture.file)?.sha256!==fixtureSource)throw failure('The immutable test fixture changed.','CHROME_TASK_FIXTURE_CHANGED');if(suppliedDigest)assertSuppliedSnapshot(result,brief.supplied_inputs); return result;};
88
147
  const save = (options, value) => options.recordWork({ type: 'chrome_task_checkpoint', source: 'workflow_host', task_digest: taskDigest, state: value });
89
148
  const invoke = async (options, name, input) => JSON.parse(await options.invoke(prefix+name, input, { signal: options.signal }));
90
149
  const assertReady = value => { if (value.stage.endsWith('_pending') || value.status === 'uncertain') throw failure('Publicación incierta: no se repite. Requiere reconciliación explícita.', 'CHROME_TASK_UNCERTAIN'); };
91
- const verify = async (options, runTests = false) => {
150
+ const observeDocument = async (options,binding,previousIdentity) => {
151
+ if(!observeIdentity)return;
152
+ const started=Date.now();let observed;
153
+ for(;;){
154
+ try{observed=await invoke(options,'Contexts',{target_id:binding.target_id});}catch(error){let detail;try{detail=JSON.parse(error.message);}catch{}if(detail?.code!=='CHROME_TARGET_NAVIGATING')throw error;}
155
+ const identity=observed?.identity;
156
+ if(identity?.context_unique_id&&identity?.connection_id&&(!previousIdentity||identity.context_unique_id!==previousIdentity.context_unique_id)){
157
+ if(previousIdentity&&(identity.connection_id!==previousIdentity.connection_id||identity.browser_endpoint_id!==previousIdentity.browser_endpoint_id))throw failure('The connection changed during document renewal.','CHROME_TASK_RELOAD_UNCERTAIN');
158
+ binding.identity=identity;return;
159
+ }
160
+ if(Date.now()-started>=3000)throw failure(previousIdentity?'Reload was dispatched but a new document was not observed; do not replay it.':'Document/connection identity unavailable.',previousIdentity?'CHROME_TASK_RELOAD_UNCERTAIN':'CHROME_TASK_UNCERTAIN');
161
+ await delay(100,undefined,{signal:options.signal});
162
+ }
163
+ };
164
+ const fixtureBinding = async (options,value,previousIdentity) => {
165
+ if(!brief.fixture)return;
166
+ const bound=value.fixture_binding;if(!bound)throw failure('No recorded fixture target. Publish the complete candidate.','CHROME_TASK_FIXTURE_MISSING');
167
+ const started=Date.now();for(;;){const live=(await invoke(options,'Targets',{})).targets,target=live.find(t=>t.target_id===bound.target_id);
168
+ if(target?.type==='page'&&target.url===bound.url)break;
169
+ if(!previousIdentity||target?.url&&target.url!=='about:blank'||Date.now()-started>=3000)throw failure('The exact owned fixture target changed.','CHROME_TASK_TARGET_CHANGED');
170
+ await delay(100,undefined,{signal:options.signal});
171
+ }
172
+ await observeDocument(options,bound,previousIdentity);await save(options,value);
173
+ };
174
+ const publishFixture = async (options,value,old) => {
175
+ if(!brief.fixture)return;
176
+ if(old.fixture_binding){
177
+ value.fixture_binding=old.fixture_binding;await save(options,value);
178
+ const found=(await invoke(options,'Targets',{})).targets.find(t=>t.target_id===old.fixture_binding.target_id);
179
+ if(found){
180
+ if(found.url!==old.fixture_binding.url)throw failure('Fixture navigation changed; cannot refresh a different resource.','CHROME_TASK_TARGET_CHANGED');
181
+ await fixtureBinding(options,value);const previousIdentity=documentProof?value.fixture_binding.identity:undefined;value.stage='fixture_reload_pending';await save(options,value);
182
+ await invoke(options,'Interact',{target_id:found.target_id,action:'reload'});
183
+ await fixtureBinding(options,value,previousIdentity);value.fixture_document_transition={before:previousIdentity,after:value.fixture_binding.identity};await save(options,value);return;
184
+ }
185
+ }
186
+ value.stage='fixture_open_pending';await save(options,value);const opened=await invoke(options,'Preview',{project:brief.project,file:brief.fixture.file});const url=new URL(opened.url);
187
+ if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||decodeURIComponent(url.pathname)!=='/'+brief.fixture.file)throw failure('Preview returned a different fixture resource.','CHROME_TASK_TARGET_CHANGED');
188
+ value.fixture_binding={target_id:opened.target_id,url:opened.url,project:brief.project,fixture_source_digest:fixtureSource};await save(options,value);await fixtureBinding(options,value);
189
+ };
190
+ const verify = async (options, runTests = false, rerun = false) => {
92
191
  const value = await state(); assertReady(value);
93
192
  if (!value.deployment) throw failure('Publica primero con ChromePublish; no hay una publicación registrada.');
94
193
  if (manifestHash(await snapshot(options.signal)) !== value.source_digest) throw failure('Los archivos cambiaron desde la publicación. Usa ChromePublish antes de verificar.', 'CHROME_TASK_STALE_SOURCE');
95
194
  const url = `chrome-extension://${value.deployment.extension_id}/${brief.popup}`;
96
- const resolve = async () => {
97
- const targets = (await invoke(options, 'Targets', {})).targets.filter(t => t.project === brief.project && t.type === 'page' && t.url === url);
98
- if (targets.length !== 1) throw failure('Popup propio ausente o ambiguo; no se abre ni elige otra pestaña.');
195
+ const resolve = async previousIdentity => {
196
+ let targets;const started=Date.now();for(;;){targets=(await invoke(options,'Targets',{})).targets.filter(t=>t.project===brief.project&&t.type==='page'&&t.url===url);if(targets.length===1)break;if(targets.length>1||!previousIdentity||Date.now()-started>=3000)throw failure('Popup propio ausente o ambiguo; no se abre ni elige otra pestaña.');await delay(100,undefined,{signal:options.signal});}
99
197
  value.binding = { target_id: targets[0].target_id, project: brief.project, url };
100
198
  await save(options, value);
101
- if (observeIdentity) {
102
- let observed;
103
- for (let attempt = 0; attempt < 8; attempt++) {
104
- observed = await invoke(options, 'Contexts', { target_id: value.binding.target_id });
105
- if (observed.identity?.context_unique_id && observed.identity?.connection_id) break;
106
- await delay(100, undefined, { signal: options.signal });
107
- }
108
- if (!observed.identity?.context_unique_id || !observed.identity?.connection_id) throw failure('Document/connection identity unavailable.');
109
- value.binding.identity = observed.identity; await save(options, value);
110
- }
199
+ await observeDocument(options,value.binding,previousIdentity);await save(options,value);
111
200
  };
112
201
  await resolve();
202
+ await fixtureBinding(options,value);
203
+ const initialBindings=value.fixture_binding?structuredClone({popup:value.binding,page:value.fixture_binding}):null;
204
+ let publicationObservation;
205
+ if (currency && runTests) {
206
+ const observed = await invoke(options, 'Deployment', { project: brief.project, reconnect: false });
207
+ if (observed.status !== 'observed' || observed.project !== brief.project || observed.attempt_id !== value.attempt_id || observed.file_manifest_sha256 !== value.file_manifest_sha256 || observed.extension_id !== value.deployment.extension_id) throw failure('The physical publication no longer matches this candidate. Publish the accepted source before testing; no interaction executed.', 'CHROME_TASK_REMOTE_CHANGED');
208
+ publicationObservation = (await store.load(sessionId)).rows.findLast(row => row.type === 'house_receipt' && row.parent_invocation_id === options.invocation_id && row.operation === prefix + 'Deployment' && row.status === 'returned')?.invocation_id;
209
+ }
210
+ let evidenceReuse;
211
+ if (currency && runTests && observeIdentity && value.behavior_invocation_id) {
212
+ const book = await store.load(sessionId);
213
+ const receipt = book.rows.findLast(row => row.type === 'house_receipt' && row.invocation_id === value.behavior_invocation_id && row.operation === 'ChromeTest');
214
+ if (!receipt || receipt.status !== 'returned') throw failure('The previous behavior execution has no successful durable receipt; uncertain tests cannot be repeated or credited.', 'CHROME_TASK_UNCERTAIN');
215
+ const previous = JSON.parse(receipt.output);
216
+ const lastEffect = brief.tests.findLastIndex(step => step.action !== 'assert');
217
+ if (!rerun && lastEffect < brief.tests.length - 1 && receipt.governance_plan_hash === plan.plan_hash && receipt.output_digest === digest(receipt.output) && previous.status === 'verified' && previous.scope === 'declared_behavior' && !previous.evidence_reuse && previous.task_digest === taskDigest && previous.source_digest === value.source_digest && digest(previous.binding) === digest(value.binding) && (!brief.fixture || digest(previous.bindings)===digest(initialBindings)) && previous.tests?.length === brief.tests.length && previous.tests.every((test,index) => test.index === index && (brief.tests[index].action === 'assert' ? test.passed === true : test.executed === true && (!documentProof || brief.tests[index].action!=='reload' || test.document_transition?.before?.context_unique_id && test.document_transition.after?.context_unique_id!==test.document_transition.before.context_unique_id && test.document_transition.after?.connection_id===test.document_transition.before.connection_id)))) {
218
+ evidenceReuse = { behavior_receipt_id: receipt.invocation_id, scope: 'same_source_and_document_with_fresh_terminal_postconditions', last_effect_index: lastEffect };
219
+ }
220
+ }
113
221
  let result;
114
222
  try { result = await invoke(options, 'Assert', { target_id: value.binding.target_id, selector: brief.selector, equals: brief.equals, timeout_ms: 0 }); }
115
223
  catch (error) { let missing = false; try { missing = JSON.parse(error.message).code === 'CHROME_TARGET_MISSING'; } catch {} if (!missing) throw error; await resolve(); result = await invoke(options, 'Assert', { target_id: value.binding.target_id, selector: brief.selector, equals: brief.equals, timeout_ms: 0 }); }
224
+ let fixtureResult,failureSurface=result.passed?null:'popup';
225
+ if(brief.fixture){fixtureResult=await invoke(options,'Assert',{target_id:value.fixture_binding.target_id,selector:brief.fixture.selector,equals:brief.fixture.equals,timeout_ms:1000});if(!fixtureResult.passed){result.passed=false;failureSurface='page';}}
116
226
  const tests = [];
227
+ if (currency && runTests && result.passed && !evidenceReuse) { value.behavior_invocation_id = options.invocation_id; await save(options, value); }
117
228
  if (runTests && result.passed) for (const [index, step] of brief.tests.entries()) {
229
+ if (evidenceReuse && index <= evidenceReuse.last_effect_index) continue;
230
+ const targetBinding=step.surface==='page'?value.fixture_binding:value.binding;
118
231
  if (step.action === 'assert') {
119
- const observed = await invoke(options, 'Assert', { target_id: value.binding.target_id, selector: step.selector, ...(step.equals !== undefined ? { equals: step.equals } : {}), ...(step.count !== undefined ? { count: step.count } : {}), timeout_ms: 1000 });
232
+ const observed = await invoke(options, 'Assert', { target_id: targetBinding.target_id, selector: step.selector, ...(step.equals !== undefined ? { equals: step.equals } : {}), ...(step.count !== undefined ? { count: step.count } : {}), timeout_ms: 1000 });
120
233
  tests.push({ index, passed: observed.passed, observed: observed.observed });
121
- if (!observed.passed) { result.passed = false; break; }
234
+ if (!observed.passed) { result.passed = false;failureSurface=step.surface||'popup'; break; }
235
+ } else if (step.action === 'message') {
236
+ value.stage='test_pending';await save(options,value);
237
+ const observed=await invoke(options,'Message',{target_id:value.binding.target_id,request:step.request});
238
+ value.stage='testing';await save(options,value);
239
+ const passed=observed.received===true&&observed.url===url&&observed.response===step.equals;
240
+ tests.push({index,executed:true,passed,observed});if(!passed){result.passed=false;failureSurface='popup';break;}
122
241
  } else {
242
+ const previousIdentity=step.action==='reload'&&documentProof?targetBinding.identity:undefined;
123
243
  value.stage = 'test_pending'; await save(options, value);
124
- await invoke(options, 'Interact', { target_id: value.binding.target_id, action: step.action, ...(step.selector ? { selector: step.selector } : {}), ...(step.action === 'fill' ? { text: step.text } : {}) });
244
+ const interaction=await invoke(options, 'Interact', { target_id: targetBinding.target_id, action: step.action, ...(step.selector ? { selector: step.selector } : {}), ...(step.action === 'fill' ? { text: step.text } : {}) });
245
+ if(interaction.dispatched===false&&interaction.blocked===true&&interaction.code==='CHROME_INPUT_BLOCKED'&&interaction.target_id===targetBinding.target_id&&interaction.action===step.action&&interaction.selector===step.selector){
246
+ value.stage='testing';await save(options,value);tests.push({index,executed:false,passed:false,blocked_input:interaction});result.passed=false;failureSurface=step.surface||'popup';break;
247
+ }
248
+ if(interaction.dispatched!==true)throw failure('Native interaction dispatch is unknown; do not replay it.','CHROME_TASK_INPUT_UNCERTAIN');
249
+ if (step.action === 'reload') {if(step.surface==='page')await fixtureBinding(options,value,previousIdentity);else await resolve(previousIdentity);}
125
250
  value.stage = 'testing'; await save(options, value);
126
- if (step.action === 'reload') await resolve();
127
- tests.push({ index, executed: true });
251
+ tests.push({ index, executed: true,...(previousIdentity?{document_transition:{before:previousIdentity,after:(step.surface==='page'?value.fixture_binding:value.binding).identity}}:{}) });
128
252
  }
129
253
  }
130
254
  if (observeIdentity) {
131
255
  const after = await invoke(options, 'Contexts', { target_id: value.binding.target_id });
132
256
  if (digest(after.identity) !== digest(value.binding.identity)) throw failure('Document/connection changed during verification.');
257
+ if(value.fixture_binding){const afterPage=await invoke(options,'Contexts',{target_id:value.fixture_binding.target_id});if(digest(afterPage.identity)!==digest(value.fixture_binding.identity))throw failure('Fixture document/connection changed during verification.');}
133
258
  }
134
259
  if (result.observed?.url !== url || manifestHash(await snapshot(options.signal)) !== value.source_digest) throw failure('Evidence no longer matches source/resource.');
135
- value.status = result.passed ? 'verified' : 'needs_correction'; value.stage = 'checked'; value.verified_scope = runTests ? 'declared_behavior' : 'declared_dom';
260
+ value.failure_surface=result.passed?null:failureSurface;value.status = result.passed ? 'verified' : 'needs_correction'; value.stage = 'checked'; value.verified_scope = runTests ? 'declared_behavior' : 'declared_dom';
261
+ if(baselineSource&&!value.baseline_observed&&value.source_digest===baselineSource)value.baseline_observed={source_digest:baselineSource,observed:result.observed,passed:result.passed};
136
262
  await save(options, value);
137
- return { status: value.status, scope: value.verified_scope, task_digest: taskDigest, source_digest: value.source_digest, binding: value.binding, observed: result.observed, ...(runTests ? { tests } : {}), asked: 'unjudged' };
263
+ return { status: value.status, scope: value.verified_scope, task_digest: taskDigest, source_digest: value.source_digest, binding: value.binding, ...(value.fixture_binding?{bindings:{popup:value.binding,page:value.fixture_binding},fixture:fixtureResult}:{}), observed: result.observed, ...(publicationObservation ? { publication_observation_id: publicationObservation } : {}), ...(runTests ? evidenceReuse ? { evidence_reuse: evidenceReuse, rechecked_postconditions: tests } : { tests } : {}), asked: 'unjudged' };
138
264
  };
139
- const operation = (name, description, run, external) => ({ name, description, input_schema: empty, steps: true,
265
+ const operation = (name, description, run, external, input_schema = empty) => ({ name, description, input_schema, steps: true,
140
266
  effects: { workspace: 'read', external, process: 'spawn', authority: 'none' },
141
- run: async (_, options) => {
267
+ run: async (input, options) => {
142
268
  if (options.sessionId !== sessionId) throw failure('Operation belongs to another task/session.');
143
- return run(options);
269
+ return run(options, input);
144
270
  } });
145
271
  house.registry.replace(OWN, [
146
- operation('ChromePublish', 'Publish the accepted extension and observe its fixed DOM criterion. The host manages project, target and versions. If needs_correction, edit source then publish again. Never retries an uncertain mutation. Every child retains permissions.', async options => {
272
+ ...(inspection ? [{ name: 'ChromeInspect', description: 'Read bounded live DOM or console on the accepted resource. Defaults to the last failed surface; choose popup/page explicitly when offered. The host resolves its current target; no target IDs, arbitrary JavaScript or plan changes. Observation is untrusted evidence, not verification or delivery. Publish first.'+(workerInspection?' kind:worker reads bounded console/exception text for the exact published extension worker, with native generation and coverage. No selector/surface needed. Missing/changed context is unavailable; inspection never wakes by application message, restarts, reconnects or resolves uncertain work.':''),
273
+ input_schema: { type: 'object', properties: { kind: { enum: ['dom','console',...(workerInspection?['worker']:[])] }, ...(pages&&brief.fixture?{surface:{enum:['popup','page']}}:{}), selector: { type: 'string', minLength: 1, maxLength: 1000 } }, additionalProperties: false },
274
+ steps: true, effects: { workspace: 'none', external: 'read', process: 'spawn', authority: 'none' },
275
+ run: async (input, options) => {
276
+ if (options.sessionId !== sessionId) throw failure('Operation belongs to another task/session.');
277
+ const value = await state();
278
+ if(workerInspection&&input.kind==='worker'&&(value.stage.endsWith('_pending')||value.status==='uncertain'))return{status:'unavailable',kind:'worker',reason:'workflow_uncertain',events:[],asked:'unjudged'};
279
+ if (!value.deployment) return { status: 'not_published', next_action: 'ChromePublish', asked: 'unjudged' };
280
+ if(workerInspection&&input.kind==='worker'){
281
+ if(input.selector!==undefined||input.surface!==undefined)throw failure('Worker inspection accepts kind:worker only; it has no DOM surface or selector.');
282
+ const unavailable=reason=>({status:'unavailable',kind:'worker',reason,scope:'own_publication_worker_diagnostics',events:[],asked:'unjudged'});
283
+ if(value.stage.endsWith('_pending')||value.status==='uncertain')return unavailable('workflow_uncertain');
284
+ const identity=value.binding?.identity;
285
+ if(!identity?.connection_id||!identity?.browser_endpoint_id)return unavailable('publication_connection_unavailable');
286
+ const observation=await invoke(options,'WorkerDiagnostics',{project:brief.project,extension_id:value.deployment.extension_id,attempt_id:value.attempt_id,file_manifest_sha256:value.file_manifest_sha256,connection_id:identity.connection_id,browser_endpoint_id:identity.browser_endpoint_id,limit:30});
287
+ const after=await state();
288
+ if(after.attempt_id!==value.attempt_id||after.source_digest!==value.source_digest||after.stage!==value.stage||after.status!==value.status)return unavailable('workflow_changed');
289
+ return{status:observation.status,kind:'worker',scope:'own_publication_worker_diagnostics',publication_source_digest:value.source_digest,observation,asked:'unjudged'};
290
+ }
291
+ const url = `chrome-extension://${value.deployment.extension_id}/${brief.popup}`;
292
+ const surface=input.surface||value.failure_surface||'popup',expectedUrl=surface==='page'?value.fixture_binding?.url:url;
293
+ const targets = (await invoke(options, 'Targets', {})).targets.filter(t => t.type === 'page' && t.url === expectedUrl && (surface==='page'?t.target_id===value.fixture_binding?.target_id:t.project===brief.project));
294
+ if (targets.length !== 1) throw failure('Own popup absent or ambiguous; inspection cannot select another target.');
295
+ const binding = { target_id: targets[0].target_id, project: brief.project, url:expectedUrl };
296
+ value.inspection_binding = binding; await save(options, value);
297
+ const kind = input.kind || 'dom';
298
+ const observation = await invoke(options, kind === 'dom' ? 'ReadDOM' : 'Console', { target_id: binding.target_id, ...(kind === 'dom' ? { selector: input.selector || 'body' } : { limit: 100 }) });
299
+ if (kind === 'dom' && observation.value?.url !== expectedUrl || !(await invoke(options, 'Targets', {})).targets.some(t => t.target_id === binding.target_id && t.url === expectedUrl && (surface==='page'||t.project === brief.project))) throw failure('The own popup changed during inspection.', 'CHROME_TASK_TARGET_CHANGED');
300
+ return { status: 'observed', kind, scope: 'own_published_popup_observation', publication_source_digest: value.source_digest, binding, observation, asked: 'unjudged' };
301
+ }
302
+ }] : []),
303
+ operation('ChromePublish', 'Publish the accepted source and observe its criterion. The host manages identity and versions. Identical bytes reuse only a physically matching publication. Failed criteria include approved DOM/console diagnostics. Edit source then publish; never retries an uncertain mutation. Children retain permissions.', async options => {
147
304
  const old = await state(); assertReady(old);
148
- const before = await snapshot(options.signal);
305
+ let before;
306
+ try { before=await snapshot(options.signal); }
307
+ catch(error) { if(error.code==='CHROME_TASK_SOURCE_INVALID')await options.recordWork({type:'chrome_candidate_rejected',source:'workflow_host',stage:'source_preflight',task_digest:taskDigest,code:error.code,remote_dispatched:false,details:error.details});throw error; }
149
308
  const continuing = old.stage === 'reconciled' || old.recovery_pending && manifestHash(before) === old.source_digest;
309
+ const diagnostics = async result => {
310
+ const allowed = name => policy.allowed.has(prefix + name) && !policy.denied.has(prefix + name);
311
+ if (inspection && result.status === 'needs_correction' && allowed('ReadDOM') && allowed('Console')) {
312
+ const dom = JSON.parse(await options.invoke('ChromeInspect', { kind: 'dom', selector: 'body' }, { signal: options.signal }));
313
+ const console = JSON.parse(await options.invoke('ChromeInspect', { kind: 'console' }, { signal: options.signal }));
314
+ const page=brief.fixture?JSON.parse(await options.invoke('ChromeInspect',{kind:'dom',surface:'page',selector:'body'},{signal:options.signal})):null;
315
+ return { ...result, diagnostics: { scope: 'untrusted_own_resource_observations', dom: dom.observation, console: console.observation,...(page?{page:page.observation}:{}) } };
316
+ }
317
+ return result;
318
+ };
319
+ if (inspection && !continuing && old.deployment && old.source_digest === manifestHash(before) && ['published', 'checked'].includes(old.stage)) {
320
+ const observed = await invoke(options, 'Deployment', { project: brief.project, reconnect: false });
321
+ if (observed.status === 'observed' && observed.attempt_id === old.attempt_id && observed.file_manifest_sha256 === old.file_manifest_sha256 && observed.extension_id === old.deployment.extension_id) return diagnostics({ ...await verify(options), reused_publication: true });
322
+ }
150
323
  if (continuing && manifestHash(before) !== old.source_digest) throw failure('El candidato reconciliado cambió; conserva su snapshot antes de continuar.', 'CHROME_TASK_STALE_SOURCE');
151
- const value = continuing ? { ...old } : { stage: 'deploy_pending', status: 'ready', source_digest: manifestHash(before), file_manifest_sha256: createHash('sha256').update(JSON.stringify(before.files.map(({path,sha256})=>({path,sha256})))).digest('hex'), attempt_id: randomUUID() }; await save(options, value);
324
+ const namespace=pages?await invoke(options,'Status',{}):null;const namespaceReceipt=(await store.load(sessionId)).rows.findLast(r=>r.type==='house_receipt'&&r.parent_invocation_id===options.invocation_id&&r.operation===prefix+'Status'&&r.status==='returned');
325
+ const value = continuing ? { ...old } : { ...(old.baseline_observed?{baseline_observed:old.baseline_observed}:{}), stage: 'deploy_pending', status: 'ready', source_digest: manifestHash(before), file_manifest_sha256: createHash('sha256').update(JSON.stringify(before.files.map(({path,sha256})=>({path,sha256})))).digest('hex'), attempt_id: randomUUID(),publication_namespace:{create_only:Boolean(namespace&&!namespace.extensions_truncated&&!namespace.extensions.some(e=>e.project===brief.project)),existing:namespace?namespace.extensions.some(e=>e.project===brief.project):true,receipt_id:namespaceReceipt?.invocation_id} }; await save(options, value);
152
326
  try {
153
- if (!continuing) value.deployment = await invoke(options, 'Deploy', { project: brief.project, directory: brief.directory, attempt_id: value.attempt_id });
327
+ if (!continuing) value.deployment = await invoke(options, 'Deploy', { project: brief.project, directory: brief.directory, attempt_id: value.attempt_id, ...(value.publication_namespace?.create_only?{create_only:true}:{}),...(suppliedInputs?{expected_manifest_sha256:value.file_manifest_sha256}:{}) });
154
328
  if (value.deployment.project !== brief.project) throw failure('Deployment returned another project.');
155
329
  if (value.deployment.attempt_id !== value.attempt_id) throw failure('Deployment returned another attempt.');
156
330
  if (!value.deployment.file_manifest_sha256 || value.deployment.file_manifest_sha256 !== createHash('sha256').update(JSON.stringify(before.files.map(({path,sha256})=>({path,sha256})))).digest('hex')) throw failure('Deployment snapshot differs from the accepted local candidate.');
@@ -158,14 +332,19 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
158
332
  value.stage = 'open_pending'; await save(options, value);
159
333
  await invoke(options, 'Open', { url: `chrome-extension://${value.deployment.extension_id}/${brief.popup}` });
160
334
  }
335
+ await publishFixture(options,value,old);
161
336
  value.stage = 'published'; await save(options, value);
162
337
  } catch (error) {
163
- value.status = ['HOUSE_REFUSED','HOUSE_GUARD','GOVERNANCE_REFUSED'].includes(error.code) ? 'refused' : 'uncertain';
338
+ if(suppliedInputs&&error.code==='CHROME_DEPLOY_PRECONDITION'){
339
+ value.status='ready';value.stage='source_rejected';await save(options,value);
340
+ await options.recordWork({type:'chrome_candidate_rejected',source:'workflow_host',stage:'deployment_snapshot_precondition',task_digest:taskDigest,code:error.code,remote_dispatched:false,details:error.details});throw error;
341
+ }
342
+ value.status = ['HOUSE_REFUSED','HOUSE_GUARD','GOVERNANCE_REFUSED','CHROME_PROJECT_EXISTS'].includes(error.code) ? 'refused' : 'uncertain';
164
343
  if (value.status === 'refused') value.stage = 'refused'; await save(options, value); throw error;
165
344
  }
166
345
  const result=await verify(options);
167
346
  if(continuing){const current=await state();delete current.recovery_pending;delete current.recovery_popup_observed;await save(options,current);}
168
- return result;
347
+ return diagnostics(result);
169
348
  }, 'write'),
170
349
  operation('ChromeRecover', 'Explicit read-only recovery after a publication/connection interruption. Reconnects, matches the durable attempt and physical remote snapshot to the accepted local candidate. Never repeats Deploy/Open/Interact. A missing popup after confirmed Deploy returns recovered with next_action ChromePublish, which opens it without publishing again. Unknown interactions and ambiguous/missing evidence remain blocked.', async options => {
171
350
  const old = await state();
@@ -184,8 +363,8 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
184
363
  if (targets.length) return verify(options);
185
364
  return { status: 'recovered', stage: value.stage, attempt_id: value.attempt_id, next_action: 'ChromePublish', note: 'Publicación observada; ChromePublish sólo abrirá el popup y verificará. No repite Deploy.' };
186
365
  }, 'read'),
187
- operation('ChromeVerify', 'Read the accepted own popup and check its fixed criterion against the currently published source and fresh document identity. No publication or navigation. Failed criterion means correct source and publish.', verify, 'read'),
188
- ...(brief.tests ? [operation('ChromeTest', 'Run the accepted interaction/assertion sequence on the own popup. This writes test data and may reload; each child retains permissions. No test steps or targets to supply. A partial/uncertain interaction stops the sequence.', options => verify(options, true), 'write')] : []),
366
+ operation('ChromeVerify', 'Read the accepted own popup and check its fixed criterion against the currently published source and fresh document identity. No publication or navigation. Failed criterion means correct source and publish.', options => verify(options), 'read'),
367
+ ...(brief.tests ? [operation('ChromeTest', currency ? 'Verify accepted behavior. The house reuses a completed test receipt only for identical source/document identity and freshly passing final postconditions; otherwise runs the sequence. rerun:true explicitly repeats actions. Child permissions remain; uncertain tests never replay.' : 'Run the accepted interaction/assertion sequence on the own popup. This writes test data and may reload; each child retains permissions. No test steps or targets to supply. A partial/uncertain interaction stops the sequence.', (options, input) => verify(options, true, currency && input.rerun === true), 'write', currency ? { type: 'object', properties: { rerun: { type: 'boolean' } }, additionalProperties: false } : empty)] : []),
189
368
  operation('ChromeFinish', 'Verify the declared browser check and close the accepted work with fresh evidence. No versions/IDs/criteria to supply. Returns done only after the host check; does not authorize publishing elsewhere.', async options => {
190
369
  const current = workProjection(await store.load(sessionId));
191
370
  if (current?.counts.done === 1) { const result = JSON.parse(await options.invoke(checkOperation, {}, { signal: options.signal })); if (result.status !== 'verified') throw failure('Current browser criterion failed.', 'WORK_UNVERIFIED'); return { status: 'done', reobserved: true, asked: 'unjudged' }; }
@@ -193,40 +372,80 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
193
372
  await options.invoke('WorkStepRun', { task_id: 'chrome-task', plan_version: current.plan.version, step_id: 'browser', recheck: true }, { signal: options.signal });
194
373
  await options.invoke('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: current.plan.version, status: 'done' }, { signal: options.signal });
195
374
  return { status: 'done', scope: 'declared_work_checks', asked: 'unjudged' };
196
- }, 'read')
375
+ }, 'read'),
376
+ ...(pipeline ? [operation('ChromeDeliver', 'Commit a complete source candidate: the house publishes, runs accepted tests and closes. Use after all source files are ready. Stops at an observed failure for you to correct; no plan, test, target or version arguments. Every child retains its permission and durable receipt. An uncertain effect is never replayed. done proves declared checks, not human acceptance.', async options => {
377
+ const publication = JSON.parse(await options.invoke('ChromePublish', {}, { signal: options.signal }));
378
+ if (publication.status !== 'verified') return { status: 'needs_correction', stage: 'primary_criterion', publication, next_action: 'Correct the observed source failure, then commit the complete candidate with ChromeDeliver.', asked: 'unjudged' };
379
+ if (brief.tests) {
380
+ const tests = JSON.parse(await options.invoke('ChromeTest', {}, { signal: options.signal }));
381
+ if (tests.status !== 'verified') {
382
+ const diagnostics={scope:'untrusted_own_resource_observations'};
383
+ if(inspection&&!policy.denied.has('ChromeInspect')&&['ReadDOM','Console'].every(name=>policy.allowed.has(prefix+name)&&!policy.denied.has(prefix+name))){
384
+ for(const [kind,key]of[['dom','dom'],['console','console']])try{diagnostics[key]=JSON.parse(await options.invoke('ChromeInspect',{kind,...(kind==='dom'?{selector:'body'}:{})},{signal:options.signal})).observation;}catch(error){if(options.signal?.aborted)throw error;diagnostics[key]={unavailable:true,code:error.code||'OBSERVATION_UNAVAILABLE'};}
385
+ }
386
+ return { status: 'needs_correction', stage: 'declared_behavior', check: tests, ...(Object.keys(diagnostics).length>1?{diagnostics}:{}), next_action: 'Correct the failing behavior using its observed diagnostics, then use ChromeDeliver. Do not change the accepted tests. A DOM marker is not proof of worker communication or persisted data.', asked: 'unjudged' };
387
+ }
388
+ }
389
+ await options.invoke('ChromeFinish', {}, { signal: options.signal });
390
+ const book = await store.load(sessionId), current = await state();
391
+ const receipt = book.rows.findLast(r => r.type === 'house_receipt' && r.operation === checkOperation && r.status === 'returned');
392
+ return { status: 'done', scope: brief.tests ? 'declared_behavior_and_source_snapshot' : 'declared_dom_criterion_and_source_snapshot', source_digest: current.source_digest, task_digest: taskDigest, receipt_id: receipt?.invocation_id, tests: brief.tests?.length || 0, asked: 'unjudged' };
393
+ }, 'write')] : [])
197
394
  ]);
198
395
  registerWorkOperations({ store, house, artifactObservation: { sessionId, operation: artifactOperation } });
199
396
  if (!saved) {
200
- await store.append(sessionId, { type: 'chrome_task_accepted', source: 'workflow_host', acceptance: source, task: brief, task_digest: taskDigest, governance_plan_hash: plan.plan_hash, governance_profile: plan.profile, artifact_observation: { operation: artifactOperation, hash: true, paths: declaredTask.artifacts } });
201
- await store.append(sessionId, { type: 'intent', text: `Accepted Chrome task: ${brief.project}, directory ${brief.directory}, popup ${brief.popup}, ${brief.selector} equals ${JSON.stringify(brief.equals)}. Publish, correct and verify only this own extension; no extra permissions.` });
397
+ await store.append(sessionId, { type: 'chrome_task_accepted', source: 'workflow_host', acceptance: source, ...(provenance ? { provenance: structuredClone(provenance) } : {}), ...(baselineSource?{baseline_source_digest:baselineSource}:{}), ...(fixtureSource?{fixture_source_digest:fixtureSource}:{}),...(suppliedDigest?{supplied_inputs_digest:suppliedDigest}:{}), task: brief, task_digest: taskDigest, governance_plan_hash: plan.plan_hash, governance_profile: plan.profile, artifact_observation: { operation: artifactOperation, hash: true, paths: declaredTask.artifacts } });
398
+ await store.append(sessionId, { type: 'chrome_task_scope', source: 'workflow_host', text: `Accepted Chrome task: ${brief.project}, directory ${brief.directory}, popup ${brief.popup}, ${brief.selector} equals ${JSON.stringify(brief.equals)}. Publish, correct and verify only this own extension; no extra permissions.` });
202
399
  }
400
+ let sourceAccess;
401
+ const sourcePath = async (file,mutable) => {
402
+ if(suppliedDigest&&mutable){if(isSuppliedPath(path.relative(directory,path.resolve(cwd,file)),brief.supplied_inputs))return {allowed:false,reason:'Supplied inputs are read-only; implement the application around the accepted assets.'};await verifySuppliedInputs(directory,brief.supplied_inputs);}
403
+ if(baselineSource&&mutable&&!(await state()).baseline_observed)return {allowed:false,reason:'The initial baseline must be observed before edits. The house performs it under current permissions; do not modify the accepted initial bytes.'};
404
+ if(fixtureSource&&mutable&&path.resolve(cwd,file)===path.join(directory,brief.fixture.file))return {allowed:false,reason:'The accepted test fixture is immutable; implement the extension, not its oracle.'};
405
+ const relative = path.relative(directory, path.resolve(cwd, file));
406
+ const lexical = Boolean(relative) && !path.isAbsolute(relative) && !relative.split(path.sep).some(part => part === '..' || ['.git','.comal','.harness','.ssh','node_modules'].includes(part) || /^\.env(?:\.|$)/i.test(part));
407
+ if (!lexical) return { allowed: false, reason: 'Only files of the accepted extension.' };
408
+ await validateTaskDirectory(cwd, brief.directory);
409
+ let ancestor = path.resolve(cwd, file);
410
+ for (;;) {
411
+ try { return { allowed: await realpath(ancestor) === ancestor, reason: 'Source paths cannot alias another resource through symlinks.' }; }
412
+ catch (error) { if (error.code !== 'ENOENT') throw error; ancestor = path.dirname(ancestor); }
413
+ }
414
+ };
203
415
  const scope = async view => {
204
416
  if (policy.denied.has(view.operation)) return { allowed: false, reason: 'Explicit session deny.' };
205
417
  if (view.operation === 'WorkPlanSet' || view.operation === 'WorkCheckRepair') return { allowed: false, reason: 'Accepted task/criterion is immutable in this workflow.' };
206
418
  if (view.actor.kind === 'human' || view.surface === 'internal') return true;
207
- if (['Read','Edit','Write'].includes(view.operation)) {
208
- const relative = path.relative(directory, path.resolve(cwd, view.input.file_path));
209
- return { allowed: Boolean(relative) && !path.isAbsolute(relative) && !relative.split(path.sep).some(part => part === '..' || ['.git','.comal','.harness','.ssh','node_modules'].includes(part) || /^\.env(?:\.|$)/i.test(part)), reason: 'Only files of the accepted extension.' };
210
- }
419
+ if (['Read','Edit','Write'].includes(view.operation)) return sourcePath(view.input.file_path,view.operation!=='Read');
420
+ if (retainedSource && CHROME_SOURCE_NAMES.includes(view.operation)) return Boolean(sourceAccess?.active() && await sourceAccess.admit(view));
211
421
  if (!legacy && view.operation === 'FileInfo') return { allowed: true, reason: 'Subject to the separately enforced artifact-hash capability.' };
212
422
  if (OWN.includes(view.operation)) return true;
213
423
  if (INTERNAL.has(view.operation)) return { allowed: ['ChromeFinish','WorkTaskUpdate'].includes(view.parent_operation), reason: 'Work administration belongs to the coordinator.' };
214
424
  if (view.operation.startsWith(prefix)) {
215
- if (!['ChromePublish','ChromeVerify','ChromeTest','ChromeRecover'].includes(view.parent_operation)) return { allowed: false, reason: 'Use the offered domain action.' };
425
+ if (!['ChromePublish','ChromeVerify','ChromeTest','ChromeRecover', ...(inspection ? ['ChromeInspect'] : [])].includes(view.parent_operation)) return { allowed: false, reason: 'Use the offered domain action.' };
216
426
  const value = await state();
217
- if (view.operation === prefix+'Deployment') return view.parent_operation === 'ChromeRecover' && view.input.project === brief.project;
218
- if (view.operation === prefix+'Deploy') return view.parent_operation === 'ChromePublish' && view.input.project === brief.project && view.input.directory === brief.directory && view.input.attempt_id === value.attempt_id;
427
+ if(view.operation===prefix+'WorkerDiagnostics'){
428
+ const identity=value.binding?.identity;
429
+ return workerInspection&&view.parent_operation==='ChromeInspect'&&!value.stage.endsWith('_pending')&&value.status!=='uncertain'&&!policy.denied.has(prefix+'Deployment')&&digest(view.input)===digest({project:brief.project,extension_id:value.deployment?.extension_id,attempt_id:value.attempt_id,file_manifest_sha256:value.file_manifest_sha256,connection_id:identity?.connection_id,browser_endpoint_id:identity?.browser_endpoint_id,limit:30});
430
+ }
431
+ if (view.operation === prefix+'Deployment') return (view.parent_operation === 'ChromeRecover' || inspection && view.parent_operation === 'ChromePublish' || currency && view.parent_operation === 'ChromeTest' && view.input.reconnect === false) && view.input.project === brief.project;
432
+ if (view.operation === prefix+'Deploy') return (!suppliedInputs||view.input.expected_manifest_sha256===value.file_manifest_sha256)&&view.parent_operation === 'ChromePublish' && view.input.project === brief.project && view.input.directory === brief.directory && view.input.attempt_id === value.attempt_id && Boolean(view.input.create_only)===Boolean(value.publication_namespace?.create_only);
219
433
  if (view.operation === prefix+'Open') return view.parent_operation === 'ChromePublish' && view.input.url === `chrome-extension://${value.deployment?.extension_id}/${brief.popup}`;
434
+ if (pages&&brief.fixture&&view.operation===prefix+'Preview')return view.parent_operation==='ChromePublish'&&view.input.project===brief.project&&view.input.file===brief.fixture.file&&value.stage==='fixture_open_pending';
435
+ if (view.operation === prefix+'Status') return pages && view.parent_operation==='ChromePublish';
220
436
  if (view.operation === prefix+'Targets') return true;
221
- if (view.operation === prefix+'Interact') return view.parent_operation === 'ChromeTest' && view.input.target_id === value.binding?.target_id && brief.tests.some(step => step.action === view.input.action && step.selector === view.input.selector && (step.action !== 'fill' || step.text === view.input.text));
222
- if (['Contexts','Assert'].some(name => view.operation === prefix+name)) return view.input.target_id === value.binding?.target_id && (view.operation !== prefix+'Assert' || view.input.selector === brief.selector && view.input.equals === brief.equals || view.parent_operation === 'ChromeTest' && brief.tests.some(step => step.action === 'assert' && step.selector === view.input.selector && step.equals === view.input.equals && step.count === view.input.count));
437
+ if (inspection && ['ReadDOM','Console'].some(name => view.operation === prefix+name)) return view.parent_operation === 'ChromeInspect' && view.input.target_id === value.inspection_binding?.target_id;
438
+ if(pages&&brief.fixture&&view.operation===prefix+'Interact'&&view.parent_operation==='ChromePublish')return value.stage==='fixture_reload_pending'&&view.input.target_id===value.fixture_binding?.target_id&&view.input.action==='reload'&&view.input.selector===undefined&&view.input.text===undefined;
439
+ if (view.operation === prefix+'Interact') return view.parent_operation === 'ChromeTest' && (view.input.target_id === value.binding?.target_id || pages&&view.input.target_id===value.fixture_binding?.target_id) && brief.tests.some(step => (step.surface==='page'?value.fixture_binding?.target_id:value.binding?.target_id)===view.input.target_id && step.action === view.input.action && step.selector === view.input.selector && (step.action !== 'fill' || step.text === view.input.text));
440
+ if (pipeline && view.operation === prefix+'Message') return view.parent_operation === 'ChromeTest' && view.input.target_id === value.binding?.target_id && brief.tests.some(step=>step.action==='message'&&step.request===view.input.request);
441
+ if (['Contexts','Assert'].some(name => view.operation === prefix+name)) return (view.input.target_id === value.binding?.target_id || pages&&view.input.target_id===value.fixture_binding?.target_id) && (view.operation !== prefix+'Assert' || view.input.target_id===value.fixture_binding?.target_id && view.input.selector===brief.fixture?.selector && view.input.equals===brief.fixture?.equals || view.input.selector === brief.selector && view.input.equals === brief.equals || view.parent_operation === 'ChromeTest' && brief.tests.some(step => (step.surface==='page'?value.fixture_binding?.target_id:value.binding?.target_id)===view.input.target_id && step.action === 'assert' && step.selector === view.input.selector && step.equals === view.input.equals && step.count === view.input.count));
223
442
  }
224
443
  return { allowed: false, reason: 'Operation outside this accepted workflow surface.' };
225
444
  };
226
- const artifactHash = view => ({
445
+ const artifactHash = async view => ({
227
446
  allowed: !policy.denied.has(view.operation) && view.contract_digest === hashOperation?.digest &&
228
- ['WorkStepRun','WorkTaskUpdate'].includes(view.parent_operation) &&
229
- digest(view.input) === digest({ file_path: declaredTask.artifacts[0], hash: true }),
447
+ (['WorkStepRun','WorkTaskUpdate'].includes(view.parent_operation) &&
448
+ digest(view.input) === digest({ file_path: declaredTask.artifacts[0], hash: true }) || retainedSource && await sourceAccess?.inspection(view)),
230
449
  reason: 'Only native full-file hashing of the exact accepted artifact by the host work coordinator.'
231
450
  });
232
451
  const close = async view => {
@@ -239,21 +458,80 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
239
458
  if (!workProjection(await store.load(sessionId))) await executor.execute('WorkPlanSet', { objective: `Verify accepted Chrome task ${brief.project} (${taskDigest})`, expected_version: 0, tasks: [declaredTask] });
240
459
  const initial = workProjection(await store.load(sessionId));
241
460
  if (initial.current?.status === 'pending') await executor.execute('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: initial.plan.version, status: 'in_progress' });
461
+ if(retainedSource)sourceAccess=bindChromeSource({executor,store,sessionId,cwd,plan,taskDigest,pathAllowed:async(file,mutable)=>(await sourcePath(file,mutable)).allowed,directory:brief.directory});
242
462
  await house.activateGovernance({ sessionId, plan, bindings: {
243
- 'runtime:chrome-task.scope': { revision: legacy ? 'chrome-scope-v3' : 'chrome-scope-v4', operations: ['*'], configuration_digest: taskDigest, check: scope },
463
+ 'runtime:chrome-task.scope': { revision: suppliedInputs?'chrome-scope-v15':retainedSource?'chrome-scope-v14':workerInspection?'chrome-scope-v13':legacy ? 'chrome-scope-v3' : documentProof ? 'chrome-scope-v12' : pages ? 'chrome-scope-v11' : pipeline ? 'chrome-scope-v9' : currency ? 'chrome-scope-v7' : inspection ? 'chrome-scope-v6' : 'chrome-scope-v5', operations: ['*'], configuration_digest: taskDigest, check: scope },
244
464
  'runtime:chrome-task.close': { revision: 'chrome-close-v1', operations: ['WorkTaskUpdate'], configuration_digest: taskDigest, check: close },
245
- ...(!legacy ? { 'runtime:chrome-task.artifact-hash': { revision: 'chrome-artifact-hash-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifacts: declaredTask.artifacts, contract_digest: hashOperation.digest }), check: artifactHash } } : {})
465
+ ...(!legacy ? { 'runtime:chrome-task.artifact-hash': { revision: retainedSource?'chrome-artifact-hash-v2':'chrome-artifact-hash-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifacts: declaredTask.artifacts, contract_digest: hashOperation.digest }), check: artifactHash } } : {})
246
466
  } });
247
467
  const project = snapshot => {
248
- const definitions = snapshot.definitions().filter(tool => PUBLIC.has(tool.name));
468
+ const definitions = snapshot.definitions().filter(tool => PUBLIC.has(tool.name) || retainedSource && CHROME_SOURCE_NAMES.includes(tool.name)).map(tool=>{
469
+ if(!pages||!['Read','Write','Edit',...(retainedSource?['SourceDraft']:[])].includes(tool.name))return tool;
470
+ const escaped=value=>value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
471
+ const view=structuredClone(tool);view.input_schema.properties.file_path.pattern='^('+escaped(brief.directory)+'/|'+escaped(directory)+'/).+$';
472
+ view.input_schema.properties.file_path.description='Accepted extension source path under '+brief.directory+'/. Host/plugin files are outside this task. '+(brief.fixture?'The fixture '+brief.fixture.file+' is read-only. ':'')+(suppliedDigest?'Supplied read-only inputs: '+brief.supplied_inputs.map(item=>item.file).join(', ')+'. ':'')+'Use existing source observations; reread only an unresolved file.';
473
+ return view;
474
+ });
249
475
  const offered = new Set(definitions.map(tool => tool.name));
250
476
  return { ...snapshot, definitions: () => definitions,
251
477
  execute: (name, input, options = {}) => {
252
478
  if (options.modelProposed && !offered.has(name)) throw failure('Use the offered domain actions; administration belongs to the house.', 'HOUSE_SURFACE_CHANGED');
253
- return snapshot.execute(name, input, options);
479
+ const modelSurface = [...offered].filter(name => !options.modelSurface || options.modelSurface.includes(name));
480
+ return snapshot.execute(name, input, options.modelProposed ? {...options,modelSurface} : options);
254
481
  }
255
482
  };
256
483
  };
484
+ const retirePermissionFacts = house.registerPermissionFacts(sessionId, async ({ operation, input, actor, parent_operation, signal }) => {
485
+ if (!pipeline || actor.kind !== 'agent') return null;
486
+ if (sourceEditFacts && ['Write','Edit'].includes(operation)) {
487
+ const native=house.registry.get(operation);
488
+ if (!native || native.contract.origin !== null || native.contract.permission !== operation || native.contract.effects.workspace !== 'write' || ['external','process','authority'].some(key=>native.contract.effects[key]!=='none')) return null;
489
+ // Scope is an observation here, never an authorization. The ordinary
490
+ // source/governance vetoes run again before execution, after the judge.
491
+ const observed=await scope({operation,input,actor,parent_operation,signal});
492
+ if (observed===false || observed?.allowed===false) return null;
493
+ const book=await store.load(sessionId);if(!book.journalAudit.verified)return null;
494
+ const accepted=savedChromeTask(book);if(accepted?.task_digest!==taskDigest)return null;
495
+ const value=await state();
496
+ const baseline=baselineSource?book.rows.findLast(row=>row.type==='chrome_baseline_observed'&&row.source==='workflow_host'&&row.task_digest===taskDigest&&row.source_digest===baselineSource):null;
497
+ const receipt=baseline?book.rows.findLast(row=>row.type==='house_receipt'&&row.operation==='ChromePublish'&&row.status==='returned'&&row.invocation_id===baseline.receipt_id&&row.output_digest===digest(row.output)):null;
498
+ let result;try{if(receipt)result=JSON.parse(receipt.output);}catch{}
499
+ const baselineVerified=Boolean(baseline&&receipt&&result?.task_digest===taskDigest&&result?.source_digest===baselineSource&&value.baseline_observed?.source_digest===baselineSource);
500
+ return {kind:'native_chrome_source_edit_observation',task_digest:taskDigest,governance_plan_hash:plan.plan_hash,project:brief.project,directory:brief.directory,file_path:input.file_path,source_role:'intermediate_implementation_candidate',criteria_origin:accepted.provenance?.criteria_origin||'explicit_host_contract',grants_permission:false,
501
+ source_scope:{status:'observed_in_scope',guard:'runtime:chrome-task.scope',symlink_aliases_excluded:true,immutable_fixture_excluded:true,rechecked_before_execution:true},
502
+ baseline_observation:baselineSource?{required:true,status:baselineVerified?'observed':'unverified',...(baselineVerified?{source_digest:baselineSource,receipt_id:receipt.invocation_id,observation_row:baseline.audit.sequence,observation_sha256:baseline.audit.sha256,passed:baseline.passed}: {})}:{required:false,status:'not_required'},
503
+ native_operation:{name:operation,contract_digest:native.digest,effects:structuredClone(native.contract.effects)},correctness:'unjudged'};
504
+ }
505
+ if (!['Deploy','Open','Interact','Message','Preview'].some(name=>operation===prefix+name) || !['ChromePublish','ChromeTest'].includes(parent_operation)) return null;
506
+ const value = await state();
507
+ if (!value.source_digest || manifestHash(await snapshot(signal)) !== value.source_digest) return null;
508
+ const book = await store.load(sessionId);
509
+ if (!book.journalAudit.verified) return null;
510
+ const accepted = savedChromeTask(book);
511
+ const facts = { kind:'native_chrome_task_resource_binding', task_digest:taskDigest, governance_plan_hash:plan.plan_hash, project:brief.project, directory:brief.directory, source_digest:value.source_digest, criteria_origin:accepted?.provenance?.criteria_origin || 'explicit_host_contract', grants_permission:false };
512
+ if (operation === prefix+'Deploy' && input.project === brief.project && input.directory === brief.directory && input.attempt_id === value.attempt_id) {const observed=book.rows.findLast(r=>r.type==='house_receipt'&&r.invocation_id===value.publication_namespace?.receipt_id&&r.operation===prefix+'Status'&&r.status==='returned'&&r.origin?.plugin==='chrome-lab');const fresh=input.create_only===true&&value.publication_namespace?.create_only&&observed&&!JSON.parse(observed.output).extensions_truncated&&!JSON.parse(observed.output).extensions.some(e=>e.project===brief.project);return { ...facts, publication_attempt:value.attempt_id,...(fresh?{publication_namespace:{status:'new_owned_project',observed_by:observed.invocation_id,observed_at:observed.timestamp}}:{}) };}
513
+ if (operation === prefix+'Open' && input.url === `chrome-extension://${value.deployment?.extension_id}/${brief.popup}` && /^[a-p]{32}$/.test(value.deployment.extension_id)) {
514
+ const publication = book.rows.findLast(r=>r.type==='house_receipt' && [prefix+'Deploy',prefix+'Deployment'].includes(r.operation) && r.status==='returned' && r.origin?.plugin==='chrome-lab' && JSON.parse(r.output).project===brief.project && JSON.parse(r.output).extension_id===value.deployment.extension_id && JSON.parse(r.output).file_manifest_sha256===value.source_digest);
515
+ if (publication) return { ...facts, observation_role:'primary_popup_criterion', criterion:{selector:brief.selector,equals:brief.equals}, ...(brief.fixture?{separate_fixture_criterion:{file:brief.fixture.file,selector:brief.fixture.selector,equals:brief.fixture.equals},page_steps_are_separate:true}:{}), extension_id:value.deployment.extension_id, url:input.url, observed_by:publication.invocation_id, observed_at:publication.timestamp, resource_identity:{status:'verified',extension_id_syntax:'valid_native_check',binding:'exact_project_publication_receipt_and_current_source'} };
516
+ }
517
+ const binding=input.target_id===value.fixture_binding?.target_id?value.fixture_binding:value.binding;
518
+ if (pages && brief.fixture && operation === prefix+'Interact' && parent_operation === 'ChromePublish' && value.stage === 'fixture_reload_pending' && input.action === 'reload' && !input.selector && binding === value.fixture_binding && binding?.identity && binding.project === brief.project && binding.fixture_source_digest === fixtureSource) {
519
+ const preview = book.rows.findLast(r=>r.type==='house_receipt'&&r.operation===prefix+'Preview'&&r.status==='returned'&&r.origin?.plugin==='chrome-lab'&&r.arguments_digest===digest({project:brief.project,file:brief.fixture.file})&&JSON.parse(r.output).target_id===binding.target_id&&JSON.parse(r.output).url===binding.url);
520
+ const observation = book.rows.findLast(r=>r.type==='house_receipt'&&r.operation===prefix+'Contexts'&&r.status==='returned'&&r.origin?.plugin==='chrome-lab'&&JSON.parse(r.output).target_id===binding.target_id&&digest(JSON.parse(r.output).identity)===digest(binding.identity));
521
+ const publication = book.rows.findLast(r=>r.type==='house_receipt'&&r.operation===prefix+'Deploy'&&r.status==='returned'&&r.origin?.plugin==='chrome-lab'&&JSON.parse(r.output).project===brief.project&&JSON.parse(r.output).extension_id===value.deployment?.extension_id&&JSON.parse(r.output).file_manifest_sha256===value.source_digest);
522
+ if (preview && observation && publication) return {...facts,observation_role:'publication_fixture_refresh',resource:binding,observed_by:observation.invocation_id,fixture_opened_by:preview.invocation_id,publication_receipt_id:publication.invocation_id,action:'reload',resource_identity:{status:'verified',binding:'exact_immutable_fixture_preview_current_document_and_own_publication'}};
523
+ }
524
+ if(pages&&brief.fixture&&operation===prefix+'Preview'&&input.project===brief.project&&input.file===brief.fixture.file)return {...facts,fixture:{file:brief.fixture.file,sha256:fixtureSource},action:'open_immutable_local_fixture'};
525
+ if (operation === prefix+'Interact' && binding?.identity && input.target_id === binding.target_id && brief.tests?.some(step=>(step.surface==='page'?value.fixture_binding?.target_id:value.binding?.target_id)===input.target_id&&step.action===input.action && step.selector===input.selector && (step.action!=='fill'||step.text===input.text))) {
526
+ const observation = book.rows.findLast(r=>r.type==='house_receipt' && r.operation===prefix+'Contexts' && r.status==='returned' && JSON.parse(r.output).target_id===input.target_id && digest(JSON.parse(r.output).identity)===digest(binding.identity));
527
+ if (observation) return { ...facts, resource:binding, observed_by:observation.invocation_id, observed_at:observation.timestamp, declared_test_digest:digest(brief.tests), declared_step:brief.tests.find(step=>(step.surface==='page'?value.fixture_binding?.target_id:value.binding?.target_id)===input.target_id&&step.action===input.action && step.selector===input.selector && (step.action!=='fill'||step.text===input.text)), action:input.action, selector:input.selector || null };
528
+ }
529
+ if (operation === prefix+'Message' && value.binding?.identity && input.target_id === value.binding.target_id && brief.tests?.some(step=>step.action==='message'&&step.request===input.request)) {
530
+ const observation=book.rows.findLast(r=>r.type==='house_receipt'&&r.operation===prefix+'Contexts'&&r.status==='returned'&&JSON.parse(r.output).target_id===input.target_id&&digest(JSON.parse(r.output).identity)===digest(value.binding.identity));
531
+ if(observation)return {...facts,resource:value.binding,observed_by:observation.invocation_id,observed_at:observation.timestamp,declared_test_digest:digest(brief.tests),declared_step:brief.tests.find(step=>step.action==='message'&&step.request===input.request),action:'message',request:input.request};
532
+ }
533
+ return null;
534
+ });
257
535
  const retireObservationRecovery=house.registerRecovery(sessionId,'ChromeRecover',async()=>({kind:'chrome_observation_retry'}));
258
536
  const retireRecovery=house.registerRecovery(sessionId,'ChromePublish',async({proposal,executor:recoveryExecutor,signal})=>{
259
537
  const before=await store.load(sessionId), checkpointRef=before.rows.findLastIndex(r=>r.type==='chrome_task_checkpoint' && r.source==='workflow_host' && r.task_digest===taskDigest);
@@ -265,9 +543,36 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
265
543
  if(observationRef<0 || reconciliationRef<0)throw failure('La reconciliación no quedó asentada.', 'CHROME_TASK_UNCERTAIN');
266
544
  return {checkpoint_ref:checkpointRef,observation_ref:observationRef,reconciliation_ref:reconciliationRef};
267
545
  });
268
- return { task: brief, taskDigest, plan,
269
- system: `Host-managed Chrome task: ${JSON.stringify(brief)}. Policy ${plan.plan_hash}. Work is accepted and active. Read/Edit/Write only its source. ChromePublish deploys and observes; ChromeVerify observes without publication; ${brief.tests ? 'ChromeTest executes the declared behavior tests; ' : ''}ChromeFinish executes the declared check and closes. After a publication/connection interruption, use ChromeRecover to reconcile by reads; never repeat uncertain publication. If it returns recovered, ChromePublish continues without Deploy. Uncertain test interactions remain blocked. No administrative IDs or criteria to supply. Correct failed criteria and publish again. Refusal, uncertainty and pauses are not delivery. Give your final report only after ChromeFinish returns done.`,
546
+ return { task: brief, taskDigest, plan, pinnedSchemas: retainedSource?[...CHROME_SOURCE_NAMES]:[],
547
+ system: `Host-managed Chrome task: ${JSON.stringify(brief)}. ${suppliedDigest?'Supplied inputs are existing immutable assets. Read them if needed; implement only mutable application files. Their identity is enforced by the house, not an instruction to repair them. ':''} Policy ${plan.plan_hash}. Work is accepted and active. Read/Edit/Write only its source. ${retainedSource?'SourceDraft/Retain/Read/Publish are offered directly for bounded source in this task: retain oversized proposals without regeneration, then publish their sealed bytes. This changes local candidate source, not Chrome delivery or permissions. No ToolPack is needed. ':''}${pipeline ? 'After all source files are ready, prefer ChromeDeliver: commit the complete candidate once; the house publishes, tests and closes. If it returns needs_correction, fix source from its evidence and commit again. No separate ChromeTest/ChromeFinish or extra speculative reread is required. ' : ''} ChromePublish deploys and observes; ChromeVerify observes without publication; ${inspection ? 'ChromeInspect reads live own-popup DOM or console without target IDs; observations do not pass checks. ' : ''}${brief.tests ? 'ChromeTest executes the declared behavior tests; ' : ''}ChromeFinish executes the declared check and closes. After a publication/connection interruption, use ChromeRecover to reconcile by reads; never repeat uncertain publication. If it returns recovered, ChromePublish continues without Deploy. Uncertain test interactions remain blocked. No administrative IDs or criteria to supply. Correct failed criteria and publish again. Refusal, uncertainty and pauses are not delivery. Give your final report only after ChromeFinish returns done.`,
270
548
  wrap: tools => ({ ...tools, house, policy, workflow: 'chrome-task', definitions: () => project(tools.snapshot()).definitions(), snapshot: () => project(tools.snapshot()), execute: (name,input,options) => project(tools.snapshot()).execute(name,input,options) }),
549
+ async prepareBaseline({signal}={}) {
550
+ if(!baselineSource||(await state()).baseline_observed)return null;
551
+ if(manifestHash(await snapshot(signal))!==baselineSource)throw failure('The initial source changed before its requested observation.','CHROME_TASK_STALE_SOURCE');
552
+ const waiting = async () => {
553
+ const current=await state(),book=await store.load(sessionId);
554
+ if(book.rows.some(r=>r.type==='chrome_baseline_waiting'&&r.attempt_id===current.attempt_id))return null;
555
+ await store.append(sessionId,{type:'chrome_baseline_waiting',source:'workflow_host',task_digest:taskDigest,attempt_id:current.attempt_id,source_digest:baselineSource,asked:'unjudged'});
556
+ return {baseline_completed:false,status:'requires_recovery',attempt_id:current.attempt_id,next_action:'ChromeRecover',note:'The initial publication may have executed. No effect was replayed; reconcile before source edits.'};
557
+ };
558
+ const current=await state();if(current.stage.endsWith('_pending')||current.status==='uncertain')return waiting();
559
+ const callId=randomUUID();let result;
560
+ try { result=JSON.parse(await executor.execute('ChromePublish',{}, {signal,callId})); }
561
+ catch(error){if(signal?.aborted||['HOUSE_REFUSED','HOUSE_GUARD','GOVERNANCE_REFUSED'].includes(error.code))throw error;const after=await state();if(after.stage.endsWith('_pending')||after.status==='uncertain')return waiting();throw error;}
562
+ const book=await store.load(sessionId),receipt=book.rows.findLast(r=>r.type==='house_receipt'&&r.call_id===callId&&r.operation==='ChromePublish'&&r.status==='returned');
563
+ if(!receipt||!(await state()).baseline_observed)throw failure('Initial observation has no durable receipt.','CHROME_TASK_UNCERTAIN');
564
+ await store.append(sessionId,{type:'chrome_baseline_observed',source:'workflow_host',task_digest:taskDigest,source_digest:baselineSource,receipt_id:receipt.invocation_id,passed:result.status==='verified',asked:'unjudged'});
565
+ return {baseline_completed:true,status:result.status,source_digest:baselineSource,receipt_id:receipt.invocation_id,observation:result.observed,diagnostics:result.diagnostics};
566
+ },
567
+ async completedResult(calls = []) {
568
+ if (!pipeline || !calls.some(c=>c.name==='ChromeDeliver')) return null;
569
+ const book = await store.load(sessionId);
570
+ const receipt = book.rows.findLast(r=>r.type==='house_receipt' && r.operation==='ChromeDeliver' && r.status==='returned' && calls.some(c=>c.id===r.call_id&&c.name==='ChromeDeliver'));
571
+ if (!receipt || !book.journalAudit.verified || workProjection(book)?.counts.done !== 1) return null;
572
+ const result = JSON.parse(receipt.output);
573
+ if (result.status !== 'done' || result.task_digest !== taskDigest || manifestHash(await snapshot()) !== result.source_digest) return null;
574
+ return { terminal:true, result:`Chrome candidate verified: ${brief.directory}/${brief.popup}.\nDeclared ${brief.tests ? `${brief.tests.length} behavior steps` : 'DOM criterion'} passed; source snapshot ${result.source_digest}.\nEvidence: ${result.receipt_id}. Human acceptance remains unjudged.`, receipt_id:receipt.invocation_id };
575
+ },
271
576
  async finishResult(final, context) {
272
577
  let delivery = { status: 'open', task_digest: taskDigest, governance_plan_hash: plan.plan_hash, asked: 'unjudged' };
273
578
  const session = await store.load(sessionId);
@@ -287,6 +592,6 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
287
592
  final.delivery = delivery;
288
593
  await store.append(sessionId, { type: 'chrome_task_delivery', source: 'workflow_host', ...delivery });
289
594
  },
290
- dispose: () => {retireRecovery();retireObservationRecovery(); house.retireGovernance(sessionId); house.registry.replace(OWN, []); }
595
+ dispose: () => {sourceAccess?.dispose();retirePermissionFacts();retireRecovery();retireObservationRecovery(); house.retireGovernance(sessionId); house.registry.replace(OWN, []); }
291
596
  };
292
597
  }