Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 335 additions & 0 deletions .planning/SAFARI-PORT-PLAN.md

Large diffs are not rendered by default.

176 changes: 98 additions & 78 deletions esbuild.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,79 @@ const SRC_ROOT = path.join(REPO_ROOT, 'extension');
const SHOWCASE_ROOT = path.join(REPO_ROOT, 'showcase');
const OUT_ROOT = path.join(REPO_ROOT, 'extension', 'dist');

// UAT-1 fix (2026-05-31): Lattice's dist/index.js top-level imports node:fs/promises,
// node:path, node:url, etc. for its artifact-storage submodule. Our offscreen import
// surface (checkpoint/signer/survivability) does NOT exercise those code paths at
// runtime, but ESM top-level imports cannot be tree-shaken merely by marking them
// external -- esbuild preserves the import specifier verbatim, and Chrome MV3 CSP
// (script-src 'self') rejects any surviving `node:*` import in the offscreen bundle.
//
// Fix: resolve every node:* specifier to a local stub module at build time via an
// inline esbuild plugin. The stub exports no-op shims for the fs / path / url surface
// Lattice's artifact-storage references. Dead code paths from artifact-storage stay
// in the bundle but call into local no-ops; no CSP-blocked imports survive in output.
const LATTICE_BUFFER_BANNER = [
'// Buffer polyfill for receipts/envelope.ts base64 encoding (UAT-08 fix; lattice-side',
'// uses Buffer.from(bytes).toString("base64") which Node provides but the offscreen',
'// browser context does not. INV-06 byte-freeze stays intact -- fix is build-side, not Lattice-side.',
'if (typeof globalThis.Buffer === "undefined") {',
' globalThis.Buffer = {',
' from: function (input, encoding) {',
' if (typeof input === "string" && encoding === "base64") {',
' var bin = atob(input);',
' var bytes = new Uint8Array(bin.length);',
' for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);',
' return bytes;',
' }',
' if (input instanceof Uint8Array || (input && typeof input.length === "number" && typeof input !== "string")) {',
' var bytes = input;',
' return {',
' toString: function (enc) {',
' if (enc === "base64") {',
' var s = "";',
' for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xFF);',
' return btoa(s);',
' }',
' throw new Error("Buffer polyfill: unsupported toString encoding: " + enc);',
' }',
' };',
' }',
' throw new Error("Buffer polyfill: unsupported Buffer.from() input");',
' }',
' };',
'}'
].join("\n");

/**
* Fresh plugin instance per build: esbuild mutates plugin state during a
* build, so the ESM and IIFE Lattice entries must not share one object.
*/
function stubNodeBuiltinsPlugin() {
return {
name: 'stub-node-builtins',
setup(build) {
build.onResolve({ filter: /^node:/ }, () => ({
path: 'node-stub',
namespace: 'node-stub-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'node-stub-ns' }, () => ({
contents: [
'export default {};',
'export const join = (...p) => p.filter(Boolean).join("/");',
'export const fileURLToPath = (u) => String(u);',
'export const mkdir = async () => undefined;',
'export const readFile = async () => "";',
'export const readdir = async () => [];',
'export const rm = async () => undefined;',
'export const stat = async () => ({});',
'export const writeFile = async () => undefined;',
].join('\n'),
loader: 'js',
}));
},
};
}

/**
* Each entry is one bundle. The shape matches what esbuild.build accepts as
* a single-build-call configuration so each entry runs as its own build (we
Expand All @@ -65,97 +138,44 @@ const OUT_ROOT = path.join(REPO_ROOT, 'extension', 'dist');
*/
const ENTRIES = [
{
name: 'offscreen-stt',
entryPoints: [path.join(SRC_ROOT, 'offscreen', 'stt.js')],
outfile: path.join(OUT_ROOT, 'offscreen', 'stt.js'),
format: 'iife',
name: 'offscreen-lattice-host',
entryPoints: [path.join(SRC_ROOT, 'offscreen', 'lattice-host.js')],
outfile: path.join(OUT_ROOT, 'offscreen', 'lattice-host.js'),
format: 'esm',
sourcemap: 'external',
platform: 'browser',
target: ['chrome120'],
bundle: true,
legalComments: 'none',
allowOverwrite: true,
banner: { js: LATTICE_BUFFER_BANNER },
plugins: [stubNodeBuiltinsPlugin()],
},
{
name: 'offscreen-lattice-host',
// Safari port: the SAME Lattice host, bundled as IIFE instead of ESM.
//
// MV3 forbids importScripts() inside a "type":"module" service worker, and
// background.js has 305 such call sites, so the SW can never be a module.
// Chrome sidesteps this with an offscreen document (which CAN use
// <script type="module">); Safari has no chrome.offscreen at all. The
// bundle was verified to need no DOM -- zero createObjectURL / Blob /
// window. / document.createElement / localStorage / Worker / indexedDB /
// XMLHttpRequest hits across 413 KB -- so IIFE loaded into the SW suffices.
//
// The ESM entry above STAYS: Chrome still ships it, and
// tests/lattice-provider-bridge-smoke.test.js reads the ESM source.
name: 'offscreen-lattice-host-iife',
entryPoints: [path.join(SRC_ROOT, 'offscreen', 'lattice-host.js')],
outfile: path.join(OUT_ROOT, 'offscreen', 'lattice-host.js'),
format: 'esm',
sourcemap: 'external',
outfile: path.join(OUT_ROOT, 'offscreen', 'lattice-host.iife.js'),
format: 'iife',
sourcemap: false,
platform: 'browser',
target: ['chrome120'],
target: ['safari18'],
bundle: true,
legalComments: 'none',
allowOverwrite: true,
// UAT-1 fix (2026-05-31): Lattice's dist/index.js top-level imports node:fs/promises,
// node:path, node:url, etc. for its artifact-storage submodule. Our offscreen import
// surface (checkpoint/signer/survivability) does NOT exercise those code paths at
// runtime, but ESM top-level imports cannot be tree-shaken merely by marking them
// external -- esbuild preserves the import specifier verbatim, and Chrome MV3 CSP
// (script-src 'self') rejects any surviving `node:*` import in the offscreen bundle.
//
// Fix: resolve every node:* specifier to a local stub module at build time via an
// inline esbuild plugin. The stub exports no-op shims for the fs / path / url surface
// Lattice's artifact-storage references. Dead code paths from artifact-storage stay
// in the bundle but call into local no-ops; no CSP-blocked imports survive in output.
banner: {
js: [
'// Buffer polyfill for receipts/envelope.ts base64 encoding (UAT-08 fix; lattice-side',
'// uses Buffer.from(bytes).toString("base64") which Node provides but the offscreen',
'// browser context does not. INV-06 byte-freeze stays intact -- fix is build-side, not Lattice-side.',
'if (typeof globalThis.Buffer === "undefined") {',
' globalThis.Buffer = {',
' from: function (input, encoding) {',
' if (typeof input === "string" && encoding === "base64") {',
' var bin = atob(input);',
' var bytes = new Uint8Array(bin.length);',
' for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);',
' return bytes;',
' }',
' if (input instanceof Uint8Array || (input && typeof input.length === "number" && typeof input !== "string")) {',
' var bytes = input;',
' return {',
' toString: function (enc) {',
' if (enc === "base64") {',
' var s = "";',
' for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] & 0xFF);',
' return btoa(s);',
' }',
' throw new Error("Buffer polyfill: unsupported toString encoding: " + enc);',
' }',
' };',
' }',
' throw new Error("Buffer polyfill: unsupported Buffer.from() input");',
' }',
' };',
'}'
].join("\n"),
},
plugins: [
{
name: 'stub-node-builtins',
setup(build) {
build.onResolve({ filter: /^node:/ }, () => ({
path: 'node-stub',
namespace: 'node-stub-ns',
}));
build.onLoad({ filter: /.*/, namespace: 'node-stub-ns' }, () => ({
contents: [
'export default {};',
'export const join = (...p) => p.filter(Boolean).join("/");',
'export const fileURLToPath = (u) => String(u);',
'export const mkdir = async () => undefined;',
'export const readFile = async () => "";',
'export const readdir = async () => [];',
'export const rm = async () => undefined;',
'export const stat = async () => ({});',
'export const writeFile = async () => undefined;',
].join('\n'),
loader: 'js',
}));
},
},
],
banner: { js: LATTICE_BUFFER_BANNER },
plugins: [stubNodeBuiltinsPlugin()],
},
{
name: 'content-canvas-interceptor',
Expand Down
26 changes: 26 additions & 0 deletions extension/ai/tool-executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,32 @@ async function executeContentTool(tool, params, tabId) {
* @returns {Promise<Object>} Structured result
*/
async function executeCdpTool(tool, params, tabId, cdpHandler) {
// Safari has no chrome.debugger, so there is no trusted-input path for the
// seven _route:'cdp' tools. Rather than hard-fail them, route to the DOM
// equivalents in content/actions.js. Those return trusted:false + degraded:
// true, so the agent still sees honestly that the input was untrusted.
//
// The mapping lives in utils/platform-adapter.js rather than on the tool
// definitions because tests/tool-definitions-parity.test.js pins a SHA-256
// over the whole registry (cross-checked against mcp/ai/tool-definitions.cjs);
// adding a field there would break both files. For the same reason the tool
// object is SHALLOW-CLONED here -- mutating the shared registry entry would
// change that hash at runtime.
//
// globalThis.FsbPlatform is undefined on Chrome and in the Node test
// harnesses, so Chrome always falls through to the cdpHandler path below.
const platform = globalThis.FsbPlatform;
if (platform && platform.caps && platform.caps.trustedInput === false) {
const domVerb = platform.CDP_DOM_FALLBACKS && platform.CDP_DOM_FALLBACKS[tool._cdpVerb];
if (domVerb) {
return executeContentTool(Object.assign({}, tool, { _contentVerb: domVerb }), params, tabId);
}
return makeResult({
success: false,
error: `capability_unavailable: ${tool.name} requires chrome.debugger, which this browser does not provide`
});
}

if (typeof cdpHandler !== 'function') {
return makeResult({
success: false,
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -19667,6 +19667,72 @@ async function executeUploadFileUnlocked(tabId, selector, filePath, options = {}
fileName = denylist.basenameOf(filePath);
automationLogger.logActionExecution(null, 'cdpUploadFile', 'start', { tabId, selector, file: fileName });

// Safari has no CDP, so DOM.setFileInputFiles does not exist. The container
// app reads the bytes -- but ONLY inside a folder the user granted, because
// App Sandbox forbids reading arbitrary absolute paths -- and a content
// script sets them on the input via DataTransfer.
//
// This sits AFTER the denylist + audit gate above on purpose: a denied path
// must never reach the native host. The bookmark containment check in the
// app is a SECOND, independent constraint, not a replacement for the gate.
//
// globalThis.FsbPlatform is undefined on Chrome and in the chokepoint test
// harness, so Chrome always continues to the CDP path below.
if (globalThis.FsbPlatform && globalThis.FsbPlatform.caps && globalThis.FsbPlatform.caps.cdp === false) {
const reader = globalThis.FsbNativeFileReader;
if (!reader || typeof reader.readFile !== 'function') {
automationLogger.logActionExecution(null, 'cdpUploadFile', 'complete', { success: false, tabId, blocked: true, reason: 'native-reader-unavailable' });
await audit('blocked', 'native-reader-unavailable', null);
return { success: false, error: 'upload_file blocked: the native file reader is unavailable', reason: 'native-reader-unavailable' };
}

const read = await reader.readFile(filePath);
if (!read || read.ok !== true) {
const readReason = (read && read.reason) ? read.reason : 'native-read-failed';
automationLogger.logActionExecution(null, 'cdpUploadFile', 'complete', { success: false, tabId, blocked: true, reason: readReason });
await audit('blocked', readReason, null);
return {
success: false,
error: 'upload_file blocked: ' + ((read && read.message) ? read.message : 'the file could not be read'),
reason: readReason
};
}

// Inject + send once, deliberately NOT sendMessageWithRetry: a retry after
// a lost reply would set the file again and fire a second change event,
// which on auto-upload inputs is a double upload. frameId 0 because that
// is the only frame the content scripts are injected into.
if (typeof ensureContentScriptInjected === 'function') {
await ensureContentScriptInjected(tabId);
}
const applied = await chrome.tabs.sendMessage(tabId, {
action: 'executeAction',
tool: 'domSetFileInput',
params: { selector, name: read.name, mime: read.mime, dataB64: read.dataB64 }
}, { frameId: 0 });
if (!applied || applied.success === false) {
const appliedMsg = (applied && applied.error) ? applied.error : 'the page did not accept the file';
automationLogger.logActionExecution(null, 'cdpUploadFile', 'complete', { success: false, tabId, error: redactPathForUploadLog(appliedMsg) });
await audit('error', 'allow', null);
return { success: false, error: 'upload_file failed: ' + redactPathForUploadLog(appliedMsg) };
}

automationLogger.logActionExecution(null, 'cdpUploadFile', 'complete', { success: true, tabId, selector, file: fileName });
await audit('success', 'allow', null);
// trusted:false is not cosmetic -- a site gating on event.isTrusted will
// reject this even though input.files is genuinely populated.
return {
success: true,
method: 'dom_set_file_input',
selector,
file: fileName,
size: read.size,
trusted: false,
degraded: true,
hadEffect: true
};
}

if (keyboardEmulator && keyboardEmulator.isAttachedTo(tabId)) {
await keyboardEmulator.detachDebugger(tabId);
}
Expand Down
Loading
Loading