From 9b8879f4a8e9f93ceb20136b93dd68043d35bd11 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 6 Oct 2026 13:39:24 +0000 Subject: [PATCH 01/43] fix(check): report unresolved authored helper credentials as warnings --- docs/SLACK-HELPER.md | 8 +- docs/SURFACE.md | 7 ++ packages/sdk/src/cli.ts | 26 ++-- packages/sdk/src/cli/check-helper-body.ts | 12 +- packages/sdk/src/cli/check-helper-surface.ts | 39 ++++++ packages/sdk/src/cli/check-triggers.ts | 13 +- packages/sdk/src/cli/check.ts | 2 + packages/sdk/src/failure-kinds.ts | 4 +- packages/sdk/src/helper-preflight.ts | 4 +- .../sdk/tests/authored-flow-slack.test.ts | 15 +-- .../sdk/tests/check-helper-surface.test.ts | 113 ++++++++++++++++++ 11 files changed, 211 insertions(+), 32 deletions(-) create mode 100644 packages/sdk/src/cli/check-helper-surface.ts create mode 100644 packages/sdk/tests/check-helper-surface.test.ts diff --git a/docs/SLACK-HELPER.md b/docs/SLACK-HELPER.md index 84fb65f21..3fd973e3c 100644 --- a/docs/SLACK-HELPER.md +++ b/docs/SLACK-HELPER.md @@ -16,9 +16,11 @@ export default flow('notify', async f => { ``` Provide a relayfile mount with a `slack/` directory. -`flows check notify.flow.ts` refuses visible Slack calls when neither a mount -nor a bot token is available, with `REFUSED [helper_slack.credential_missing]` -and exit status 2. +`flows check notify.flow.ts` reports Slack under `REQUIRES` and warns +`helper_credential_unresolved` when no local mount is available. +Local `flows run` still refuses with `helper_slack.credential_missing` and +exit status 2 before executing the body. `flows schedule`, `flows deploy`, +and `flows run --cloud` check the workspace integration at submit. Direct bot-token transport is deferred: a token without a mount is refused as `helper_slack.mount_required`, before running the body. A header can declare `tools: { slack: true }` for calls hidden behind another diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 29c6887f3..89a9d962c 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1393,6 +1393,13 @@ because it is a footnote to that line. It never refuses, and it is scoped to `.flow.ts` is checked through its header without compiling step bodies, so it has no agent steps to count. +Missing local helper mounts similarly produce `helper_credential_unresolved` +on `flows check`, after `REQUIRES` and before the result. Cloud submission +(`schedule`, `deploy`, `run --cloud`) checks the workspace integration and +refuses if Cloud cannot connect it. Local `run` still requires a mount or the +provider's mock mode. Unsupported helper operations remain refusals. +Non-`.flow.*` authored modules also warn, but have no `REQUIRES` line. + `flows check --watch` checks once, then watches the target, its reachable relative `use:` imports, and the nearest `flows.json` walking up from the flow directory. Saves are debounced for 150 ms; a change during a check diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 7989f596e..108b8eadd 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -445,7 +445,8 @@ export async function runCli( * aspects' coverage. */ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckReport }> { - const helper = await checkHelperBody(path); + const invocation = { warnUnresolvedHelperCredential: true }; + const helper = await checkHelperBody(path, invocation); if (!helper.report.ok) return helper; // The activity checker loads the TypeScript compiler. YAML checks and // unrelated CLI commands should not pay that startup cost on every run. @@ -454,9 +455,11 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR if (!activities.report.ok) return activities; const mcp = await checkTypeScriptFlow(path); const triggers = isAuthoredFlowPath(path) - ? await checkAuthoredTriggers(path) + ? await checkAuthoredTriggers(path, invocation) : undefined; const triggerDiagnostics = triggers?.report.diagnostics ?? []; + // The trigger leg already includes the same helper diagnostics. + const helperDiagnostics = triggers === undefined ? helper.report.diagnostics : []; const triggerOk = triggers?.report.ok ?? true; return { report: { @@ -467,7 +470,7 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR // The authored definition sees helper flags, body use and `cli:` // declarations; the compiled view underneath knows only its steps. ...(triggers?.report.requirements === undefined ? {} : { requirements: triggers.report.requirements }), - diagnostics: [...helper.report.diagnostics, ...activities.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics], + diagnostics: [...helperDiagnostics, ...activities.report.diagnostics, ...mcp.report.diagnostics, ...triggerDiagnostics], ok: helper.report.ok && activities.report.ok && mcp.report.ok && triggerOk, }, }; @@ -1034,15 +1037,12 @@ function parseTickArgs(rest: readonly string[]): ParsedArgs | undefined { } /** - * `agent_worker_unresolved` reads as a footnote to `REQUIRES codex (step - * "implement"), …`: that line already names the steps that need an agent - * worker, and this says what has to be true for one to be attached. So the - * plain-text pass holds it back and emits it in that position, exactly once — - * the leading batch below skips it rather than printing it twice. JSON mode - * returns before any of this and keeps the single ordered diagnostics array. + * Worker and helper warnings annotate REQUIRES. Hold them back until that + * line, then emit each once. JSON keeps the single diagnostics array. */ -function isWorkerSurfaceWarning(diagnostic: CheckReport['diagnostics'][number]): boolean { - return diagnostic.kind === 'agent_worker_unresolved'; +function isRequiresFootnote(diagnostic: CheckReport['diagnostics'][number]): boolean { + return diagnostic.kind === 'agent_worker_unresolved' + || diagnostic.kind === 'helper_credential_unresolved'; } const MODEL_PROVENANCE: Readonly> = { @@ -1062,8 +1062,8 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo): void { io.stdout(JSON.stringify(report)); return; } - const deferred = report.diagnostics.filter(isWorkerSurfaceWarning); - emitDiagnostics(report.diagnostics.filter((diagnostic) => !isWorkerSurfaceWarning(diagnostic)), io); + const deferred = report.diagnostics.filter(isRequiresFootnote); + emitDiagnostics(report.diagnostics.filter((diagnostic) => !isRequiresFootnote(diagnostic)), io); for (const gate of report.gates) { // A gate that accepts every output is legal, but it must not read like a // gate that judges something. diff --git a/packages/sdk/src/cli/check-helper-body.ts b/packages/sdk/src/cli/check-helper-body.ts index 5a156400c..8f953b657 100644 --- a/packages/sdk/src/cli/check-helper-body.ts +++ b/packages/sdk/src/cli/check-helper-body.ts @@ -1,13 +1,19 @@ import { loadAuthoredFlow } from '../authored-flow-loader.js'; import { PluginError } from '../plugin-manifest.js'; import { checkSlackHelpers } from '../slack-preflight.js'; -import { inputFailureReport, type CheckExecution } from './check.js'; +import { inputFailureReport, type CheckInvocation, type CheckExecution } from './check.js'; + +import { helperCredentialDiagnostics } from './check-helper-surface.js'; /** Imports the definition, but never executes arbitrary authored body code. */ -export async function checkHelperBody(path: string): Promise { +export async function checkHelperBody(path: string, invocation: CheckInvocation = {}): Promise { try { const { handle, getDefinition } = await loadAuthoredFlow(path); - return { report: { ...checkSlackHelpers(getDefinition(handle)), path } }; + const report = checkSlackHelpers(getDefinition(handle)); + const diagnostics = invocation.warnUnresolvedHelperCredential === true + ? helperCredentialDiagnostics(report.diagnostics).diagnostics : report.diagnostics; + return { report: { ...report, path, diagnostics, + ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal') } }; } catch (error) { if (error instanceof PluginError) { return { report: inputFailureReport({ kind: error.code, message: error.message }, path) }; diff --git a/packages/sdk/src/cli/check-helper-surface.ts b/packages/sdk/src/cli/check-helper-surface.ts new file mode 100644 index 000000000..2df3abb46 --- /dev/null +++ b/packages/sdk/src/cli/check-helper-surface.ts @@ -0,0 +1,39 @@ +import { helperProviders } from '@relayflows/surface/runtime'; +import type { PreflightDiagnostic } from '../preflight.js'; +import type { CheckWarningDiagnostic } from './check.js'; + +const mountKinds = new Set([ + 'helper_slack.credential_missing', 'helper_slack.mount_required', + 'helper_provider.mount_required', 'helper_mount_required', +]); + +/** Restate local mount failures for inspection only; execution stays strict. */ +export function helperCredentialDiagnostics(diagnostics: readonly PreflightDiagnostic[]): { + diagnostics: Array; + downgraded: boolean; +} { + const result: Array = []; + const seen = new Set(); + for (const diagnostic of diagnostics) { + const helper = diagnostic.severity === 'refusal' && mountKinds.has(diagnostic.kind) + ? helperProviders.find(({ namespace, provider }) => + diagnostic.message.startsWith(`f.${namespace} requires `) + || diagnostic.message.startsWith(`${provider} helper requires `)) + : undefined; + // Unknown diagnostic shapes remain refusals rather than silently passing. + if (helper === undefined) { + result.push(diagnostic); + continue; + } + if (seen.has(helper.provider)) continue; + seen.add(helper.provider); + result.push({ + severity: 'warning', kind: 'helper_credential_unresolved', + message: `f.${helper.namespace} needs a ${helper.provider} mount, which is not available locally. ` + + 'flows schedule / flows deploy / flows run --cloud check the integration against your workspace at submit and refuse if Cloud cannot connect it. ' + + `A local flows run needs a relayfile ${helper.provider} mount (a ${helper.provider}/ directory under RELAYFILE_MOUNT_PATH) ` + + `or ${helper.mockEnv}=1, and refuses with [${diagnostic.kind}] without one.`, + }); + } + return { diagnostics: result, downgraded: seen.size > 0 }; +} diff --git a/packages/sdk/src/cli/check-triggers.ts b/packages/sdk/src/cli/check-triggers.ts index d938fb7f0..47f7d86c1 100644 --- a/packages/sdk/src/cli/check-triggers.ts +++ b/packages/sdk/src/cli/check-triggers.ts @@ -7,16 +7,19 @@ import { scheduleLowering } from '../schedule-trigger.js'; import { checkSlackHelpers } from '../slack-preflight.js'; import { flowRequirements, mergeFlowExtensionRequirements } from '../flow-requirements.js'; import { PluginError } from '../plugin-manifest.js'; -import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js'; +import { inputFailureReport, readProjectConfig, type CheckInvocation, type CheckReport } from './check.js'; + +import { helperCredentialDiagnostics } from './check-helper-surface.js'; /** * Inspect an authored flow without running a handler or contacting the daemon. * Combines the two authored-only preflight paths that share a loaded flow: the * webhook-trigger check (E) and the f.slack helper check (B). Skipping either * turned this into a silent trapdoor -- a `.flow.ts` using `f.slack.post` - * without a token would pass `flows check` and only crash at run. + * without a mount would silently pass inspection. Check may opt into a + * warning; local run keeps the default refusal before daemon attachment. */ -export async function checkAuthoredTriggers(path: string): Promise<{ +export async function checkAuthoredTriggers(path: string, invocation: CheckInvocation = {}): Promise<{ report: CheckReport; loaded?: LoadedAuthoredFlow; }> { @@ -33,8 +36,10 @@ export async function checkAuthoredTriggers(path: string): Promise<{ // refused there, on the first real event. const providerDiagnostics = preflightProviderTriggers(triggers); const helperReport = checkSlackHelpers(definition); + const helperDiagnostics = invocation.warnUnresolvedHelperCredential === true + ? helperCredentialDiagnostics(helperReport.diagnostics).diagnostics : helperReport.diagnostics; const diagnostics = [ - ...triggerDiagnostics, ...providerDiagnostics, ...helperReport.diagnostics, + ...triggerDiagnostics, ...providerDiagnostics, ...helperDiagnostics, ]; // A schedule is inspectable data: print what it lowers to, and say plainly // when the local runner cannot drive it. Neither is a refusal — Cloud can. diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index cc6c42779..11a654e80 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -125,6 +125,8 @@ type CliProbeOutcomeMap = Map; * anything that depends on how the flow is about to be invoked opts in here. */ export interface CheckInvocation { + /** Only flows check opts into reporting missing local helper mounts as warnings. */ + warnUnresolvedHelperCredential?: boolean; /** Environment used only by provider executable/auth/model probes. */ environment?: NodeJS.ProcessEnv; /** diff --git a/packages/sdk/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index 7a05c98a2..bd18e1d90 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -172,7 +172,9 @@ export const RUN_WARNING_KINDS = [ * daemon-free, can see none either — so it opts in, while `flows run`, `flows * build` and SDK submissions do not (cli/check-worker-surface.ts). */ -export const CHECK_WARNING_KINDS = ['editor_schema_missing', 'agent_worker_unresolved'] as const; +/** helper_credential_unresolved describes a local mount gap during inspection; + * Cloud validates workspace integrations at submit, and local execution refuses. */ +export const CHECK_WARNING_KINDS = ['editor_schema_missing', 'agent_worker_unresolved', 'helper_credential_unresolved'] as const; export type CheckWarningKind = (typeof CHECK_WARNING_KINDS)[number]; export type PreflightFailureKind = (typeof PREFLIGHT_FAILURE_KINDS)[number]; diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index 112192c2a..b7f8b3476 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -32,7 +32,9 @@ export function preflightHelpers( diagnostics.push({ severity: 'refusal', kind: provider === 'slack' ? (fact.token?.trim() ? 'helper_slack.mount_required' : 'helper_slack.credential_missing') : 'helper_provider.mount_required', message: `f.${namespace} requires a relayfile ${provider} mount; direct-token transport is not implemented.` }); - } else if (provider === 'notion' && !fact.mock && /\.\s*appendBlock\b/.test(body)) { + } + // Missing local mounts must not hide an operation unsupported on Cloud too. + if (supported && provider === 'notion' && !fact.mock && /\.\s*appendBlock\b/.test(body)) { diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' }); } diff --git a/packages/sdk/tests/authored-flow-slack.test.ts b/packages/sdk/tests/authored-flow-slack.test.ts index 9a2e33853..5ea60f117 100644 --- a/packages/sdk/tests/authored-flow-slack.test.ts +++ b/packages/sdk/tests/authored-flow-slack.test.ts @@ -120,7 +120,7 @@ describe('authored Slack helper effects', () => { expect(network).not.toHaveBeenCalled(); }); - it('refuses missing credentials before running the body and flows check exits 2', async () => { + it('refuses missing credentials before running the body while flows check warns', async () => { for (const key of ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFILE_MOUNT_PATH', 'WORKSPACE_ROOT', 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT']) vi.stubEnv(key, ''); let executed = false; const handle = flow('no-credential', async f => { executed = true; await f.slack.post('#test', 'hi'); f.done('success'); }); @@ -132,8 +132,8 @@ describe('authored Slack helper effects', () => { const fixture = join(dataDir, 'slack.mjs'); writeFileSync(fixture, `import { flow } from ${JSON.stringify(join(root, 'packages/sdk/node_modules/@relayflows/surface/dist/index.js'))};\nexport default flow('slack', async f => { await f.slack.post('#test', 'hi'); f.done('success'); });`); const output: string[] = []; - expect(await runCli(['check', '--json', fixture], { stdout: line => output.push(line), stderr: line => output.push(line) })).toBe(2); - expect(output.join('')).toContain('helper_slack.credential_missing'); + expect(await runCli(['check', '--json', fixture], { stdout: line => output.push(line), stderr: line => output.push(line) })).toBe(0); + expect(output.join('')).toContain('helper_credential_unresolved'); vi.stubEnv('SLACK_BOT_TOKEN', 'configured-token'); await expect(executeAuthoredFlow(handle, new JournalClient('/must-not-connect'))).rejects.toMatchObject({ code: 'helper_slack.mount_required' }); expect(executed).toBe(false); @@ -142,12 +142,13 @@ describe('authored Slack helper effects', () => { }).ok).toBe(true); }); - it('surfaces helper_slack.credential_missing on `.flow.ts` paths too, not just helper modules', async () => { + it('surfaces helper_credential_unresolved on `.flow.ts` paths too, not just helper modules', async () => { // Regression: `flows check` on `.flow.ts` routes through `checkAuthoredTriggers` // (trigger-aware) while `.mjs` helper modules routed through `checkHelperBody`. // Before this fix, the `.flow.ts` path silently skipped the helper preflight, // so a flow using `f.slack.post` without SLACK_BOT_TOKEN passed check and only - // crashed at run. + // crashed at run. Check now warns; check-helper-surface.test.ts pins the + // strict local-run refusal before daemon attachment. for (const key of ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFILE_MOUNT_PATH', 'WORKSPACE_ROOT', 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT']) vi.stubEnv(key, ''); const dataDir = temporary(); mkdirSync(join(dataDir, 'node_modules/@relayflows'), { recursive: true }); @@ -155,8 +156,8 @@ describe('authored Slack helper effects', () => { const fixture = join(dataDir, 'slack.flow.ts'); writeFileSync(fixture, `import { flow } from ${JSON.stringify(join(root, 'packages/sdk/node_modules/@relayflows/surface/dist/index.js'))};\nexport default flow('slack-authored', async f => { await f.slack.post('#test', 'hi'); f.done('success'); });`); const output: string[] = []; - expect(await runCli(['check', '--json', fixture], { stdout: line => output.push(line), stderr: line => output.push(line) })).toBe(2); - expect(output.join('')).toContain('helper_slack.credential_missing'); + expect(await runCli(['check', '--json', fixture], { stdout: line => output.push(line), stderr: line => output.push(line) })).toBe(0); + expect(output.join('')).toContain('helper_credential_unresolved'); }); it.each(['confirm', 'complete'] as const)('replays after SIGKILL before %s with the same token and one successful completion', async boundary => { diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts new file mode 100644 index 000000000..b3d245d94 --- /dev/null +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -0,0 +1,113 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { runCli } from '../src/cli.js'; +import { checkHelperBody } from '../src/cli/check-helper-body.js'; +import { checkAuthoredTriggers } from '../src/cli/check-triggers.js'; +import { CHECK_WARNING_KINDS } from '../src/failure-kinds.js'; +import { helperCredentialDiagnostics } from '../src/cli/check-helper-surface.js'; + +const directories: string[] = []; +beforeEach(() => { + for (const key of ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFLOWS_LINEAR_MOCK', + 'RELAYFLOWS_NOTION_MOCK', 'RELAYFILE_MOUNT_PATH', 'WORKSPACE_ROOT', + 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT']) vi.stubEnv(key, ''); +}); +afterEach(() => { + vi.unstubAllEnvs(); + for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); +function fixture(body = "await f.slack.post('#test', 'hi');", header = '', name = 'test.flow.ts') { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-')); + directories.push(dir); + writeFileSync(join(dir, 'flows.json'), '{}'); + mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); + symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); + const path = join(dir, name); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', ${header} async f => { ${body} f.done('success'); });`); + return path; +} +async function check(path: string, json = false, verb = 'check') { + const lines: string[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const exit = await runCli([verb, ...(json ? ['--json'] : []), path, ...(verb === 'run' ? ['--input', '{}'] : [])], { + stdout: line => { stdout.push(line); lines.push(line); }, + stderr: line => { stderr.push(line); lines.push(line); }, + }); + return { exit, lines, stdout, stderr }; +} +it('declares the warning and preserves unrelated refusals and unknown shapes', () => { + expect(CHECK_WARNING_KINDS).toContain('helper_credential_unresolved'); + const diagnostics = [ + { severity: 'refusal' as const, kind: 'probe_failed' as const, message: 'probe threw' }, + { severity: 'refusal' as const, kind: 'helper_provider.mount_required' as const, message: 'unknown shape' }, + ]; + expect(helperCredentialDiagnostics(diagnostics)).toEqual({ diagnostics, downgraded: false }); +}); +it('reports the declared integration once after REQUIRES and before CHECK PASSED', async () => { + const result = await check(fixture('', '{ tools: { slack: true } },')); + expect(result.exit).toBe(0); + const requires = result.lines.findIndex(line => line.includes('REQUIRES slack (tools.slack)')); + const warning = result.lines.findIndex(line => line.includes('[helper_credential_unresolved]')); + const passed = result.lines.findIndex(line => line.includes('CHECK PASSED')); + expect(requires).toBeGreaterThanOrEqual(0); + expect(warning).toBeGreaterThan(requires); + expect(passed).toBeGreaterThan(warning); + expect(result.stderr.filter(line => line.includes('[helper_credential_unresolved]'))).toHaveLength(1); +}); +it('emits one diagnostic in JSON and stderr with integration requirements', async () => { + const result = await check(fixture('', '{ tools: { slack: true } },'), true); + expect(result.exit).toBe(0); + const report = JSON.parse(result.stdout.join('')); + expect(report.ok).toBe(true); + expect(report.diagnostics.filter((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toHaveLength(1); + expect(report.requirements.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); + expect(result.stderr.filter(line => line.includes('[helper_credential_unresolved]'))).toHaveLength(1); +}); +it.each(['slack', 'linear'])('discovers body-only %s use', async provider => { + const result = await check(fixture(`await f.${provider}.${provider === 'slack' ? 'post' : 'comment'}('test', 'hi');`)); + expect(result.exit).toBe(0); + expect(result.stdout.join('\n')).toContain(`REQUIRES ${provider} (f.${provider})`); +}); +it.each([ + ['airtable', "await f.airtable.createRecord('base', 'table', {});"], + ['notion appendBlock', "await f.notion.appendBlock('page', {});"], +])('still refuses unsupported %s without a mount', async (_name, body) => { + const result = await check(fixture(body)); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('keeps default and explicit opt-out checks strict, and local run refuses before attach', async () => { + const path = fixture(); + for (const invocation of [undefined, {}, { warnUnresolvedHelperCredential: false }]) { + expect((await checkAuthoredTriggers(path, invocation)).report.ok).toBe(false); + } + expect((await checkHelperBody(path)).report.ok).toBe(false); + const result = await check(path, false, 'run'); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_slack.credential_missing]'); +}); +it('does not warn with a local mount', async () => { + const path = fixture(); + mkdirSync(join(dirname(path), 'slack')); + vi.stubEnv('RELAYFILE_MOUNT_PATH', dirname(path)); + const result = await check(path); + expect(result.exit).toBe(0); + expect(result.stderr.join('\n')).not.toContain('helper_credential_unresolved'); +}); +it('warns for a non-flow module without a REQUIRES line', async () => { + // Only isAuthoredFlowPath modules reach the requirements-producing trigger leg. + const result = await check(fixture(undefined, '', 'slack.mjs')); + expect(result.exit).toBe(0); + expect(result.stderr.join('\n')).toContain('[helper_credential_unresolved]'); + expect(result.stdout.join('\n')).not.toContain('REQUIRES'); +}); +it('warns once for a token without a mount and names the strict refusal', async () => { + vi.stubEnv('SLACK_BOT_TOKEN', 'test-token'); + const result = await check(fixture()); + expect(result.exit).toBe(0); + expect(result.stderr.join('\n')).toContain('[helper_slack.mount_required]'); +}); From c37b5ad2b7f982e4e88b9c0fdcd889c4696b0e54 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 6 Oct 2026 13:42:52 +0000 Subject: [PATCH 02/43] fix(check): report YAML helper requirements before credential warnings --- docs/YAML-HELPERS.md | 4 +- evidence/helper-check/authored-subset.txt | 13 + evidence/helper-check/build.txt | 5 + .../helper-check/example-check-module.txt | 3 + evidence/helper-check/example-check.txt | 5 + .../helper-check/example-module-boundary.txt | 9 + evidence/helper-check/example-run-module.txt | 3 + evidence/helper-check/example-run.txt | 5 + evidence/helper-check/example.py | 23 ++ .../helper-check/mutation-notion-fail.txt | 36 +++ .../helper-check/mutation-notion-pass.txt | 13 + .../helper-check/mutation-placement-fail.txt | 29 ++ .../helper-check/mutation-placement-pass.txt | 14 + evidence/helper-check/regression-final.txt | 25 ++ evidence/helper-check/regression.txt | 98 +++++++ evidence/helper-check/requirements.mjs | 20 ++ evidence/helper-check/requirements.txt | 7 + evidence/helper-check/tests-a.txt | 12 + evidence/helper-check/typecheck-a.txt | 5 + evidence/helper-check/typecheck-tests-a.txt | 5 + evidence/helper-check/typecheck-tests.txt | 5 + evidence/helper-check/typecheck.txt | 5 + packages/sdk/src/cli.ts | 2 +- packages/sdk/src/cli/check.ts | 12 +- .../sdk/tests/check-helper-surface.test.ts | 62 +++- summary.md | 270 ++++++++++++++++++ 26 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 evidence/helper-check/authored-subset.txt create mode 100644 evidence/helper-check/build.txt create mode 100644 evidence/helper-check/example-check-module.txt create mode 100644 evidence/helper-check/example-check.txt create mode 100644 evidence/helper-check/example-module-boundary.txt create mode 100644 evidence/helper-check/example-run-module.txt create mode 100644 evidence/helper-check/example-run.txt create mode 100644 evidence/helper-check/example.py create mode 100644 evidence/helper-check/mutation-notion-fail.txt create mode 100644 evidence/helper-check/mutation-notion-pass.txt create mode 100644 evidence/helper-check/mutation-placement-fail.txt create mode 100644 evidence/helper-check/mutation-placement-pass.txt create mode 100644 evidence/helper-check/regression-final.txt create mode 100644 evidence/helper-check/regression.txt create mode 100644 evidence/helper-check/requirements.mjs create mode 100644 evidence/helper-check/requirements.txt create mode 100644 evidence/helper-check/tests-a.txt create mode 100644 evidence/helper-check/typecheck-a.txt create mode 100644 evidence/helper-check/typecheck-tests-a.txt create mode 100644 evidence/helper-check/typecheck-tests.txt create mode 100644 evidence/helper-check/typecheck.txt create mode 100644 summary.md diff --git a/docs/YAML-HELPERS.md b/docs/YAML-HELPERS.md index 7d1461008..c6417ba75 100644 --- a/docs/YAML-HELPERS.md +++ b/docs/YAML-HELPERS.md @@ -43,7 +43,9 @@ takes `{issueId, body}`, and `updateIssue` takes `{issueId, args}`. Configure a relayfile mount containing the provider directory using the same mount environment variables as TS Slack helpers (`RELAYFILE_MOUNT_PATH`, `WORKSPACE_ROOT`, `WORKFORCE_SANDBOX_ROOT`, `RELAYFILE_MOUNT_ROOT`, or -`RELAYFILE_ROOT`). `flows check` refuses missing mounts. To run locally: +`RELAYFILE_ROOT`). `flows check` lists the provider under `REQUIRES` and warns +`helper_credential_unresolved` for missing local mounts. Cloud submission checks +workspace integrations; local `flows run` still refuses missing mounts. To run locally: ```sh flows run notify.yaml --local-agent diff --git a/evidence/helper-check/authored-subset.txt b/evidence/helper-check/authored-subset.txt new file mode 100644 index 000000000..587e09742 --- /dev/null +++ b/evidence/helper-check/authored-subset.txt @@ -0,0 +1,13 @@ +$ cd packages/sdk && npx vitest run tests/authored-flow-slack.test.ts tests/authored-helpers.test.ts -t 'refuses missing credentials|on `.flow.ts` paths too|rejects malformed arguments' + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/authored-helpers.test.ts (6 tests | 5 skipped) 28ms + ✓ tests/authored-flow-slack.test.ts (7 tests | 5 skipped) 727ms + ✓ authored Slack helper effects > refuses missing credentials before running the body while flows check warns 511ms + + Test Files 2 passed (2) + Tests 3 passed | 10 skipped (13) + Start at 13:40:46 + Duration 2.58s (transform 1.11s, setup 27ms, collect 2.85s, tests 755ms, environment 0ms, prepare 112ms) + diff --git a/evidence/helper-check/build.txt b/evidence/helper-check/build.txt new file mode 100644 index 000000000..419353eff --- /dev/null +++ b/evidence/helper-check/build.txt @@ -0,0 +1,5 @@ +$ npm --prefix packages/sdk run build + +> @relayflows/sdk@2.0.42 build +> tsc && node scripts/make-cli-executable.mjs + diff --git a/evidence/helper-check/example-check-module.txt b/evidence/helper-check/example-check-module.txt new file mode 100644 index 000000000..b602f90af --- /dev/null +++ b/evidence/helper-check/example-check-module.txt @@ -0,0 +1,3 @@ +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node --experimental-default-type=module packages/sdk/dist/cli.js check examples/stale-issues/stale-issues.flow.ts +node: bad option: --experimental-default-type=module +exit=9 diff --git a/evidence/helper-check/example-check.txt b/evidence/helper-check/example-check.txt new file mode 100644 index 000000000..0826f1afa --- /dev/null +++ b/evidence/helper-check/example-check.txt @@ -0,0 +1,5 @@ +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js check examples/stale-issues/stale-issues.flow.ts +(node:14983) Warning: Failed to load the ES module: /home/daytona/.relayflow-v2-supervisor/durable/repository/examples/stale-issues/stale-issues.flow.ts. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension. +(Use `node --trace-warnings ...` to show where the warning was created) +REFUSED [invalid_spec] Flow "/home/daytona/.relayflow-v2-supervisor/durable/repository/examples/stale-issues/stale-issues.flow.ts" could not be imported: Cannot use import statement outside a module +exit=2 diff --git a/evidence/helper-check/example-module-boundary.txt b/evidence/helper-check/example-module-boundary.txt new file mode 100644 index 000000000..7621ba7a7 --- /dev/null +++ b/evidence/helper-check/example-module-boundary.txt @@ -0,0 +1,9 @@ +$ python3 evidence/helper-check/example.py +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js check examples/stale-issues/stale-issues.flow.ts +REQUIRES slack (tools.slack), claude (llm step) +WARNING [helper_credential_unresolved] f.slack needs a slack mount, which is not available locally. flows schedule / flows deploy / flows run --cloud check the integration against your workspace at submit and refuse if Cloud cannot connect it. A local flows run needs a relayfile slack mount (a slack/ directory under RELAYFILE_MOUNT_PATH) or RELAYFLOWS_SLACK_MOCK=1, and refuses with [helper_slack.credential_missing] without one. +CHECK PASSED examples/stale-issues/stale-issues.flow.ts +exit=0 +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js run examples/stale-issues/stale-issues.flow.ts --input '{"repo":"acme/api","channel":"#eng"}' +REFUSED [helper_slack.credential_missing] f.slack requires a relayfile slack mount; direct-token transport is not implemented. +exit=2 diff --git a/evidence/helper-check/example-run-module.txt b/evidence/helper-check/example-run-module.txt new file mode 100644 index 000000000..e331215c3 --- /dev/null +++ b/evidence/helper-check/example-run-module.txt @@ -0,0 +1,3 @@ +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node --experimental-default-type=module packages/sdk/dist/cli.js run examples/stale-issues/stale-issues.flow.ts --input '{"repo":"acme/api","channel":"#eng"}' +node: bad option: --experimental-default-type=module +exit=9 diff --git a/evidence/helper-check/example-run.txt b/evidence/helper-check/example-run.txt new file mode 100644 index 000000000..54e421721 --- /dev/null +++ b/evidence/helper-check/example-run.txt @@ -0,0 +1,5 @@ +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js run examples/stale-issues/stale-issues.flow.ts --input '{"repo":"acme/api","channel":"#eng"}' +(node:15007) Warning: Failed to load the ES module: /home/daytona/.relayflow-v2-supervisor/durable/repository/examples/stale-issues/stale-issues.flow.ts. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension. +(Use `node --trace-warnings ...` to show where the warning was created) +REFUSED [invalid_spec] Flow "/home/daytona/.relayflow-v2-supervisor/durable/repository/examples/stale-issues/stale-issues.flow.ts" could not be imported: Cannot use import statement outside a module +exit=2 diff --git a/evidence/helper-check/example.py b/evidence/helper-check/example.py new file mode 100644 index 000000000..a2374cea7 --- /dev/null +++ b/evidence/helper-check/example.py @@ -0,0 +1,23 @@ +from pathlib import Path +import os, shlex, subprocess +boundary = Path('examples/stale-issues/package.json') +assert not boundary.exists() +env = os.environ.copy() +keys = ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFILE_MOUNT_PATH', + 'WORKSPACE_ROOT', 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT'] +for key in keys: + env.pop(key, None) +# This checkout lacks a module boundary for examples; the installed CLI normally +# runs in an author's module project. Restore the checkout even on failure. +boundary.write_text('{"type":"module"}\n') +try: + for verb in ['check', 'run']: + cmd = ['node', 'packages/sdk/dist/cli.js', verb, 'examples/stale-issues/stale-issues.flow.ts'] + if verb == 'run': + cmd += ['--input', '{"repo":"acme/api","channel":"#eng"}'] + result = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + print('$ env ' + ' '.join('-u ' + key for key in keys) + ' ' + shlex.join(cmd)) + print(result.stdout, end='') + print(f'exit={result.returncode}') +finally: + boundary.unlink() diff --git a/evidence/helper-check/mutation-notion-fail.txt b/evidence/helper-check/mutation-notion-fail.txt new file mode 100644 index 000000000..9c63b22f8 --- /dev/null +++ b/evidence/helper-check/mutation-notion-fail.txt @@ -0,0 +1,36 @@ +$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 590ms + × still refuses unsupported notion appendBlock without a mount 588ms + → expected +0 to be 2 // Object.is equality + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > still refuses unsupported notion appendBlock without a mount +AssertionError: expected +0 to be 2 // Object.is equality + +- Expected ++ Received + +- 2 ++ 0 + + ❯ tests/check-helper-surface.test.ts:80:23 + 78| ])('still refuses unsupported %s without a mount', async (_name, body)… + 79| const result = await check(fixture(body)); + 80| expect(result.exit).toBe(2); + | ^ + 81| expect(result.stderr.join('\n')).toContain('[helper_provider.unsuppo… + 82| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests 1 failed | 10 skipped (11) + Start at 13:39:11 + Duration 2.56s (transform 1.12s, setup 18ms, collect 1.72s, tests 590ms, environment 0ms, prepare 84ms) + + +exit=1 diff --git a/evidence/helper-check/mutation-notion-pass.txt b/evidence/helper-check/mutation-notion-pass.txt new file mode 100644 index 000000000..ddc20b1a6 --- /dev/null +++ b/evidence/helper-check/mutation-notion-pass.txt @@ -0,0 +1,13 @@ +$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 90ms + + Test Files 1 passed (1) + Tests 1 passed | 10 skipped (11) + Start at 13:39:14 + Duration 2.16s (transform 1.23s, setup 20ms, collect 1.87s, tests 90ms, environment 0ms, prepare 50ms) + + +exit=0 diff --git a/evidence/helper-check/mutation-placement-fail.txt b/evidence/helper-check/mutation-placement-fail.txt new file mode 100644 index 000000000..1b08e79ff --- /dev/null +++ b/evidence/helper-check/mutation-placement-fail.txt @@ -0,0 +1,29 @@ +$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 542ms + × reports the declared integration once after REQUIRES and before CHECK PASSED 541ms + → expected 0 to be greater than 1 + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > reports the declared integration once after REQUIRES and before CHECK PASSED +AssertionError: expected 0 to be greater than 1 + ❯ tests/check-helper-surface.test.ts:57:19 + 55| const passed = result.lines.findIndex(line => line.includes('CHECK P… + 56| expect(requires).toBeGreaterThanOrEqual(0); + 57| expect(warning).toBeGreaterThan(requires); + | ^ + 58| expect(passed).toBeGreaterThan(warning); + 59| expect(result.stderr.filter(line => line.includes('[helper_credentia… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests 1 failed | 10 skipped (11) + Start at 13:39:05 + Duration 2.45s (transform 1.09s, setup 16ms, collect 1.70s, tests 542ms, environment 0ms, prepare 42ms) + + +exit=1 diff --git a/evidence/helper-check/mutation-placement-pass.txt b/evidence/helper-check/mutation-placement-pass.txt new file mode 100644 index 000000000..4e1d46022 --- /dev/null +++ b/evidence/helper-check/mutation-placement-pass.txt @@ -0,0 +1,14 @@ +$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 577ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 576ms + + Test Files 1 passed (1) + Tests 1 passed | 10 skipped (11) + Start at 13:39:08 + Duration 2.58s (transform 1.19s, setup 17ms, collect 1.80s, tests 577ms, environment 0ms, prepare 53ms) + + +exit=0 diff --git a/evidence/helper-check/regression-final.txt b/evidence/helper-check/regression-final.txt new file mode 100644 index 000000000..d8218d8e5 --- /dev/null +++ b/evidence/helper-check/regression-final.txt @@ -0,0 +1,25 @@ +$ cd packages/sdk && npx vitest run tests/check-helper-surface.test.ts tests/check-worker-surface.test.ts tests/preflight.test.ts tests/helpers-fanout.test.ts tests/helper-reference.test.ts tests/flow-requirements.test.ts tests/flow-extension-compose.test.ts + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/helper-reference.test.ts (30 tests) 22ms + ✓ tests/preflight.test.ts (70 tests) 100ms + ✓ tests/check-worker-surface.test.ts (11 tests) 88ms + ✓ tests/flow-requirements.test.ts (14 tests) 1030ms + ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 786ms + ✓ tests/helpers-fanout.test.ts (96 tests) 156ms + ✓ tests/check-helper-surface.test.ts (15 tests) 4150ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 479ms + ✓ keeps default and explicit opt-out checks strict, and local run refuses before attach 339ms + ✓ preserves requirements when compilation refuses and for named agents 2248ms + ✓ tests/flow-extension-compose.test.ts (33 tests) 5886ms + ✓ composing flow extensions onto a base flow > composes two extensions in declaration order, and the order is the lockfile order 445ms + ✓ composing flow extensions onto a base flow > flows check reports the composition and keeps the composed triggers deliverable 973ms + ✓ composing flow extensions onto a base flow > flows check probes extension preflight before reporting the project healthy 305ms + ✓ composing flow extensions onto a base flow > uses extension permissions for hosted deploy preflight and the deploy body 322ms + + Test Files 7 passed (7) + Tests 269 passed (269) + Start at 13:40:37 + Duration 7.93s (transform 1.99s, setup 57ms, collect 6.67s, tests 11.43s, environment 1ms, prepare 287ms) + diff --git a/evidence/helper-check/regression.txt b/evidence/helper-check/regression.txt new file mode 100644 index 000000000..036ad927b --- /dev/null +++ b/evidence/helper-check/regression.txt @@ -0,0 +1,98 @@ +$ cd packages/sdk && npx vitest run tests/check-helper-surface.test.ts tests/check-worker-surface.test.ts tests/preflight.test.ts tests/helpers-fanout.test.ts tests/helper-reference.test.ts tests/flow-requirements.test.ts tests/flow-extension-compose.test.ts tests/direct-input.test.ts + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/direct-input.test.ts (6 tests | 6 skipped) 6ms + ✓ tests/helper-reference.test.ts (30 tests) 28ms + ✓ tests/preflight.test.ts (70 tests) 125ms + ✓ tests/flow-requirements.test.ts (14 tests) 887ms + ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 651ms + ✓ tests/check-worker-surface.test.ts (11 tests) 91ms + ✓ tests/helpers-fanout.test.ts (96 tests) 191ms + ❯ tests/check-helper-surface.test.ts (14 tests | 1 failed) 2345ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 582ms + ✓ keeps default and explicit opt-out checks strict, and local run refuses before attach 345ms + × YAML local run still refuses a missing mount 10ms + → expected 'REFUSED [invalid_invocation] Usage:\n…' to contain '[helper_mount_required]' + ✓ tests/flow-extension-compose.test.ts (33 tests) 6464ms + ✓ composing flow extensions onto a base flow > composes two extensions in declaration order, and the order is the lockfile order 607ms + ✓ composing flow extensions onto a base flow > flows check reports the composition and keeps the composed triggers deliverable 1181ms + ✓ composing flow extensions onto a base flow > uses extension permissions for hosted deploy preflight and the deploy body 302ms + +⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/direct-input.test.ts [ tests/direct-input.test.ts ] +Error: relayflowd is not executable at /home/daytona/.relayflow-v2-supervisor/durable/repository/kernel/target/debug/relayflowd + ❯ requireExecutable tests/direct-input.test.ts:319:11 + 317| accessSync(path, constants.X_OK); + 318| } catch { + 319| throw new Error(`${label} is not executable at ${path}`); + | ^ + 320| } + 321| } + ❯ tests/direct-input.test.ts:32:3 + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯ + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > YAML local run still refuses a missing mount +AssertionError: expected 'REFUSED [invalid_invocation] Usage:\n…' to contain '[helper_mount_required]' + +- Expected ++ Received + +- [helper_mount_required] ++ REFUSED [invalid_invocation] Usage: ++ flows add ++ flows add ++ flows plugin list [--json] ++ flows plugin verify [--json] [--offline] ++ flows plugin remove [--json] ++ flows plugin update [--json] [--yes] [--to ] [] ++ flows build [--out ] ++ flows build --verify ++ flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--plugin ] [--no-connect] [--json] ++ flows deploy --flow [--plugin ] [--no-connect] [--json] ++ flows deployments [--json] ++ flows versions [--json] ++ flows rollback [--json] ++ flows undeploy [--json] ++ flows schedule [--cron "" | --every ] [--tz ] [--input ] [--name ] [--no-connect] [--json] ++ flows schedules [--json] ++ flows unschedule [--json] ++ flows deploy @sha256: --to ++ flows run @sha256: [--bucket ] [--data-dir ] [--json] ++ flows check [--watch] [--json] ++ flows serve-webhook --data-dir --port

[--allow [,]] ++ flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir

] [--local-agent [--agent-capacity ]] [--reuse-from ] ++ flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] ++ flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] --input ++ flows sync [--json] [--dry-run] [--dir ] ++ flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent [--agent-capacity ]] --input ++ flows tick start --schedule-id --interval-ms [--epoch-ms ] [--max-catch-up ] [--poll-interval-ms ] [--data-dir ] ++ flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent [--agent-capacity ]] ++ flows answer [--json] [--no-spawn] [--data-dir ] [--note ] [--by ] ++ flows replay [--allow-human-influenced] [--json] [--data-dir ] [--at ] ++ flows status [--json] [--data-dir ] [--tail ] [] ++ flows status --cloud [--json] [--watch] ++ flows runs [--limit ] [--json] ++ flows logs [--step ] [--raw] [--json] [--follow] ++ flows observer [--data-dir ] ++ flows hn-monitor start [--data-dir ] [--poll-interval-ms ] + + ❯ tests/check-helper-surface.test.ts:142:36 + 140| const result = await check(yamlFixture(), false, 'run'); + 141| expect(result.exit).toBe(2); + 142| expect(result.stderr.join('\n')).toContain('[helper_mount_required]'… + | ^ + 143| }); + 144| it('YAML with a mount reports the integration without a credential war… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯ + + Test Files 2 failed | 6 passed (8) + Tests 1 failed | 267 passed | 6 skipped (274) + Start at 13:39:55 + Duration 8.46s (transform 1.81s, setup 124ms, collect 6.28s, tests 10.14s, environment 1ms, prepare 340ms) + diff --git a/evidence/helper-check/requirements.mjs b/evidence/helper-check/requirements.mjs new file mode 100644 index 000000000..78702b13b --- /dev/null +++ b/evidence/helper-check/requirements.mjs @@ -0,0 +1,20 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { flowRequirements, describeFlowRequirements } from '../../packages/sdk/dist/flow-requirements.js'; +import { checkAuthoredFlow } from '../../packages/sdk/dist/cli/check.js'; + +const dir = mkdtempSync(join(tmpdir(), 'requirements-evidence-')); +try { + writeFileSync(join(dir, 'flows.json'), '{}'); + const base = { version: '0.1.0', name: 'named', + agents: { reviewer: { cli: 'codex', model: 'gpt-5' } }, + steps: [{ id: 'review', type: 'agent', agent: 'reviewer', instruction: 'Review' }] }; + for (const [name, spec] of [['named-agent', base], ['use', { ...base, use: ['./missing.yaml'] }]]) { + // This was the complete safeRequirements implementation before Part B. + console.log(name + ' before: REQUIRES ' + describeFlowRequirements(flowRequirements(spec))); + const report = checkAuthoredFlow(spec, join(dir, 'flow.yaml')).report; + console.log(name + ' after: REQUIRES ' + describeFlowRequirements(report.requirements)); + console.log(name + ' refusals: ' + report.diagnostics.filter(d => d.severity === 'refusal').map(d => d.kind).join(', ')); + } +} finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/evidence/helper-check/requirements.txt b/evidence/helper-check/requirements.txt new file mode 100644 index 000000000..9f2ea3fce --- /dev/null +++ b/evidence/helper-check/requirements.txt @@ -0,0 +1,7 @@ +$ node evidence/helper-check/requirements.mjs +named-agent before: REQUIRES codex (step "review") +named-agent after: REQUIRES codex (step "review") +named-agent refusals: model_unavailable +use before: REQUIRES codex (step "review") +use after: REQUIRES codex (step "review") +use refusals: invalid_spec diff --git a/evidence/helper-check/tests-a.txt b/evidence/helper-check/tests-a.txt new file mode 100644 index 000000000..84fa3ed85 --- /dev/null +++ b/evidence/helper-check/tests-a.txt @@ -0,0 +1,12 @@ +$ cd packages/sdk && npx vitest run tests/check-helper-surface.test.ts + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (11 tests) 2144ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 587ms + + Test Files 1 passed (1) + Tests 11 passed (11) + Start at 13:39:04 + Duration 3.97s (transform 1.09s, setup 20ms, collect 1.63s, tests 2.14s, environment 0ms, prepare 42ms) + diff --git a/evidence/helper-check/typecheck-a.txt b/evidence/helper-check/typecheck-a.txt new file mode 100644 index 000000000..e2ad4ec43 --- /dev/null +++ b/evidence/helper-check/typecheck-a.txt @@ -0,0 +1,5 @@ +$ npm --prefix packages/sdk run typecheck + +> @relayflows/sdk@2.0.42 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + diff --git a/evidence/helper-check/typecheck-tests-a.txt b/evidence/helper-check/typecheck-tests-a.txt new file mode 100644 index 000000000..12b8bbce7 --- /dev/null +++ b/evidence/helper-check/typecheck-tests-a.txt @@ -0,0 +1,5 @@ +$ npm --prefix packages/sdk run typecheck:tests + +> @relayflows/sdk@2.0.42 typecheck:tests +> tsc -p tsconfig.tests.json + diff --git a/evidence/helper-check/typecheck-tests.txt b/evidence/helper-check/typecheck-tests.txt new file mode 100644 index 000000000..12b8bbce7 --- /dev/null +++ b/evidence/helper-check/typecheck-tests.txt @@ -0,0 +1,5 @@ +$ npm --prefix packages/sdk run typecheck:tests + +> @relayflows/sdk@2.0.42 typecheck:tests +> tsc -p tsconfig.tests.json + diff --git a/evidence/helper-check/typecheck.txt b/evidence/helper-check/typecheck.txt new file mode 100644 index 000000000..e2ad4ec43 --- /dev/null +++ b/evidence/helper-check/typecheck.txt @@ -0,0 +1,5 @@ +$ npm --prefix packages/sdk run typecheck + +> @relayflows/sdk@2.0.42 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 108b8eadd..d88d6ab6e 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -302,7 +302,7 @@ export async function runCli( // without compiling steps, so it has no agent steps to count. const checked = /\.(?:[cm]?[jt]s)$/.test(parsed.value) ? await checkAuthoredFlowComposed(parsed.value) - : checkFlow(parsed.value, { warnUnresolvedAgentWorker: true }); + : checkFlow(parsed.value, { warnUnresolvedAgentWorker: true, warnUnresolvedHelperCredential: true }); emitCheckReport(checked.report, parsed.json, io); return checked.report.ok ? 0 : 2; } diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index 11a654e80..fec93cd68 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -5,6 +5,7 @@ import { accessSync, constants, readFileSync } from 'node:fs'; import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js'; +import { helperCredentialDiagnostics } from './check-helper-surface.js'; import { agentWorkerDiagnostics } from './check-worker-surface.js'; import { helperReady } from '../yaml-helper-effect.js'; import { flowRequirements, type FlowRequirements } from '../flow-requirements.js'; @@ -181,7 +182,10 @@ export function checkFlow(path: string, invocation: CheckInvocation = {}): Check /** Requirements never turn a preflight refusal into an unrelated exception. */ function safeRequirements(authoring: FlowSpec, projectCli: string | undefined): FlowRequirements | undefined { try { - return flowRequirements(authoring, projectCli === undefined ? {} : { projectCli }); + let requirementsSpec = authoring; + try { requirementsSpec = compileSpec(authoring); } + catch { /* Preserve requirements from invalid authoring; preflight owns its refusal. */ } + return flowRequirements(requirementsSpec, projectCli === undefined ? {} : { projectCli }); } catch { return undefined; } @@ -248,14 +252,16 @@ export function checkAuthoredFlow( const workerSurface = invocation.warnUnresolvedAgentWorker === true ? agentWorkerDiagnostics(authoring) : []; + const diagnostics = invocation.warnUnresolvedHelperCredential === true + ? helperCredentialDiagnostics(result.diagnostics).diagnostics : result.diagnostics; return { report: { - ok: result.ok, + ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'), path, ...(config.path !== undefined ? { projectConfigPath: config.path } : {}), gates: result.gates, resolutions: result.resolutions, - diagnostics: [...result.diagnostics, ...workerSurface], + diagnostics: [...diagnostics, ...workerSurface], requirements: safeRequirements(authoring, config.cli), }, ...(result.ok && flow !== undefined ? { flow } : {}), diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index b3d245d94..d1e60dd93 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -33,7 +33,7 @@ async function check(path: string, json = false, verb = 'check') { const lines: string[] = []; const stdout: string[] = []; const stderr: string[] = []; - const exit = await runCli([verb, ...(json ? ['--json'] : []), path, ...(verb === 'run' ? ['--input', '{}'] : [])], { + const exit = await runCli([verb, ...(json ? ['--json'] : []), path, ...(verb === 'run' && path.endsWith('.flow.ts') ? ['--input', '{}'] : [])], { stdout: line => { stdout.push(line); lines.push(line); }, stderr: line => { stderr.push(line); lines.push(line); }, }); @@ -111,3 +111,63 @@ it('warns once for a token without a mount and names the strict refusal', async expect(result.exit).toBe(0); expect(result.stderr.join('\n')).toContain('[helper_slack.mount_required]'); }); + +function yamlFixture() { + const path = join(dirname(fixture()), 'notify.yaml'); + writeFileSync(path, `version: '0.1.0' +name: notify +steps: + - id: notify + slack: + post: { channel: '#test', text: hi } +`); + return path; +} +it('YAML reports both footnotes once after REQUIRES and before CHECK PASSED', async () => { + const result = await check(yamlFixture()); + expect(result.exit).toBe(0); + const requires = result.lines.findIndex(line => line.includes('REQUIRES slack (step "notify")')); + const passed = result.lines.findIndex(line => line.includes('CHECK PASSED')); + expect(requires).toBeGreaterThanOrEqual(0); + for (const kind of ['helper_credential_unresolved', 'agent_worker_unresolved']) { + const matches = result.lines.flatMap((line, index) => line.includes(`[${kind}]`) ? [index] : []); + expect(matches).toHaveLength(1); + expect(matches[0]).toBeGreaterThan(requires); + expect(matches[0]).toBeLessThan(passed); + } +}); +it('YAML local run still refuses a missing mount', async () => { + const result = await check(yamlFixture(), false, 'run'); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_mount_required]'); +}); +it('YAML with a mount reports the integration without a credential warning or harness', async () => { + const path = yamlFixture(); + mkdirSync(join(dirname(path), 'slack')); + vi.stubEnv('RELAYFILE_MOUNT_PATH', dirname(path)); + const result = await check(path, true); + expect(result.exit).toBe(0); + const report = JSON.parse(result.stdout.join('')); + expect(report.requirements.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); + expect(report.requirements.harnessUses).toEqual([]); + expect(report.diagnostics.some((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toBe(false); +}); + +it('preserves requirements when compilation refuses and for named agents', async () => { + const path = yamlFixture(); + for (const extra of ['', "use: ['./missing.yaml']\n"]) { + writeFileSync(path, `version: '0.1.0' +name: named +${extra}agents: + reviewer: { cli: codex, model: gpt-5 } +steps: + - id: review + type: agent + agent: reviewer + instruction: Review +`); + const result = await check(path, true); + const report = JSON.parse(result.stdout.join('')); + expect(report.requirements.harnessUses).toContainEqual({ harness: 'codex', detail: 'step "review"' }); + } +}); diff --git a/summary.md b/summary.md new file mode 100644 index 000000000..78472a8be --- /dev/null +++ b/summary.md @@ -0,0 +1,270 @@ +Fix: let flows check report Cloud-bound helper requirements without local mounts + +A laptop without Slack mounted can inspect an authored Slack flow: check reports +REQUIRES slack and helper_credential_unresolved, then passes. Local run retains +its credential refusal before daemon attachment. The same rule applies to YAML +helpers, whose requirements are now derived from compiled steps with an authoring +fallback for invalid specs. + +The warning is opt-in at the check surface, emitted once after REQUIRES. +Non-.flow.* modules receive a self-contained warning without a requirements line. +Unsupported providers and Notion appendBlock remain refusals even without mounts. +Hosted submission, execution, resume, and pure preflight retain their existing +credential checks. Watch inherits the CLI behavior through its child process. + +Implementation follows reviewed-plan.md Parts A and B as separate commits. +The two existing authored Slack assertions were retargeted because their check +behavior changes; their executeAuthoredFlow rejection assertions remain intact. +No quality gates or GitHub workflow files were edited. + +Validation and limits: +- 269 regression tests and 3 daemon-free authored tests passed; build and both + TypeScript checks passed. Commands and literal output follow. +- Warning placement and Notion refusal were mutation-verified: each specific + source change was reverted, its test failed, the source bytes were restored, + and the same test passed. Both outputs are below. +- The named-agent and unresolved-use requirements comparison preserved the + codex requirement. The use fixture is deliberately invalid; it verifies the + compile-failure fallback, not successful import expansion. +- The exact example passes check and refuses local run with a temporary + package.json module boundary. The helper script restores the checkout. + Without that boundary, this checkout's Node import fails before preflight + (example-check.txt and example-run.txt). An attempted Node module flag was + unsupported (example-*-module.txt); no loader fix is included. +- Full SDK tests were not run: Cargo and relayflowd are unavailable. + The initial broader command failed direct-input.test.ts setup for the absent + binary; its literal output is in evidence/helper-check/regression.txt, alongside + an initial YAML test argument error corrected before the final run. + The authored subset skips ten daemon-dependent cases: Slack structured posts, + journaled effect, confirm/complete SIGKILL replay, and multi-call writeback; + generic helper acceptance, all-provider resume, confirm/complete SIGKILL replay, + and provider-failure journaling. Those crash gates remain unverified here. + +Captured evidence (paths relative to the repository): + +evidence/helper-check/build.txt + +```text +$ npm --prefix packages/sdk run build + +> @relayflows/sdk@2.0.42 build +> tsc && node scripts/make-cli-executable.mjs + + +``` + +evidence/helper-check/typecheck.txt + +```text +$ npm --prefix packages/sdk run typecheck + +> @relayflows/sdk@2.0.42 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +``` + +evidence/helper-check/typecheck-tests.txt + +```text +$ npm --prefix packages/sdk run typecheck:tests + +> @relayflows/sdk@2.0.42 typecheck:tests +> tsc -p tsconfig.tests.json + + +``` + +evidence/helper-check/regression-final.txt + +```text +$ cd packages/sdk && npx vitest run tests/check-helper-surface.test.ts tests/check-worker-surface.test.ts tests/preflight.test.ts tests/helpers-fanout.test.ts tests/helper-reference.test.ts tests/flow-requirements.test.ts tests/flow-extension-compose.test.ts + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/helper-reference.test.ts (30 tests) 22ms + ✓ tests/preflight.test.ts (70 tests) 100ms + ✓ tests/check-worker-surface.test.ts (11 tests) 88ms + ✓ tests/flow-requirements.test.ts (14 tests) 1030ms + ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 786ms + ✓ tests/helpers-fanout.test.ts (96 tests) 156ms + ✓ tests/check-helper-surface.test.ts (15 tests) 4150ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 479ms + ✓ keeps default and explicit opt-out checks strict, and local run refuses before attach 339ms + ✓ preserves requirements when compilation refuses and for named agents 2248ms + ✓ tests/flow-extension-compose.test.ts (33 tests) 5886ms + ✓ composing flow extensions onto a base flow > composes two extensions in declaration order, and the order is the lockfile order 445ms + ✓ composing flow extensions onto a base flow > flows check reports the composition and keeps the composed triggers deliverable 973ms + ✓ composing flow extensions onto a base flow > flows check probes extension preflight before reporting the project healthy 305ms + ✓ composing flow extensions onto a base flow > uses extension permissions for hosted deploy preflight and the deploy body 322ms + + Test Files 7 passed (7) + Tests 269 passed (269) + Start at 13:40:37 + Duration 7.93s (transform 1.99s, setup 57ms, collect 6.67s, tests 11.43s, environment 1ms, prepare 287ms) + + +``` + +evidence/helper-check/authored-subset.txt + +```text +$ cd packages/sdk && npx vitest run tests/authored-flow-slack.test.ts tests/authored-helpers.test.ts -t 'refuses missing credentials|on `.flow.ts` paths too|rejects malformed arguments' + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/authored-helpers.test.ts (6 tests | 5 skipped) 28ms + ✓ tests/authored-flow-slack.test.ts (7 tests | 5 skipped) 727ms + ✓ authored Slack helper effects > refuses missing credentials before running the body while flows check warns 511ms + + Test Files 2 passed (2) + Tests 3 passed | 10 skipped (13) + Start at 13:40:46 + Duration 2.58s (transform 1.11s, setup 27ms, collect 2.85s, tests 755ms, environment 0ms, prepare 112ms) + + +``` + +evidence/helper-check/requirements.txt + +```text +$ node evidence/helper-check/requirements.mjs +named-agent before: REQUIRES codex (step "review") +named-agent after: REQUIRES codex (step "review") +named-agent refusals: model_unavailable +use before: REQUIRES codex (step "review") +use after: REQUIRES codex (step "review") +use refusals: invalid_spec + +``` + +evidence/helper-check/example-module-boundary.txt + +```text +$ python3 evidence/helper-check/example.py +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js check examples/stale-issues/stale-issues.flow.ts +REQUIRES slack (tools.slack), claude (llm step) +WARNING [helper_credential_unresolved] f.slack needs a slack mount, which is not available locally. flows schedule / flows deploy / flows run --cloud check the integration against your workspace at submit and refuse if Cloud cannot connect it. A local flows run needs a relayfile slack mount (a slack/ directory under RELAYFILE_MOUNT_PATH) or RELAYFLOWS_SLACK_MOCK=1, and refuses with [helper_slack.credential_missing] without one. +CHECK PASSED examples/stale-issues/stale-issues.flow.ts +exit=0 +$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js run examples/stale-issues/stale-issues.flow.ts --input '{"repo":"acme/api","channel":"#eng"}' +REFUSED [helper_slack.credential_missing] f.slack requires a relayfile slack mount; direct-token transport is not implemented. +exit=2 + +``` + +evidence/helper-check/mutation-placement-fail.txt + +```text +$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 542ms + × reports the declared integration once after REQUIRES and before CHECK PASSED 541ms + → expected 0 to be greater than 1 + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > reports the declared integration once after REQUIRES and before CHECK PASSED +AssertionError: expected 0 to be greater than 1 + ❯ tests/check-helper-surface.test.ts:57:19 + 55| const passed = result.lines.findIndex(line => line.includes('CHECK P… + 56| expect(requires).toBeGreaterThanOrEqual(0); + 57| expect(warning).toBeGreaterThan(requires); + | ^ + 58| expect(passed).toBeGreaterThan(warning); + 59| expect(result.stderr.filter(line => line.includes('[helper_credentia… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests 1 failed | 10 skipped (11) + Start at 13:39:05 + Duration 2.45s (transform 1.09s, setup 16ms, collect 1.70s, tests 542ms, environment 0ms, prepare 42ms) + + +exit=1 + +``` + +evidence/helper-check/mutation-placement-pass.txt + +```text +$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 577ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 576ms + + Test Files 1 passed (1) + Tests 1 passed | 10 skipped (11) + Start at 13:39:08 + Duration 2.58s (transform 1.19s, setup 17ms, collect 1.80s, tests 577ms, environment 0ms, prepare 53ms) + + +exit=0 + +``` + +evidence/helper-check/mutation-notion-fail.txt + +```text +$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 590ms + × still refuses unsupported notion appendBlock without a mount 588ms + → expected +0 to be 2 // Object.is equality + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > still refuses unsupported notion appendBlock without a mount +AssertionError: expected +0 to be 2 // Object.is equality + +- Expected ++ Received + +- 2 ++ 0 + + ❯ tests/check-helper-surface.test.ts:80:23 + 78| ])('still refuses unsupported %s without a mount', async (_name, body)… + 79| const result = await check(fixture(body)); + 80| expect(result.exit).toBe(2); + | ^ + 81| expect(result.stderr.join('\n')).toContain('[helper_provider.unsuppo… + 82| }); + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests 1 failed | 10 skipped (11) + Start at 13:39:11 + Duration 2.56s (transform 1.12s, setup 18ms, collect 1.72s, tests 590ms, environment 0ms, prepare 84ms) + + +exit=1 + +``` + +evidence/helper-check/mutation-notion-pass.txt + +```text +$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 90ms + + Test Files 1 passed (1) + Tests 1 passed | 10 skipped (11) + Start at 13:39:14 + Duration 2.16s (transform 1.23s, setup 20ms, collect 1.87s, tests 90ms, environment 0ms, prepare 50ms) + + +exit=0 + +``` From 435f17004a6f3af93a08ff112fdb6e43bfb44508 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 6 Oct 2026 14:17:22 +0000 Subject: [PATCH 03/43] test(check): pin the reported example against the credential refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix for `flows check` refusing a Cloud-bound f.slack flow is covered by synthetic fixtures, but nothing in the suite guards the artifact the defect was reported against. `examples/stale-issues/stale-issues.flow.ts` declares `tools: { slack: true }` and calls `f.slack.post`, and on a machine with no Slack mount it used to exit 2 on `helper_slack.credential_missing` before printing `REQUIRES`. Copy the shipped example into a staged directory the way tests/flow-requirements.test.ts already does, and assert the three things the report asked for: no REFUSED line, exit 0, and `REQUIRES slack (tools.slack), claude (llm step)` on stdout with exactly one `helper_credential_unresolved` footnote. The copy writes its own `{"type":"module"}` boundary: `examples/` carries no package.json, so under a checkout whose ancestry declares `"type": "commonjs"` the authored `.flow.ts` cannot be imported at all, which is a property of the checkout and not of the flow. Mutation-verified in evidence/helper-check/mutation-shipped-example.md: reverting cli.ts's `warnUnresolvedHelperCredential` opt-in fails the test on the exact refusal from the report, and the restored file is byte-identical (sha256 83e60fd1…). Co-Authored-By: Claude Opus 5 --- .../helper-check/mutation-shipped-example.md | 82 +++++++++++++++++++ .../regression-shipped-example.txt | 26 ++++++ .../typecheck-shipped-example.txt | 10 +++ .../sdk/tests/check-helper-surface.test.ts | 31 ++++++- 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 evidence/helper-check/mutation-shipped-example.md create mode 100644 evidence/helper-check/regression-shipped-example.txt create mode 100644 evidence/helper-check/typecheck-shipped-example.txt diff --git a/evidence/helper-check/mutation-shipped-example.md b/evidence/helper-check/mutation-shipped-example.md new file mode 100644 index 000000000..45e92baba --- /dev/null +++ b/evidence/helper-check/mutation-shipped-example.md @@ -0,0 +1,82 @@ +# Mutation verification — the shipped-example regression test + +## 1. Revert the fix (the authored `.flow.ts` opt-in in cli.ts) + +``` +$ sed -i 's/ const invocation = { warnUnresolvedHelperCredential: true };/ const invocation = {};/' packages/sdk/src/cli.ts +$ git diff packages/sdk/src/cli.ts +@@ -447,3 +447,3 @@ export async function runCli( + async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckReport }> { +- const invocation = { warnUnresolvedHelperCredential: true }; ++ const invocation = {}; + const helper = await checkHelperBody(path, invocation); +$ sha256sum packages/sdk/src/cli.ts +d5b85a21a85115a0561011ed4dbda1b5f86a28d915c33c3466a07dfd45926f5d packages/sdk/src/cli.ts +``` + +## 2. The test fails, with exactly the refusal from the report + +``` +$ cd packages/sdk && ./node_modules/.bin/vitest run tests/check-helper-surface.test.ts -t "answers the shipped Cloud-bound stale-issues example instead of refusing it" + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ❯ tests/check-helper-surface.test.ts (16 tests | 1 failed | 15 skipped) 99ms + × answers the shipped Cloud-bound stale-issues example instead of refusing it 98ms + → expected [ Array(1) ] to deeply equal [] + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL tests/check-helper-surface.test.ts > answers the shipped Cloud-bound stale-issues example instead of refusing it +AssertionError: expected [ Array(1) ] to deeply equal [] + +- Expected ++ Received + +- Array [] ++ Array [ ++ "REFUSED [helper_slack.credential_missing] f.slack requires a relayfile slack mount; direct-token transport is not implemented.", ++ ] + + ❯ tests/check-helper-surface.test.ts:85:68 + 83| // workspace integration at submit, so inspection has to answer, not… + 84| const result = await check(shippedExample('stale-issues')); + 85| expect(result.stderr.filter(line => line.startsWith('REFUSED'))).toE… + | ^ + 86| expect(result.exit).toBe(0); + 87| expect(result.stdout).toContain('REQUIRES slack (tools.slack), claud… + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + Test Files 1 failed (1) + Tests 1 failed | 15 skipped (16) + Start at 14:15:50 + Duration 2.00s (transform 1.10s, setup 17ms, collect 1.70s, tests 99ms, environment 0ms, prepare 41ms) + +``` + +## 3. Restore byte-for-byte + +``` +$ cp /tmp/cli.ts.orig packages/sdk/src/cli.ts +$ git diff --stat packages/sdk/src/cli.ts # empty: identical to HEAD +$ sha256sum packages/sdk/src/cli.ts +83e60fd1e04acdc5ea679585c23923fd93fafd335c72077df776e0da9585d33c packages/sdk/src/cli.ts +``` + +## 4. The test passes again + +``` +$ cd packages/sdk && ./node_modules/.bin/vitest run tests/check-helper-surface.test.ts -t "answers the shipped Cloud-bound stale-issues example instead of refusing it" + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/check-helper-surface.test.ts (16 tests | 15 skipped) 573ms + ✓ answers the shipped Cloud-bound stale-issues example instead of refusing it 572ms + + Test Files 1 passed (1) + Tests 1 passed | 15 skipped (16) + Start at 14:15:52 + Duration 3.11s (transform 1.55s, setup 13ms, collect 2.35s, tests 573ms, environment 0ms, prepare 39ms) + +``` diff --git a/evidence/helper-check/regression-shipped-example.txt b/evidence/helper-check/regression-shipped-example.txt new file mode 100644 index 000000000..061c9ea01 --- /dev/null +++ b/evidence/helper-check/regression-shipped-example.txt @@ -0,0 +1,26 @@ +$ cd packages/sdk && ./node_modules/.bin/vitest run tests/check-helper-surface.test.ts tests/authored-flow-slack.test.ts tests/check-worker-surface.test.ts tests/flow-requirements.test.ts tests/preflight.test.ts tests/helpers-fanout.test.ts tests/helper-reference.test.ts tests/flow-extension-compose.test.ts + + RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk + + ✓ tests/preflight.test.ts (70 tests) 104ms + ✓ tests/helper-reference.test.ts (30 tests) 34ms + ✓ tests/authored-flow-slack.test.ts (7 tests) 1947ms + ✓ authored Slack helper effects > refuses missing credentials before running the body while flows check warns 441ms + ✓ authored Slack helper effects > replays after SIGKILL before confirm with the same token and one successful completion 508ms + ✓ authored Slack helper effects > replays after SIGKILL before complete with the same token and one successful completion 496ms + ✓ tests/flow-requirements.test.ts (14 tests) 797ms + ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 533ms + ✓ tests/check-worker-surface.test.ts (11 tests) 91ms + ✓ tests/helpers-fanout.test.ts (96 tests) 190ms + ✓ tests/flow-extension-compose.test.ts (33 tests) 5949ms + ✓ composing flow extensions onto a base flow > composes two extensions in declaration order, and the order is the lockfile order 476ms + ✓ composing flow extensions onto a base flow > flows check reports the composition and keeps the composed triggers deliverable 962ms + ✓ tests/check-helper-surface.test.ts (16 tests) 5008ms + ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 456ms + ✓ preserves requirements when compilation refuses and for named agents 3058ms + + Test Files 8 passed (8) + Tests 277 passed (277) + Start at 14:16:19 + Duration 8.95s (transform 1.61s, setup 73ms, collect 5.86s, tests 14.12s, environment 1ms, prepare 366ms) + diff --git a/evidence/helper-check/typecheck-shipped-example.txt b/evidence/helper-check/typecheck-shipped-example.txt new file mode 100644 index 000000000..2c65ae331 --- /dev/null +++ b/evidence/helper-check/typecheck-shipped-example.txt @@ -0,0 +1,10 @@ +$ cd packages/sdk && npm run typecheck && npm run typecheck:tests + +> @relayflows/sdk@2.0.42 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.42 typecheck:tests +> tsc -p tsconfig.tests.json + +exit=0 diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index d1e60dd93..95d86cfe9 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; @@ -29,6 +29,24 @@ function fixture(body = "await f.slack.post('#test', 'hi');", header = '', name export default flow('test', ${header} async f => { ${body} f.done('success'); });`); return path; } +/** + * A shipped example, copied where `@relayflows/surface` resolves — the same + * staging `tests/flow-requirements.test.ts` uses. Copied rather than checked in + * place because `examples/` carries no `package.json`, so a checkout whose + * ancestry declares `"type": "commonjs"` cannot import an authored `.flow.ts` + * there at all; the temp copy declares its own module boundary. + */ +function shippedExample(name: string) { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-example-')); + directories.push(dir); + writeFileSync(join(dir, 'package.json'), '{"type":"module"}'); + writeFileSync(join(dir, 'flows.json'), '{}'); + mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); + symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); + const path = join(dir, `${name}.flow.ts`); + copyFileSync(resolve('../../examples', name, `${name}.flow.ts`), path); + return path; +} async function check(path: string, json = false, verb = 'check') { const lines: string[] = []; const stdout: string[] = []; @@ -58,6 +76,17 @@ it('reports the declared integration once after REQUIRES and before CHECK PASSED expect(passed).toBeGreaterThan(warning); expect(result.stderr.filter(line => line.includes('[helper_credential_unresolved]'))).toHaveLength(1); }); +it('answers the shipped Cloud-bound stale-issues example instead of refusing it', async () => { + // The reported defect, on the artifact it was reported against: with no local + // Slack mount this exited 2 on [helper_slack.credential_missing] and never + // reached REQUIRES. `flows schedule` / `flows deploy` check Slack as a + // workspace integration at submit, so inspection has to answer, not refuse. + const result = await check(shippedExample('stale-issues')); + expect(result.stderr.filter(line => line.startsWith('REFUSED'))).toEqual([]); + expect(result.exit).toBe(0); + expect(result.stdout).toContain('REQUIRES slack (tools.slack), claude (llm step)'); + expect(result.lines.filter(line => line.includes('[helper_credential_unresolved]'))).toHaveLength(1); +}); it('emits one diagnostic in JSON and stderr with integration requirements', async () => { const result = await check(fixture('', '{ tools: { slack: true } },'), true); expect(result.exit).toBe(0); From 5cd190dacd42370f5e31110a54ac55e2e2df00f1 Mon Sep 17 00:00:00 2001 From: Relayflow Date: Tue, 6 Oct 2026 14:29:50 +0000 Subject: [PATCH 04/43] Keep relayflow working files out of the change --- summary.md | 270 ----------------------------------------------------- 1 file changed, 270 deletions(-) delete mode 100644 summary.md diff --git a/summary.md b/summary.md deleted file mode 100644 index 78472a8be..000000000 --- a/summary.md +++ /dev/null @@ -1,270 +0,0 @@ -Fix: let flows check report Cloud-bound helper requirements without local mounts - -A laptop without Slack mounted can inspect an authored Slack flow: check reports -REQUIRES slack and helper_credential_unresolved, then passes. Local run retains -its credential refusal before daemon attachment. The same rule applies to YAML -helpers, whose requirements are now derived from compiled steps with an authoring -fallback for invalid specs. - -The warning is opt-in at the check surface, emitted once after REQUIRES. -Non-.flow.* modules receive a self-contained warning without a requirements line. -Unsupported providers and Notion appendBlock remain refusals even without mounts. -Hosted submission, execution, resume, and pure preflight retain their existing -credential checks. Watch inherits the CLI behavior through its child process. - -Implementation follows reviewed-plan.md Parts A and B as separate commits. -The two existing authored Slack assertions were retargeted because their check -behavior changes; their executeAuthoredFlow rejection assertions remain intact. -No quality gates or GitHub workflow files were edited. - -Validation and limits: -- 269 regression tests and 3 daemon-free authored tests passed; build and both - TypeScript checks passed. Commands and literal output follow. -- Warning placement and Notion refusal were mutation-verified: each specific - source change was reverted, its test failed, the source bytes were restored, - and the same test passed. Both outputs are below. -- The named-agent and unresolved-use requirements comparison preserved the - codex requirement. The use fixture is deliberately invalid; it verifies the - compile-failure fallback, not successful import expansion. -- The exact example passes check and refuses local run with a temporary - package.json module boundary. The helper script restores the checkout. - Without that boundary, this checkout's Node import fails before preflight - (example-check.txt and example-run.txt). An attempted Node module flag was - unsupported (example-*-module.txt); no loader fix is included. -- Full SDK tests were not run: Cargo and relayflowd are unavailable. - The initial broader command failed direct-input.test.ts setup for the absent - binary; its literal output is in evidence/helper-check/regression.txt, alongside - an initial YAML test argument error corrected before the final run. - The authored subset skips ten daemon-dependent cases: Slack structured posts, - journaled effect, confirm/complete SIGKILL replay, and multi-call writeback; - generic helper acceptance, all-provider resume, confirm/complete SIGKILL replay, - and provider-failure journaling. Those crash gates remain unverified here. - -Captured evidence (paths relative to the repository): - -evidence/helper-check/build.txt - -```text -$ npm --prefix packages/sdk run build - -> @relayflows/sdk@2.0.42 build -> tsc && node scripts/make-cli-executable.mjs - - -``` - -evidence/helper-check/typecheck.txt - -```text -$ npm --prefix packages/sdk run typecheck - -> @relayflows/sdk@2.0.42 typecheck -> tsc --noEmit && tsc -p tsconfig.type-tests.json - - -``` - -evidence/helper-check/typecheck-tests.txt - -```text -$ npm --prefix packages/sdk run typecheck:tests - -> @relayflows/sdk@2.0.42 typecheck:tests -> tsc -p tsconfig.tests.json - - -``` - -evidence/helper-check/regression-final.txt - -```text -$ cd packages/sdk && npx vitest run tests/check-helper-surface.test.ts tests/check-worker-surface.test.ts tests/preflight.test.ts tests/helpers-fanout.test.ts tests/helper-reference.test.ts tests/flow-requirements.test.ts tests/flow-extension-compose.test.ts - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ✓ tests/helper-reference.test.ts (30 tests) 22ms - ✓ tests/preflight.test.ts (70 tests) 100ms - ✓ tests/check-worker-surface.test.ts (11 tests) 88ms - ✓ tests/flow-requirements.test.ts (14 tests) 1030ms - ✓ flows check prints REQUIRES > names the helper, the harness and the mcp server of an authored flow 786ms - ✓ tests/helpers-fanout.test.ts (96 tests) 156ms - ✓ tests/check-helper-surface.test.ts (15 tests) 4150ms - ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 479ms - ✓ keeps default and explicit opt-out checks strict, and local run refuses before attach 339ms - ✓ preserves requirements when compilation refuses and for named agents 2248ms - ✓ tests/flow-extension-compose.test.ts (33 tests) 5886ms - ✓ composing flow extensions onto a base flow > composes two extensions in declaration order, and the order is the lockfile order 445ms - ✓ composing flow extensions onto a base flow > flows check reports the composition and keeps the composed triggers deliverable 973ms - ✓ composing flow extensions onto a base flow > flows check probes extension preflight before reporting the project healthy 305ms - ✓ composing flow extensions onto a base flow > uses extension permissions for hosted deploy preflight and the deploy body 322ms - - Test Files 7 passed (7) - Tests 269 passed (269) - Start at 13:40:37 - Duration 7.93s (transform 1.99s, setup 57ms, collect 6.67s, tests 11.43s, environment 1ms, prepare 287ms) - - -``` - -evidence/helper-check/authored-subset.txt - -```text -$ cd packages/sdk && npx vitest run tests/authored-flow-slack.test.ts tests/authored-helpers.test.ts -t 'refuses missing credentials|on `.flow.ts` paths too|rejects malformed arguments' - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ✓ tests/authored-helpers.test.ts (6 tests | 5 skipped) 28ms - ✓ tests/authored-flow-slack.test.ts (7 tests | 5 skipped) 727ms - ✓ authored Slack helper effects > refuses missing credentials before running the body while flows check warns 511ms - - Test Files 2 passed (2) - Tests 3 passed | 10 skipped (13) - Start at 13:40:46 - Duration 2.58s (transform 1.11s, setup 27ms, collect 2.85s, tests 755ms, environment 0ms, prepare 112ms) - - -``` - -evidence/helper-check/requirements.txt - -```text -$ node evidence/helper-check/requirements.mjs -named-agent before: REQUIRES codex (step "review") -named-agent after: REQUIRES codex (step "review") -named-agent refusals: model_unavailable -use before: REQUIRES codex (step "review") -use after: REQUIRES codex (step "review") -use refusals: invalid_spec - -``` - -evidence/helper-check/example-module-boundary.txt - -```text -$ python3 evidence/helper-check/example.py -$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js check examples/stale-issues/stale-issues.flow.ts -REQUIRES slack (tools.slack), claude (llm step) -WARNING [helper_credential_unresolved] f.slack needs a slack mount, which is not available locally. flows schedule / flows deploy / flows run --cloud check the integration against your workspace at submit and refuse if Cloud cannot connect it. A local flows run needs a relayfile slack mount (a slack/ directory under RELAYFILE_MOUNT_PATH) or RELAYFLOWS_SLACK_MOCK=1, and refuses with [helper_slack.credential_missing] without one. -CHECK PASSED examples/stale-issues/stale-issues.flow.ts -exit=0 -$ env -u SLACK_BOT_TOKEN -u RELAYFLOWS_SLACK_MOCK -u RELAYFILE_MOUNT_PATH -u WORKSPACE_ROOT -u WORKFORCE_SANDBOX_ROOT -u RELAYFILE_MOUNT_ROOT -u RELAYFILE_ROOT node packages/sdk/dist/cli.js run examples/stale-issues/stale-issues.flow.ts --input '{"repo":"acme/api","channel":"#eng"}' -REFUSED [helper_slack.credential_missing] f.slack requires a relayfile slack mount; direct-token transport is not implemented. -exit=2 - -``` - -evidence/helper-check/mutation-placement-fail.txt - -```text -$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 542ms - × reports the declared integration once after REQUIRES and before CHECK PASSED 541ms - → expected 0 to be greater than 1 - -⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ - - FAIL tests/check-helper-surface.test.ts > reports the declared integration once after REQUIRES and before CHECK PASSED -AssertionError: expected 0 to be greater than 1 - ❯ tests/check-helper-surface.test.ts:57:19 - 55| const passed = result.lines.findIndex(line => line.includes('CHECK P… - 56| expect(requires).toBeGreaterThanOrEqual(0); - 57| expect(warning).toBeGreaterThan(requires); - | ^ - 58| expect(passed).toBeGreaterThan(warning); - 59| expect(result.stderr.filter(line => line.includes('[helper_credentia… - -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ - - Test Files 1 failed (1) - Tests 1 failed | 10 skipped (11) - Start at 13:39:05 - Duration 2.45s (transform 1.09s, setup 16ms, collect 1.70s, tests 542ms, environment 0ms, prepare 42ms) - - -exit=1 - -``` - -evidence/helper-check/mutation-placement-pass.txt - -```text -$ npx vitest run tests/check-helper-surface.test.ts -t reports the declared integration once - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 577ms - ✓ reports the declared integration once after REQUIRES and before CHECK PASSED 576ms - - Test Files 1 passed (1) - Tests 1 passed | 10 skipped (11) - Start at 13:39:08 - Duration 2.58s (transform 1.19s, setup 17ms, collect 1.80s, tests 577ms, environment 0ms, prepare 53ms) - - -exit=0 - -``` - -evidence/helper-check/mutation-notion-fail.txt - -```text -$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ❯ tests/check-helper-surface.test.ts (11 tests | 1 failed | 10 skipped) 590ms - × still refuses unsupported notion appendBlock without a mount 588ms - → expected +0 to be 2 // Object.is equality - -⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ - - FAIL tests/check-helper-surface.test.ts > still refuses unsupported notion appendBlock without a mount -AssertionError: expected +0 to be 2 // Object.is equality - -- Expected -+ Received - -- 2 -+ 0 - - ❯ tests/check-helper-surface.test.ts:80:23 - 78| ])('still refuses unsupported %s without a mount', async (_name, body)… - 79| const result = await check(fixture(body)); - 80| expect(result.exit).toBe(2); - | ^ - 81| expect(result.stderr.join('\n')).toContain('[helper_provider.unsuppo… - 82| }); - -⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ - - Test Files 1 failed (1) - Tests 1 failed | 10 skipped (11) - Start at 13:39:11 - Duration 2.56s (transform 1.12s, setup 18ms, collect 1.72s, tests 590ms, environment 0ms, prepare 84ms) - - -exit=1 - -``` - -evidence/helper-check/mutation-notion-pass.txt - -```text -$ npx vitest run tests/check-helper-surface.test.ts -t still refuses unsupported notion - - RUN v2.1.9 /home/daytona/.relayflow-v2-supervisor/durable/repository/packages/sdk - - ✓ tests/check-helper-surface.test.ts (11 tests | 10 skipped) 90ms - - Test Files 1 passed (1) - Tests 1 passed | 10 skipped (11) - Start at 13:39:14 - Duration 2.16s (transform 1.23s, setup 20ms, collect 1.87s, tests 90ms, environment 0ms, prepare 50ms) - - -exit=0 - -``` From c8eb1b051da509b588187df2641323aac294cc94 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 23:03:51 -0700 Subject: [PATCH 05/43] fix(check): keep communication preflight and honest YAML remedies when a helper mount is missing F1: when the only refusal is a missing local helper mount that `flows check` restates as helper_credential_unresolved, still compile the flow for inspection so the communication environment refusal (RELAY_API_KEY) and the budget_unmetered warning no longer depend on whether the helper is mounted. The compiled flow is still returned only when preflight admitted it. F2: YAML execution honours a mock only for Slack; the warning no longer recommends RELAYFLOWS_LINEAR_MOCK / RELAYFLOWS_GITHUB_MOCK for YAML helpers. Authored TS helpers keep their provider-specific mock advice. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli/check-helper-surface.ts | 8 ++- packages/sdk/src/cli/check.ts | 9 ++- .../sdk/tests/check-helper-surface.test.ts | 72 ++++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/cli/check-helper-surface.ts b/packages/sdk/src/cli/check-helper-surface.ts index 2df3abb46..5cfac853b 100644 --- a/packages/sdk/src/cli/check-helper-surface.ts +++ b/packages/sdk/src/cli/check-helper-surface.ts @@ -27,12 +27,16 @@ export function helperCredentialDiagnostics(diagnostics: readonly PreflightDiagn } if (seen.has(helper.provider)) continue; seen.add(helper.provider); + // YAML execution (helper_mount_required) honours a mock only for Slack + // (yaml-helper-effect.ts helperReady); every other YAML helper needs a mount. + const mockSupported = diagnostic.kind !== 'helper_mount_required' || helper.provider === 'slack'; result.push({ severity: 'warning', kind: 'helper_credential_unresolved', message: `f.${helper.namespace} needs a ${helper.provider} mount, which is not available locally. ` + 'flows schedule / flows deploy / flows run --cloud check the integration against your workspace at submit and refuse if Cloud cannot connect it. ' - + `A local flows run needs a relayfile ${helper.provider} mount (a ${helper.provider}/ directory under RELAYFILE_MOUNT_PATH) ` - + `or ${helper.mockEnv}=1, and refuses with [${diagnostic.kind}] without one.`, + + `A local flows run needs a relayfile ${helper.provider} mount (a ${helper.provider}/ directory under RELAYFILE_MOUNT_PATH)` + + (mockSupported ? ` or ${helper.mockEnv}=1` : '') + + `, and refuses with [${diagnostic.kind}] without one.`, }); } return { diagnostics: result, downgraded: seen.size > 0 }; diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index fec93cd68..d21343bd2 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -226,7 +226,14 @@ export function checkAuthoredFlow( ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}), probes, }); - const flow = result.ok + // check may restate a missing local helper mount as a warning. When that is + // the only refusal, still compile the flow so the communication checks + // below run: they must not depend on whether a helper is mounted. The + // compiled flow is returned only when preflight itself admitted it. + const inspectOnly = !result.ok && invocation.warnUnresolvedHelperCredential === true + && !helperCredentialDiagnostics(result.diagnostics).diagnostics + .some(diagnostic => diagnostic.severity === 'refusal'); + const flow = result.ok || inspectOnly ? bindResolvedCliPaths( compileSpec(authoring), result.resolutions, diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 95d86cfe9..a0e142dae 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -1,4 +1,4 @@ -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; @@ -7,6 +7,8 @@ import { checkHelperBody } from '../src/cli/check-helper-body.js'; import { checkAuthoredTriggers } from '../src/cli/check-triggers.js'; import { CHECK_WARNING_KINDS } from '../src/failure-kinds.js'; import { helperCredentialDiagnostics } from '../src/cli/check-helper-surface.js'; +import { checkAuthoredFlow } from '../src/cli/check.js'; +import type { FlowSpec } from '../src/spec.js'; const directories: string[] = []; beforeEach(() => { @@ -200,3 +202,71 @@ steps: expect(report.requirements.harnessUses).toContainEqual({ harness: 'codex', detail: 'step "review"' }); } }); + +function communicatingYaml(provider: 'slack' | 'linear' | 'github', mounted: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-comm-')); + directories.push(dir); + const cli = join(dir, 'codex'); + writeFileSync(cli, '#!/bin/sh\nexit 0\n'); + chmodSync(cli, 0o755); + if (mounted) { + mkdirSync(join(dir, provider)); + vi.stubEnv('RELAYFILE_MOUNT_PATH', dir); + } + const helper = provider === 'slack' ? { slack: { post: { channel: '#test', text: 'hi' } } } + : provider === 'linear' ? { linear: { createIssue: { teamId: 'eng', title: 'hi' } } } + : { github: { createIssue: { owner: 'o', repo: 'r', title: 'hi', body: 'hi' } } }; + const spec = { version: '0.1.0', cli, steps: [ + { id: 'a', type: 'agent', instruction: 'send' }, { id: 'b', type: 'agent', instruction: 'receive' }, + { id: 'notify', ...helper }, + ], communication: { links: [{ from: 'a', to: 'b' }] } } as unknown as FlowSpec; + const config = { directory: dir, models: [], executors: [] }; + return (warn: boolean) => checkAuthoredFlow(spec, join(dir, 'flow.yaml'), + { projectConfig: config }, { warnUnresolvedHelperCredential: warn }).report; +} +it('inspecting past a missing mount never admits the flow for execution', () => { + vi.stubEnv('RELAY_API_KEY', 'rk_live_test'); + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-admit-')); + directories.push(dir); + const cli = join(dir, 'codex'); + writeFileSync(cli, '#!/bin/sh\nexit 0\n'); + chmodSync(cli, 0o755); + const spec = { version: '0.1.0', cli, steps: [{ id: 'a', type: 'agent', instruction: 'go' }, + { id: 'notify', slack: { post: { channel: '#test', text: 'hi' } } }] } as unknown as FlowSpec; + const config = { directory: dir, models: [], executors: [] }; + const checked = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }, + { warnUnresolvedHelperCredential: true }); + expect(checked.report.ok).toBe(true); + expect(checked.flow).toBeUndefined(); + const strict = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }); + expect(strict.report.ok).toBe(false); + expect(strict.flow).toBeUndefined(); +}); +it.each([false, true])('keeps the communication refusal and budget warning whether or not Slack is mounted (mounted=%s)', mounted => { + vi.stubEnv('RELAY_API_KEY', ''); + const report = communicatingYaml('slack', mounted)(true); + expect(report.ok).toBe(false); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ severity: 'refusal', kind: 'probe_failed', message: expect.stringContaining('RELAY_API_KEY') }), + expect.objectContaining({ severity: 'warning', kind: 'budget_unmetered' }), + ])); + expect(report.diagnostics.some(d => d.kind === 'helper_credential_unresolved')).toBe(!mounted); +}); +it('YAML Slack keeps the mock remedy because the YAML execution path honours it', () => { + const report = communicatingYaml('slack', false)(true); + const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); + expect(warning?.message).toContain('RELAYFLOWS_SLACK_MOCK=1'); +}); +it.each(['linear', 'github'] as const)('YAML %s offers only remedies its execution path supports', provider => { + const check = communicatingYaml(provider, false); + const report = check(true); + const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); + expect(warning?.message).toContain(`relayfile ${provider} mount`); + expect(warning?.message).not.toMatch(/_MOCK=1/); + expect(warning?.message).toContain('[helper_mount_required]'); +}); +it('authored TS helpers keep their provider-specific mock remedy', () => { + const { diagnostics } = helperCredentialDiagnostics([{ severity: 'refusal', kind: 'helper_provider.mount_required', + message: 'f.linear requires a relayfile linear mount; direct-token transport is not implemented.' }]); + expect(diagnostics[0]?.message).toContain('RELAYFLOWS_LINEAR_MOCK=1'); +}); From 72043aaaf19653fa8cd3a68a148ddb9e6601f6a8 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 7 Oct 2026 23:55:26 -0700 Subject: [PATCH 06/43] fix(check): detect f.notion.appendBlock from the parse, not the body text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unsupported-operation check matched any `.appendBlock` in the body — an unrelated object's method, a string, a comment — so a Notion flow with no local mount still exited 2 after its mount refusal became a warning. helperMethodsUsed reads `f..` references with the same scoped parse as helperNamespacesUsed; only a body whose context name cannot be resolved keeps the permissive text match. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 7 +++-- packages/sdk/src/helper-reference.ts | 28 +++++++++++++++++++ .../sdk/tests/check-helper-surface.test.ts | 18 ++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index b7f8b3476..f675e2b5c 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -1,6 +1,6 @@ import { helperProviders } from '@relayflows/surface/runtime'; import type { PreflightResult, PreflightDiagnostic } from './preflight.js'; -import { helperNamespacesUsed } from './helper-reference.js'; +import { helperMethodsUsed, helperNamespacesUsed } from './helper-reference.js'; /** Static discovery never executes the body; dynamic aliases are checked at call time. */ export function preflightHelpers( @@ -18,6 +18,9 @@ export function preflightHelpers( // comment, template quasi or regex is not used, and refusing on one demands // a mount the flow never touches. const referenced = root === undefined ? new Set() : helperNamespacesUsed(body, root); + // An unresolvable context name leaves only the text; keep refusing permissively there. + const appendsNotionBlock = root === undefined + ? /\.\s*appendBlock\b/.test(body) : helperMethodsUsed(body, root).has('notion.appendBlock'); for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); @@ -34,7 +37,7 @@ export function preflightHelpers( message: `f.${namespace} requires a relayfile ${provider} mount; direct-token transport is not implemented.` }); } // Missing local mounts must not hide an operation unsupported on Cloud too. - if (supported && provider === 'notion' && !fact.mock && /\.\s*appendBlock\b/.test(body)) { + if (supported && provider === 'notion' && !fact.mock && appendsNotionBlock) { diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' }); } diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 37749e614..95efe7076 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -38,6 +38,34 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet.` helper operations a flow body references, as + * `namespace.method`. Same parse and scoping as `helperNamespacesUsed`, so an + * unrelated object's method, a string or a comment naming one is not a use. + */ +export function helperMethodsUsed(body: string, root: string): ReadonlySet { + const program = parseFlowBody(body); + const used = new Set(); + if (program === null) { + // Permissive, like textFallback: an unparseable body over-reports. + const escaped = root.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const pattern = new RegExp(`(?:^|[^\\w$.])${escaped}\\s*\\.\\s*([\\w$]+)\\s*\\.\\s*([\\w$]+)`, 'gu'); + for (const match of body.matchAll(pattern)) used.add(`${match[1]}.${match[2]}`); + return used; + } + walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { + if (node.type !== 'MemberExpression') return; + const inner = node.object as AstNode | undefined; + if (inner?.type !== 'MemberExpression') return; + const object = inner.object as AstNode | undefined; + if (object?.type !== 'Identifier' || object.name !== root) return; + const namespace = memberName(inner); + const method = memberName(node); + if (namespace !== undefined && method !== undefined) used.add(`${namespace}.${method}`); + }); + return used; +} + /** `f.slack`, `f["slack"]`, `f?.slack` — but not `f[variable]`, which is unknowable. */ function memberName(node: AstNode): string | undefined { const property = node.property as AstNode | undefined; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index a0e142dae..c07a3594c 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -270,3 +270,21 @@ it('authored TS helpers keep their provider-specific mock remedy', () => { message: 'f.linear requires a relayfile linear mount; direct-token transport is not implemented.' }]); expect(diagnostics[0]?.message).toContain('RELAYFLOWS_LINEAR_MOCK=1'); }); +it('does not refuse an unrelated .appendBlock in a Notion flow with no local mount', async () => { + const body = `const doc = { appendBlock() { return 1; } }; + doc.appendBlock(); // f.notion.appendBlock is not called here + const note = 'x.appendBlock'; + await f.notion.createPage({ parent: 'p', title: note });`; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); + expect(result.stderr.join('\n')).toContain('[helper_credential_unresolved]'); +}); +it('still refuses a real f.notion.appendBlock through an aliased context name', async () => { + const path = fixture('', '{ tools: { notion: true } },'); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', { tools: { notion: true } }, async ctx => { await ctx.notion.appendBlock('page', {}); ctx.done('success'); });`); + const result = await check(path); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From aea5c1aad807c3ecc3a78b5599fe0eceb035a9dc Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:09:29 -0700 Subject: [PATCH 07/43] fix(check): keep the appendBlock refusal for an aliased or destructured f.notion helperMethodsUsed only saw `f.notion.appendBlock`, so `const notion = f.notion` or `const { notion } = f` followed by `notion.appendBlock(...)` passed check while execution always rejects it. helperMemberUses also reports namespaces that leave the context without a method (aliased, destructured, passed on) and every member name the body accesses; when f.notion escapes, any appendBlock access keeps the unsupported-operation refusal. Direct, non-escaping use stays precise, so an unrelated object's appendBlock still does not refuse. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 13 ++-- packages/sdk/src/helper-reference.ts | 66 +++++++++++++++---- .../sdk/tests/check-helper-surface.test.ts | 8 +++ 3 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index f675e2b5c..693658e31 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -1,6 +1,6 @@ import { helperProviders } from '@relayflows/surface/runtime'; import type { PreflightResult, PreflightDiagnostic } from './preflight.js'; -import { helperMethodsUsed, helperNamespacesUsed } from './helper-reference.js'; +import { helperMemberUses, helperNamespacesUsed } from './helper-reference.js'; /** Static discovery never executes the body; dynamic aliases are checked at call time. */ export function preflightHelpers( @@ -18,9 +18,14 @@ export function preflightHelpers( // comment, template quasi or regex is not used, and refusing on one demands // a mount the flow never touches. const referenced = root === undefined ? new Set() : helperNamespacesUsed(body, root); - // An unresolvable context name leaves only the text; keep refusing permissively there. - const appendsNotionBlock = root === undefined - ? /\.\s*appendBlock\b/.test(body) : helperMethodsUsed(body, root).has('notion.appendBlock'); + // An unresolvable context name leaves only the text; keep refusing permissively + // there. An aliased or destructured f.notion cannot be followed statically, so + // any appendBlock member access then counts as the unsupported operation. + const uses = root === undefined ? undefined : helperMemberUses(body, root); + const appendsNotionBlock = uses === undefined + ? /\.\s*appendBlock\b/.test(body) + : uses.methods.has('notion.appendBlock') + || ((uses.escaped.has('notion') || uses.escaped.has('*')) && uses.members.has('appendBlock')); for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 95efe7076..843e9834a 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -42,28 +42,66 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet.` helper operations a flow body references, as * `namespace.method`. Same parse and scoping as `helperNamespacesUsed`, so an * unrelated object's method, a string or a comment naming one is not a use. + * + * A namespace that leaves the context without a method — `const n = f.notion`, + * `const { notion } = f`, `use(f.notion)` — is reported in `escaped` (`*` for + * the whole context), because its later calls cannot be attributed statically. + * `members` lists every member name the body accesses, for callers that must + * stay conservative about an escaped namespace. */ -export function helperMethodsUsed(body: string, root: string): ReadonlySet { +export function helperMemberUses(body: string, root: string): { + methods: ReadonlySet; escaped: ReadonlySet; members: ReadonlySet; +} { const program = parseFlowBody(body); - const used = new Set(); + const methods = new Set(); + const escaped = new Set(); + const members = new Set(); if (program === null) { // Permissive, like textFallback: an unparseable body over-reports. - const escaped = root.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); - const pattern = new RegExp(`(?:^|[^\\w$.])${escaped}\\s*\\.\\s*([\\w$]+)\\s*\\.\\s*([\\w$]+)`, 'gu'); - for (const match of body.matchAll(pattern)) used.add(`${match[1]}.${match[2]}`); - return used; + const escapedRoot = root.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + const pattern = new RegExp(`(?:^|[^\\w$.])${escapedRoot}\\s*\\.\\s*([\\w$]+)\\s*\\.\\s*([\\w$]+)`, 'gu'); + for (const match of body.matchAll(pattern)) methods.add(`${match[1]}.${match[2]}`); + escaped.add('*'); + for (const match of body.matchAll(/\.\s*([\w$]+)/gu)) members.add(match[1]!); + return { methods, escaped, members }; } + const namespaceRefs = new Map(); + const methodRefs = new Map(); + const bump = (map: Map, key: string) => map.set(key, (map.get(key) ?? 0) + 1); walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { + if (node.type === 'VariableDeclarator') { + const id = node.id as AstNode | undefined; + const init = node.init as AstNode | undefined; + if (id?.type === 'ObjectPattern' && init?.type === 'Identifier' && init.name === root) { + for (const property of (id.properties as AstNode[] | undefined) ?? []) { + const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; + const name = key?.type === 'Identifier' && property.computed !== true ? key.name + : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; + escaped.add(name ?? '*'); + } + } + return; + } if (node.type !== 'MemberExpression') return; - const inner = node.object as AstNode | undefined; - if (inner?.type !== 'MemberExpression') return; - const object = inner.object as AstNode | undefined; - if (object?.type !== 'Identifier' || object.name !== root) return; - const namespace = memberName(inner); - const method = memberName(node); - if (namespace !== undefined && method !== undefined) used.add(`${namespace}.${method}`); + const name = memberName(node); + if (name !== undefined) members.add(name); + const object = node.object as AstNode | undefined; + if (object?.type === 'Identifier' && object.name === root) { + if (name !== undefined) bump(namespaceRefs, name); + return; + } + if (object?.type !== 'MemberExpression') return; + const context = object.object as AstNode | undefined; + if (context?.type !== 'Identifier' || context.name !== root) return; + const namespace = memberName(object); + if (namespace === undefined) return; + if (name !== undefined) methods.add(`${namespace}.${name}`); + bump(methodRefs, namespace); }); - return used; + for (const [namespace, count] of namespaceRefs) { + if (count > (methodRefs.get(namespace) ?? 0)) escaped.add(namespace); + } + return { methods, escaped, members }; } /** `f.slack`, `f["slack"]`, `f?.slack` — but not `f[variable]`, which is unknowable. */ diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index c07a3594c..092999b46 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -288,3 +288,11 @@ export default flow('test', { tools: { notion: true } }, async ctx => { await ct expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['an alias', 'const notion = f.notion; await notion.appendBlock("page", {});'], + ['a destructured helper', 'const { notion } = f; await notion.appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From e8d22fd76070777284362ff81a71316a0914954b Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:17:33 -0700 Subject: [PATCH 08/43] fix(check): follow destructured helper methods; reads of f.notion are not hand-offs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const { appendBlock } = f.notion` escaped detection because the method name was a binding, not a member access, and every extra `f.notion` mention — including `if (f.notion)` — counted as an escape. helperMemberUses now records destructured property names as member accesses, and marks a namespace escaped only when its value is handed on: a declaration or assignment initializer, a call/new argument, a property value, an array element, a return, spread or yield (through ?:, ||/??, await and parentheses). Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 102 ++++++++++++------ .../sdk/tests/check-helper-surface.test.ts | 12 +++ 2 files changed, 84 insertions(+), 30 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 843e9834a..29ff82a7f 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -43,11 +43,12 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet; escaped: ReadonlySet; members: ReadonlySet; @@ -65,42 +66,83 @@ export function helperMemberUses(body: string, root: string): { for (const match of body.matchAll(/\.\s*([\w$]+)/gu)) members.add(match[1]!); return { methods, escaped, members }; } - const namespaceRefs = new Map(); - const methodRefs = new Map(); - const bump = (map: Map, key: string) => map.set(key, (map.get(key) ?? 0) + 1); + // Where a value can flow on to: each such slot holding `f.` (or `f`) + // hands the helper to code this scan cannot follow. Reads such as `if (f.notion)` + // or `typeof f.notion` are not slots and do not escape. + const handOff = (expression: AstNode | undefined): void => { + if (expression === undefined) return; + switch (expression.type) { + case 'ConditionalExpression': + handOff(expression.consequent as AstNode); handOff(expression.alternate as AstNode); return; + case 'LogicalExpression': + handOff(expression.left as AstNode); handOff(expression.right as AstNode); return; + case 'SequenceExpression': { + const items = expression.expressions as AstNode[]; + handOff(items[items.length - 1]); return; + } + case 'AwaitExpression': case 'ParenthesizedExpression': case 'ChainExpression': + handOff((expression.argument ?? expression.expression) as AstNode); return; + case 'Identifier': + if (expression.name === root) escaped.add('*'); + return; + case 'MemberExpression': { + const object = expression.object as AstNode | undefined; + const namespace = memberName(expression); + if (object?.type === 'Identifier' && object.name === root) escaped.add(namespace ?? '*'); + return; + } + default: + } + }; + const patternKeys = (pattern: AstNode | undefined): void => { + if (pattern?.type !== 'ObjectPattern') return; + for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { + const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; + const name = key?.type === 'Identifier' && property.computed !== true ? key.name + : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; + if (name !== undefined) members.add(name); + } + }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { - if (node.type === 'VariableDeclarator') { - const id = node.id as AstNode | undefined; - const init = node.init as AstNode | undefined; - if (id?.type === 'ObjectPattern' && init?.type === 'Identifier' && init.name === root) { - for (const property of (id.properties as AstNode[] | undefined) ?? []) { - const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; - const name = key?.type === 'Identifier' && property.computed !== true ? key.name - : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; - escaped.add(name ?? '*'); + switch (node.type) { + case 'VariableDeclarator': + // `const { appendBlock } = f.notion` reads appendBlock as surely as a member access. + patternKeys(node.id as AstNode); + handOff(node.init as AstNode); + if ((node.id as AstNode | undefined)?.type === 'ObjectPattern' && (node.init as AstNode | undefined)?.type === 'Identifier' + && (node.init as AstNode).name === root) { + for (const property of ((node.id as AstNode).properties as AstNode[] | undefined) ?? []) { + const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; + escaped.add(key?.type === 'Identifier' && property.computed !== true ? key.name! + : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : '*'); + } } - } - return; + return; + case 'AssignmentExpression': + patternKeys(node.left as AstNode); handOff(node.right as AstNode); return; + case 'CallExpression': case 'NewExpression': + for (const argument of (node.arguments as AstNode[] | undefined) ?? []) handOff(argument); + return; + case 'Property': + if (node.value !== node.key) handOff(node.value as AstNode); + return; + case 'ArrayExpression': + for (const element of (node.elements as Array | undefined) ?? []) handOff(element ?? undefined); + return; + case 'ReturnStatement': case 'SpreadElement': case 'YieldExpression': + handOff(node.argument as AstNode); return; + case 'MemberExpression': break; + default: return; } - if (node.type !== 'MemberExpression') return; const name = memberName(node); if (name !== undefined) members.add(name); const object = node.object as AstNode | undefined; - if (object?.type === 'Identifier' && object.name === root) { - if (name !== undefined) bump(namespaceRefs, name); - return; - } if (object?.type !== 'MemberExpression') return; const context = object.object as AstNode | undefined; if (context?.type !== 'Identifier' || context.name !== root) return; const namespace = memberName(object); - if (namespace === undefined) return; - if (name !== undefined) methods.add(`${namespace}.${name}`); - bump(methodRefs, namespace); + if (namespace !== undefined && name !== undefined) methods.add(`${namespace}.${name}`); }); - for (const [namespace, count] of namespaceRefs) { - if (count > (methodRefs.get(namespace) ?? 0)) escaped.add(namespace); - } return { methods, escaped, members }; } diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 092999b46..39829246b 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -296,3 +296,15 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it('keeps refusing a destructured f.notion.appendBlock method', async () => { + const result = await check(fixture('const { appendBlock } = f.notion; await appendBlock("page", {});', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('does not treat an existence check of f.notion as a handoff', async () => { + const body = `if (f.notion && typeof f.notion === 'object') await f.notion.createPage({ parent: 'p', title: 't' }); + const doc = { appendBlock() { return 1; } }; doc.appendBlock();`; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); From 5205d7f3c537549bff2747608fee2abd6ab392f2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:32:47 -0700 Subject: [PATCH 09/43] fix(check): judge helper escapes by an allowlist of read-only positions Enumerating hand-off shapes kept missing some (arrow expression bodies, parameter and destructuring defaults, nested destructuring). helperMemberUses now links parents once and treats a use of `f.` as safe only as a method access or a read-only check (typeof/!/comparison operand, condition); every other position escapes the namespace, and a bare `f` outside those positions escapes the whole context. Destructured keys are collected at any depth, through defaults and array patterns. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 126 +++++++++--------- .../sdk/tests/check-helper-surface.test.ts | 10 ++ 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 29ff82a7f..24b5d702f 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -43,10 +43,11 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet` (or `f`) - // hands the helper to code this scan cannot follow. Reads such as `if (f.notion)` - // or `typeof f.notion` are not slots and do not escape. - const handOff = (expression: AstNode | undefined): void => { - if (expression === undefined) return; - switch (expression.type) { - case 'ConditionalExpression': - handOff(expression.consequent as AstNode); handOff(expression.alternate as AstNode); return; - case 'LogicalExpression': - handOff(expression.left as AstNode); handOff(expression.right as AstNode); return; - case 'SequenceExpression': { - const items = expression.expressions as AstNode[]; - handOff(items[items.length - 1]); return; - } - case 'AwaitExpression': case 'ParenthesizedExpression': case 'ChainExpression': - handOff((expression.argument ?? expression.expression) as AstNode); return; - case 'Identifier': - if (expression.name === root) escaped.add('*'); - return; - case 'MemberExpression': { - const object = expression.object as AstNode | undefined; - const namespace = memberName(expression); - if (object?.type === 'Identifier' && object.name === root) escaped.add(namespace ?? '*'); - return; + // Parent links for the whole body, so each visible use of the context can be + // judged by where it sits. + const parents = new WeakMap(); + const link = (node: AstNode): void => { + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = node[key]; + for (const entry of Array.isArray(child) ? child : [child]) { + if (isNode(entry)) { parents.set(entry, node); link(entry); } } - default: } }; + link(program); + // Every destructured key, at any depth: `const { notion: { appendBlock } } = f` + // reads appendBlock as surely as a member access does. const patternKeys = (pattern: AstNode | undefined): void => { - if (pattern?.type !== 'ObjectPattern') return; + if (pattern === undefined) return; + if (pattern.type === 'AssignmentPattern') return patternKeys(pattern.left as AstNode); + if (pattern.type === 'ArrayPattern') { + for (const element of (pattern.elements as Array | undefined) ?? []) patternKeys(element ?? undefined); + return; + } + if (pattern.type !== 'ObjectPattern') return; for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { - const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; - const name = key?.type === 'Identifier' && property.computed !== true ? key.name - : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; + if (property.type !== 'Property') continue; + const name = keyName(property); if (name !== undefined) members.add(name); + patternKeys(property.value as AstNode); + } + }; + // A read that cannot hand the helper on: a member access (`f.notion.x`), an + // operand of typeof/!/comparison, or a condition. Anything else may alias it. + const readOnly = (node: AstNode): boolean => { + const parent = parents.get(node); + if (parent === undefined) return false; + switch (parent.type) { + case 'UnaryExpression': case 'BinaryExpression': return true; + case 'IfStatement': case 'WhileStatement': case 'DoWhileStatement': case 'ForStatement': + return parent.test === node; + case 'ConditionalExpression': return parent.test === node || readOnly(parent); + case 'LogicalExpression': case 'ChainExpression': return readOnly(parent); + default: return false; } }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { - switch (node.type) { - case 'VariableDeclarator': - // `const { appendBlock } = f.notion` reads appendBlock as surely as a member access. - patternKeys(node.id as AstNode); - handOff(node.init as AstNode); - if ((node.id as AstNode | undefined)?.type === 'ObjectPattern' && (node.init as AstNode | undefined)?.type === 'Identifier' - && (node.init as AstNode).name === root) { - for (const property of ((node.id as AstNode).properties as AstNode[] | undefined) ?? []) { - const key = property.type === 'Property' ? property.key as AstNode | undefined : undefined; - escaped.add(key?.type === 'Identifier' && property.computed !== true ? key.name! - : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : '*'); - } - } + if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } + if (node.type === 'Identifier' && node.name === root) { + const parent = parents.get(node); + const declaresIt = parent !== undefined && isFunction(parent) + && ((parent.params as AstNode[] | undefined) ?? []).includes(node); + const isName = parent?.type === 'Property' && parent.key === node && parent.computed !== true && parent.shorthand !== true + || parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true; + if (declaresIt || isName) return; + if (parent?.type === 'MemberExpression' && parent.object === node) { + const namespace = memberName(parent); + const grand = parents.get(parent); + const method = grand?.type === 'MemberExpression' && grand.object === parent; + if (!method && !readOnly(parent)) escaped.add(namespace ?? '*'); return; - case 'AssignmentExpression': - patternKeys(node.left as AstNode); handOff(node.right as AstNode); return; - case 'CallExpression': case 'NewExpression': - for (const argument of (node.arguments as AstNode[] | undefined) ?? []) handOff(argument); - return; - case 'Property': - if (node.value !== node.key) handOff(node.value as AstNode); - return; - case 'ArrayExpression': - for (const element of (node.elements as Array | undefined) ?? []) handOff(element ?? undefined); - return; - case 'ReturnStatement': case 'SpreadElement': case 'YieldExpression': - handOff(node.argument as AstNode); return; - case 'MemberExpression': break; - default: return; + } + if (!readOnly(node)) escaped.add('*'); + return; } + if (node.type !== 'MemberExpression') return; const name = memberName(node); if (name !== undefined) members.add(name); const object = node.object as AstNode | undefined; @@ -146,6 +143,13 @@ export function helperMemberUses(body: string, root: string): { return { methods, escaped, members }; } +/** A non-computed property key's name, or a string-literal key. */ +function keyName(property: AstNode): string | undefined { + const key = property.key as AstNode | undefined; + if (key?.type === 'Identifier' && property.computed !== true) return key.name; + return key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; +} + /** `f.slack`, `f["slack"]`, `f?.slack` — but not `f[variable]`, which is unknowable. */ function memberName(node: AstNode): string | undefined { const property = node.property as AstNode | undefined; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 39829246b..f8e31b18a 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -308,3 +308,13 @@ it('does not treat an existence check of f.notion as a handoff', async () => { expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); expect(result.exit).toBe(0); }); +it.each([ + ['nested destructuring', 'const { notion: { appendBlock } } = f; await appendBlock("page", {});'], + ['an arrow expression body', 'const get = () => f.notion; await get().appendBlock("page", {});'], + ['a parameter default', 'const run = async (n = f.notion) => n.appendBlock("page", {}); await run();'], + ['a nested destructuring default', 'const { appendBlock: add = f.notion.appendBlock } = {}; await add("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 7284ad1cc4b2cc57b9343c4f74c65e70c0435ae0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:42:33 -0700 Subject: [PATCH 10/43] fix(check): say why an alias-inferred appendBlock refusal fired, and how to avoid it When f.notion escapes, flows check cannot attribute a later .appendBlock to it or to another object, and deliberately refuses rather than admit a flow that execution always rejects. The refusal now says so and names the remedy: call f.notion methods directly so check can tell them apart. Direct use stays precise (an unrelated doc.appendBlock with f.notion.createPage passes). Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 7 ++++++- packages/sdk/tests/check-helper-surface.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index 693658e31..5293cf13d 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -22,6 +22,9 @@ export function preflightHelpers( // there. An aliased or destructured f.notion cannot be followed statically, so // any appendBlock member access then counts as the unsupported operation. const uses = root === undefined ? undefined : helperMemberUses(body, root); + // Attributed only when f.notion is called directly; an escaped f.notion cannot be + // told apart from another object's appendBlock, so check refuses and says why. + const notionAppendInferred = uses !== undefined && !uses.methods.has('notion.appendBlock'); const appendsNotionBlock = uses === undefined ? /\.\s*appendBlock\b/.test(body) : uses.methods.has('notion.appendBlock') @@ -44,7 +47,9 @@ export function preflightHelpers( // Missing local mounts must not hide an operation unsupported on Cloud too. if (supported && provider === 'notion' && !fact.mock && appendsNotionBlock) { diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', - message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' }); + message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' + + (notionAppendInferred ? ' f.notion is aliased or passed on in this body, so any .appendBlock access is treated as this operation;' + + ' call f.notion methods directly (f.notion.createPage(...)) to let flows check tell them apart.' : '') }); } } return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics }; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index f8e31b18a..df4f94b19 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -318,3 +318,12 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it('explains an alias-inferred appendBlock refusal and how to avoid it', async () => { + const aliased = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); + expect(aliased.exit).toBe(2); + expect(aliased.stderr.join('\n')).toContain('f.notion is aliased or passed on in this body'); + const direct = await check(fixture('await f.notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); + expect(direct.exit).toBe(0); + const real = await check(fixture('await f.notion.appendBlock("page", {});', '{ tools: { notion: true } },')); + expect(real.stderr.join('\n')).not.toContain('aliased or passed on'); +}); From e83d2e7e4c3c6facb1402e5e801040996e274433 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 00:52:33 -0700 Subject: [PATCH 11/43] fix(cloud): read integration requirements for every authored flow extension flows check now downgrades a missing helper mount for any authored flow, on the promise that Cloud submission verifies the integration. flowRequirementsForPath only loaded .flow.ts, so .flow.mjs/.mts/.js submissions skipped ensureIntegrationsConnected. It now uses the shared isAuthoredFlowPath predicate that check uses. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli/cloud-connect-cli.ts | 5 ++++- packages/sdk/tests/check-helper-surface.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/cli/cloud-connect-cli.ts b/packages/sdk/src/cli/cloud-connect-cli.ts index 03d18083f..03019bde9 100644 --- a/packages/sdk/src/cli/cloud-connect-cli.ts +++ b/packages/sdk/src/cli/cloud-connect-cli.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { createInterface } from 'node:readline'; import { parse as parseYaml } from 'yaml'; import { loadAuthoredFlow } from '../authored-flow-loader.js'; +import { isAuthoredFlowPath } from '../direct-input.js'; import { ensureIntegrationsConnected, type ConnectPrompt, type ConnectionsOutcome } from '../cloud-connect.js'; import { CloudFlowError, cloudRequest, isCloudRecord, type CloudConnectionOptions } from '../cloud-http.js'; import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js'; @@ -35,7 +36,9 @@ export async function flowRequirementsForPath( const projectCli = context.projectCli ?? projectCliFor(path); const withCli = { ...context, ...(projectCli === undefined ? {} : { projectCli }) }; try { - if (/\.flow\.ts$/iu.test(path)) { + // Every authored extension flows check accepts; skipping one would let a + // Cloud submission go out without its integration check. + if (isAuthoredFlowPath(path)) { const loaded = await loadAuthoredFlow(path); return flowRequirements(loaded.getDefinition(loaded.handle), withCli); } diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index df4f94b19..f800901f3 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -327,3 +327,10 @@ it('explains an alias-inferred appendBlock refusal and how to avoid it', async ( const real = await check(fixture('await f.notion.appendBlock("page", {});', '{ tools: { notion: true } },')); expect(real.stderr.join('\n')).not.toContain('aliased or passed on'); }); +it.each(['test.flow.mjs', 'test.flow.js'])('Cloud requirement reading covers %s, so submit can verify its integrations', async name => { + const { flowRequirementsForPath } = await import('../src/cli/cloud-connect-cli.js'); + const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); + writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); + const requirements = await flowRequirementsForPath(path); + expect(requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); +}); From 692f5a4ba7dfee6daf46bc1bdc872766f7769ab6 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:02:34 -0700 Subject: [PATCH 12/43] fix(check): computed Notion members cannot rule out appendBlock f.notion[expr] could be any method, and f[expr] any namespace; neither was recorded, so a computed appendBlock call passed check while execution always rejects it. A computed method is now recorded as notion.* and refuses like appendBlock; a computed namespace escapes the whole context. The refusal text names computed calls among the reasons it cannot rule the call out. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 6 +++--- packages/sdk/src/helper-reference.ts | 9 ++++++--- packages/sdk/tests/check-helper-surface.test.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index 5293cf13d..c1e319f59 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -27,7 +27,7 @@ export function preflightHelpers( const notionAppendInferred = uses !== undefined && !uses.methods.has('notion.appendBlock'); const appendsNotionBlock = uses === undefined ? /\.\s*appendBlock\b/.test(body) - : uses.methods.has('notion.appendBlock') + : uses.methods.has('notion.appendBlock') || uses.methods.has('notion.*') || ((uses.escaped.has('notion') || uses.escaped.has('*')) && uses.members.has('appendBlock')); for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true @@ -48,8 +48,8 @@ export function preflightHelpers( if (supported && provider === 'notion' && !fact.mock && appendsNotionBlock) { diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' - + (notionAppendInferred ? ' f.notion is aliased or passed on in this body, so any .appendBlock access is treated as this operation;' - + ' call f.notion methods directly (f.notion.createPage(...)) to let flows check tell them apart.' : '') }); + + (notionAppendInferred ? ' f.notion is aliased, passed on or called through a computed name in this body, so the call cannot be ruled out;' + + ' call f.notion methods directly by name (f.notion.createPage(...)) to let flows check tell them apart.' : '') }); } } return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics }; diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 24b5d702f..c062ede41 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -47,7 +47,8 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet { const aliased = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); expect(aliased.exit).toBe(2); - expect(aliased.stderr.join('\n')).toContain('f.notion is aliased or passed on in this body'); + expect(aliased.stderr.join('\n')).toContain('f.notion is aliased, passed on or called through a computed name'); const direct = await check(fixture('await f.notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); expect(direct.exit).toBe(0); const real = await check(fixture('await f.notion.appendBlock("page", {});', '{ tools: { notion: true } },')); - expect(real.stderr.join('\n')).not.toContain('aliased or passed on'); + expect(real.stderr.join('\n')).not.toContain('aliased, passed on'); }); it.each(['test.flow.mjs', 'test.flow.js'])('Cloud requirement reading covers %s, so submit can verify its integrations', async name => { const { flowRequirementsForPath } = await import('../src/cli/cloud-connect-cli.js'); @@ -334,3 +334,11 @@ it.each(['test.flow.mjs', 'test.flow.js'])('Cloud requirement reading covers %s, const requirements = await flowRequirementsForPath(path); expect(requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); }); +it.each([ + ['a computed method', 'const method = "appendBlock"; await f.notion[method]("page", {});'], + ['a computed namespace', 'const ns = "notion"; await f[ns].appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From dad1e15fe147effe2a58dacd3a232c4a56ed9dc2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:12:33 -0700 Subject: [PATCH 13/43] fix(check): computed member access cannot rule out an aliased appendBlock `notion[method]` on an alias of f.notion recorded no member, so check passed a call execution always rejects. Every computed, non-literal member access is now recorded as an unknown member (`*`), which keeps the refusal when f.notion escapes; direct, named use stays precise. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 2 +- packages/sdk/src/helper-reference.ts | 8 +++++--- packages/sdk/tests/check-helper-surface.test.ts | 5 +++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index c1e319f59..34af9f300 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -28,7 +28,7 @@ export function preflightHelpers( const appendsNotionBlock = uses === undefined ? /\.\s*appendBlock\b/.test(body) : uses.methods.has('notion.appendBlock') || uses.methods.has('notion.*') - || ((uses.escaped.has('notion') || uses.escaped.has('*')) && uses.members.has('appendBlock')); + || ((uses.escaped.has('notion') || uses.escaped.has('*')) && (uses.members.has('appendBlock') || uses.members.has('*'))); for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index c062ede41..badc36d2a 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -49,8 +49,9 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet; escaped: ReadonlySet; members: ReadonlySet; @@ -134,7 +135,8 @@ export function helperMemberUses(body: string, root: string): { } if (node.type !== 'MemberExpression') return; const name = memberName(node); - if (name !== undefined) members.add(name); + // `x[expr]` could name any member, appendBlock included: record it as unknown. + members.add(name ?? '*'); const object = node.object as AstNode | undefined; if (object?.type !== 'MemberExpression') return; const context = object.object as AstNode | undefined; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index a87cd7ec0..4e9c6596a 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -342,3 +342,8 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it('keeps refusing a computed call through an aliased f.notion', async () => { + const result = await check(fixture('const notion = f.notion; const method = "appendBlock"; await notion[method]("page", {});', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 02e0371adc9da65060e6de484bdb86eb9597f18f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:22:23 -0700 Subject: [PATCH 14/43] fix(check): computed destructuring keys and rest elements are unknown members `const { [method]: append } = f.notion` and `const { ...rest } = f.notion` can take appendBlock without naming it; both now record an unknown member (`*`), which keeps the refusal for an escaped f.notion. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 6 +++--- packages/sdk/tests/check-helper-surface.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index badc36d2a..afd48aee7 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -93,9 +93,9 @@ export function helperMemberUses(body: string, root: string): { } if (pattern.type !== 'ObjectPattern') return; for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { - if (property.type !== 'Property') continue; - const name = keyName(property); - if (name !== undefined) members.add(name); + // `...rest` and `[expr]: x` can take any member, appendBlock included. + if (property.type !== 'Property') { members.add('*'); continue; } + members.add(keyName(property) ?? '*'); patternKeys(property.value as AstNode); } }; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 4e9c6596a..a7a12f9b3 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -347,3 +347,11 @@ it('keeps refusing a computed call through an aliased f.notion', async () => { expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['a computed destructuring key', 'const method = "appendBlock"; const { [method]: append } = f.notion; await append("page", {});'], + ['a rest element', 'const { createPage, ...rest } = f.notion; await rest.appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 47f674f9e069e41929f57682959022f438eaa603 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:32:51 -0700 Subject: [PATCH 15/43] fix(cloud): never prompt to connect integrations for an authored flow Cloud will refuse Cloud submission accepts only .flow.ts authored sources; .flow.mjs/.mts/.js are refused as unsupported_source. ensureFlowConnections now returns before reading requirements, prompting or contacting Cloud for those, so the command reaches its refusal without connecting anything first. Requirement reading stays broad for deploy's harness remedy. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli/cloud-connect-cli.ts | 6 +++++- packages/sdk/tests/check-helper-surface.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/cli/cloud-connect-cli.ts b/packages/sdk/src/cli/cloud-connect-cli.ts index 03019bde9..818d2f64a 100644 --- a/packages/sdk/src/cli/cloud-connect-cli.ts +++ b/packages/sdk/src/cli/cloud-connect-cli.ts @@ -80,11 +80,15 @@ export async function currentWorkspaceId(options: CloudConnectionOptions): Promi * contacted when the flow requires no integration, so a flow with no * helpers, sources or repository submits exactly as before; the derived * requirements still come back so a later harness refusal can name its remedy. - * `undefined` only when the source does not load — the submission says why. + * `undefined` when the source does not load or is an authored extension Cloud + * does not submit — the submission says why. */ export async function ensureFlowConnections( input: FlowConnectionsInput, options: CloudConnectionOptions = {}, ): Promise<{ requirements: FlowRequirements; outcome: ConnectionsOutcome } | undefined> { + // Cloud submission accepts only .flow.ts among authored sources (cloud-run.ts + // prepareCloudSubmission); never prompt or connect for one it will refuse. + if (isAuthoredFlowPath(input.path) && !/\.flow\.ts$/iu.test(input.path)) return undefined; const requirements = await flowRequirementsForPath(input.path, input); if (requirements === undefined) return undefined; if (requirements.integrations.length === 0) return { requirements, outcome: { ready: [], connected: [] } }; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index a7a12f9b3..74988b8f4 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -355,3 +355,13 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each(['test.flow.mjs', 'test.flow.js'])('never prompts to connect integrations for %s, which Cloud submission refuses', async name => { + const { ensureFlowConnections } = await import('../src/cli/cloud-connect-cli.js'); + const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); + writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no network in this test')); + const prompt = vi.fn(async () => true); + expect(await ensureFlowConnections({ path, prompt } as never, { token: 'test-token' })).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); From e97af0b7d49ca6f815b4c639fb996a8437f21ff0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 01:42:50 -0700 Subject: [PATCH 16/43] fix(check): an escaped f.notion refuses whenever the body names appendBlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect.get(f.notion, 'appendBlock') and n['appendBlock'] name the operation only as a string. Once f.notion escapes, any appendBlock the body names — as a member, a computed access, or text — keeps the refusal. Only a name assembled at runtime remains unprovable statically; execution still refuses that call. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-preflight.ts | 6 +++++- packages/sdk/tests/check-helper-surface.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index 34af9f300..ca95f7cc3 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -28,7 +28,11 @@ export function preflightHelpers( const appendsNotionBlock = uses === undefined ? /\.\s*appendBlock\b/.test(body) : uses.methods.has('notion.appendBlock') || uses.methods.has('notion.*') - || ((uses.escaped.has('notion') || uses.escaped.has('*')) && (uses.members.has('appendBlock') || uses.members.has('*'))); + // Once f.notion escapes, any appendBlock the body names — a member, a + // computed access, or the string itself (Reflect.get, obj['appendBlock']) + // — may reach it. A name assembled at runtime still refuses at execution. + || ((uses.escaped.has('notion') || uses.escaped.has('*')) + && (uses.members.has('appendBlock') || uses.members.has('*') || /appendBlock/.test(body))); for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 74988b8f4..b6a78be35 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -365,3 +365,11 @@ it.each(['test.flow.mjs', 'test.flow.js'])('never prompts to connect integration expect(prompt).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); +it.each([ + ['Reflect.get', 'await Reflect.get(f.notion, "appendBlock")("page", {});'], + ['a string-keyed alias', 'const n = f.notion; await n["appendBlock"]("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 4bce310adb4f2bd3b9b8af41ee59123753288213 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 02:22:21 -0700 Subject: [PATCH 17/43] fix(check): f.notion handed into a call may have any member read from it Reflect.get(f.notion, key) with a dynamic key, or any function receiving f.notion, can reach appendBlock through code the scan cannot see. Passing the helper (or the context) as a call or new argument now records an unknown member, which keeps the refusal for the escaped namespace. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 9 +++++++++ packages/sdk/tests/check-helper-surface.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index afd48aee7..f0256c07e 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -113,6 +113,13 @@ export function helperMemberUses(body: string, root: string): { default: return false; } }; + // Passed into a call (`Reflect.get(f.notion, key)`, `use(f.notion)`), the + // helper meets code this scan cannot see: any member may be read from it. + const handedToCall = (value: AstNode): void => { + const parent = parents.get(value); + if ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') + && ((parent.arguments as AstNode[] | undefined) ?? []).includes(value)) members.add('*'); + }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } if (node.type === 'Identifier' && node.name === root) { @@ -128,9 +135,11 @@ export function helperMemberUses(body: string, root: string): { const method = grand?.type === 'MemberExpression' && grand.object === parent; // `f[expr]` names no knowable namespace: whatever it reaches escapes. if (namespace === undefined || (!method && !readOnly(parent))) escaped.add(namespace ?? '*'); + if (!method) handedToCall(parent); return; } if (!readOnly(node)) escaped.add('*'); + handedToCall(node); return; } if (node.type !== 'MemberExpression') return; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index b6a78be35..a69ebcae9 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -373,3 +373,11 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['a dynamic Reflect.get key', 'const method = String(Date.now()); await Reflect.get(f.notion, method)("page", {});'], + ['a helper function', 'const call = (n, m) => n[m]; await call(f.notion, "append" + "Block")("page", {});'], +])('keeps refusing when f.notion is handed to %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From a86730bdef5c18f3d2b3b36ce3c6cf3eed9f6c48 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 02:33:48 -0700 Subject: [PATCH 18/43] fix(check): see a helper hand-off through optional chains and value wrappers handedToCall checked only the immediate parent, so Reflect.get(f?.notion, k), f.notion ?? {}, or a spread argument hid the call. It now climbs through wrappers that pass the value on unchanged (optional chain, parentheses, await, ?: branches, ||/??, sequence, spread/array) before testing for a call argument. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 13 +++++++++++-- packages/sdk/tests/check-helper-surface.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index f0256c07e..bae1ee00a 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -115,10 +115,19 @@ export function helperMemberUses(body: string, root: string): { }; // Passed into a call (`Reflect.get(f.notion, key)`, `use(f.notion)`), the // helper meets code this scan cannot see: any member may be read from it. + // Wrappers that pass their operand's value through unchanged. + const transparent = new Set(['ChainExpression', 'ParenthesizedExpression', 'AwaitExpression', + 'ConditionalExpression', 'LogicalExpression', 'SequenceExpression', 'SpreadElement', 'ArrayExpression']); const handedToCall = (value: AstNode): void => { - const parent = parents.get(value); + let current = value; + let parent = parents.get(current); + while (parent !== undefined && transparent.has(parent.type) + && !(parent.type === 'ConditionalExpression' && parent.test === current)) { + current = parent; + parent = parents.get(current); + } if ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') - && ((parent.arguments as AstNode[] | undefined) ?? []).includes(value)) members.add('*'); + && ((parent.arguments as AstNode[] | undefined) ?? []).includes(current)) members.add('*'); }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index a69ebcae9..2e410b8c9 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -381,3 +381,12 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['an optional chain', 'const method = String(Date.now()); await Reflect.get(f?.notion, method)("page", {});'], + ['a logical fallback', 'const method = String(Date.now()); await Reflect.get(f.notion ?? {}, method)("page", {});'], + ['a spread argument', 'const method = String(Date.now()); await Reflect.get(...[f.notion, method])("page", {});'], +])('keeps refusing when f.notion reaches a call through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 7f27b2c5c29e4548a25502cc5527844c8ac8fbd2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 02:44:33 -0700 Subject: [PATCH 19/43] fix(check): any escape but a plain binding hands f.notion to unseen code Enumerating hand-off positions (call arguments, wrappers) kept missing some: an object or property holding f.notion, a return, a property assignment. The rule is now inverted: an escaping helper keeps its read members tracked only when it lands in a plain binding (declaration, assignment to an identifier or pattern) or is called directly; every other landing records an unknown member, which keeps the appendBlock refusal. A plain alias using only named supported methods still passes. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 24 ++++++++++++------- .../sdk/tests/check-helper-surface.test.ts | 14 +++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index bae1ee00a..d84d68da3 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -113,12 +113,15 @@ export function helperMemberUses(body: string, root: string): { default: return false; } }; - // Passed into a call (`Reflect.get(f.notion, key)`, `use(f.notion)`), the - // helper meets code this scan cannot see: any member may be read from it. // Wrappers that pass their operand's value through unchanged. const transparent = new Set(['ChainExpression', 'ParenthesizedExpression', 'AwaitExpression', - 'ConditionalExpression', 'LogicalExpression', 'SequenceExpression', 'SpreadElement', 'ArrayExpression']); - const handedToCall = (value: AstNode): void => { + 'ConditionalExpression', 'LogicalExpression', 'SequenceExpression']); + // Where an escaping helper lands. Only a plain binding — `const n = f.notion`, + // `n = f.notion`, or destructuring — keeps it inside this body, where the + // names read from it are tracked. Anywhere else (a call or new argument, an + // object or array, a property, a return, a spread) hands it to code this scan + // cannot see, so any member may be read from it: record an unknown one. + const handedOn = (value: AstNode): void => { let current = value; let parent = parents.get(current); while (parent !== undefined && transparent.has(parent.type) @@ -126,8 +129,12 @@ export function helperMemberUses(body: string, root: string): { current = parent; parent = parents.get(current); } - if ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') - && ((parent.arguments as AstNode[] | undefined) ?? []).includes(current)) members.add('*'); + // Calling it (`f.done(...)`) uses the value here; it is not handed on. + const bound = ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === current) + || (parent?.type === 'VariableDeclarator' && parent.init === current) + || (parent?.type === 'AssignmentExpression' && parent.right === current + && ['Identifier', 'ObjectPattern', 'ArrayPattern'].includes((parent.left as AstNode).type)); + if (!bound) members.add('*'); }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } @@ -144,11 +151,10 @@ export function helperMemberUses(body: string, root: string): { const method = grand?.type === 'MemberExpression' && grand.object === parent; // `f[expr]` names no knowable namespace: whatever it reaches escapes. if (namespace === undefined || (!method && !readOnly(parent))) escaped.add(namespace ?? '*'); - if (!method) handedToCall(parent); + if (!method && !readOnly(parent)) handedOn(parent); return; } - if (!readOnly(node)) escaped.add('*'); - handedToCall(node); + if (!readOnly(node)) { escaped.add('*'); handedOn(node); } return; } if (node.type !== 'MemberExpression') return; diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 2e410b8c9..576249b66 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -390,3 +390,17 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['an object passed to a call', 'await globalThis.invokeHelper({ notion: f.notion }, "page");'], + ['a returned helper', 'const get = () => { return f.notion; }; await globalThis.invokeHelper(get, "page");'], + ['a property assignment', 'const box = {}; box.n = f.notion; await globalThis.invokeHelper(box, "page");'], +])('keeps refusing when f.notion escapes through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('a plain alias with only named, supported methods still passes', async () => { + const result = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); From 1ea329fbe79ca3b88f0641e3490e942e14dd8574 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 02:54:23 -0700 Subject: [PATCH 20/43] fix(check): follow helper aliases; discarded guards are not hand-offs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Names bound to a helper namespace (`const n = f.notion`, `n = f.notion`, `const { notion } = f`) are now aliases whose later uses are judged like `f.notion` itself, following rebinds to a fixpoint: an alias handed to a call, object, return or spread records an unknown member. A plain alias of the whole context is treated as unknown. - A value that is discarded (`f.notion && …` as a statement), negated, compared or used as a condition is a read, not a hand-off, so guards around supported calls no longer refuse. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-reference.ts | 79 +++++++++++++++++-- .../sdk/tests/check-helper-surface.test.ts | 17 ++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index d84d68da3..b06db0d43 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -121,7 +121,31 @@ export function helperMemberUses(body: string, root: string): { // names read from it are tracked. Anywhere else (a call or new argument, an // object or array, a property, a return, a spread) hands it to code this scan // cannot see, so any member may be read from it: record an unknown one. - const handedOn = (value: AstNode): void => { + // Names that hold a helper namespace (`const n = f.notion`, `const { notion } = f`). + // Their later uses are judged exactly like `f.notion` itself. + const aliases = new Set(); + const bindingNames = (pattern: AstNode | undefined, into: Set): void => { + if (pattern === undefined) return; + if (pattern.type === 'Identifier') { into.add(pattern.name!); return; } + if (pattern.type === 'AssignmentPattern') return bindingNames(pattern.left as AstNode, into); + if (pattern.type === 'RestElement') return bindingNames(pattern.argument as AstNode, into); + if (pattern.type === 'ArrayPattern') { + for (const element of (pattern.elements as Array | undefined) ?? []) bindingNames(element ?? undefined, into); + return; + } + if (pattern.type === 'ObjectPattern') { + for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { + bindingNames((property.type === 'Property' ? property.value : property.argument) as AstNode, into); + } + } + }; + // Where a helper value lands. A plain binding keeps it inside this body as an + // alias whose uses are tracked; a discarded value or a condition reads it; a + // direct call uses it. Anywhere else (a call or new argument, an object or + // array, a property, a return, a spread) hands it to code this scan cannot + // see, so any member may be read from it: record an unknown one. `context` + // marks the whole `f`, whose plain alias cannot be followed member by member. + const handedOn = (value: AstNode, context = false): void => { let current = value; let parent = parents.get(current); while (parent !== undefined && transparent.has(parent.type) @@ -129,12 +153,18 @@ export function helperMemberUses(body: string, root: string): { current = parent; parent = parents.get(current); } - // Calling it (`f.done(...)`) uses the value here; it is not handed on. - const bound = ((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === current) - || (parent?.type === 'VariableDeclarator' && parent.init === current) - || (parent?.type === 'AssignmentExpression' && parent.right === current - && ['Identifier', 'ObjectPattern', 'ArrayPattern'].includes((parent.left as AstNode).type)); - if (!bound) members.add('*'); + if (parent === undefined) { members.add('*'); return; } + const discarded = parent.type === 'ExpressionStatement' || parent.type === 'UnaryExpression' + || parent.type === 'BinaryExpression' + || (['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ConditionalExpression'].includes(parent.type) + && parent.test === current); + const called = (parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current; + if (discarded || called) return; + const target = parent.type === 'VariableDeclarator' && parent.init === current ? parent.id as AstNode + : parent.type === 'AssignmentExpression' && parent.right === current ? parent.left as AstNode : undefined; + if (target === undefined || !['Identifier', 'ObjectPattern', 'ArrayPattern'].includes(target.type) + || (context && target.type !== 'ObjectPattern')) { members.add('*'); return; } + bindingNames(target, aliases); }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } @@ -154,7 +184,7 @@ export function helperMemberUses(body: string, root: string): { if (!method && !readOnly(parent)) handedOn(parent); return; } - if (!readOnly(node)) { escaped.add('*'); handedOn(node); } + if (!readOnly(node)) { escaped.add('*'); handedOn(node, true); } return; } if (node.type !== 'MemberExpression') return; @@ -169,6 +199,39 @@ export function helperMemberUses(body: string, root: string): { // `f.notion[expr]` could be any method; record it as unknown (`notion.*`). if (namespace !== undefined) methods.add(`${namespace}.${name ?? '*'}`); }); + // Follow aliases until no new one appears: each use of an alias name that is + // neither a member access nor a read is judged as a landing of the helper. + const judged = new Set(); + const identifiers: AstNode[] = []; + const collect = (node: AstNode): void => { + if (node.type === 'Identifier') identifiers.push(node); + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = node[key]; + for (const entry of Array.isArray(child) ? child : [child]) if (isNode(entry)) collect(entry); + } + }; + collect(program); + const declaresName = (node: AstNode): boolean => { + const parent = parents.get(node); + if (parent === undefined) return false; + if (parent.type === 'VariableDeclarator') return parent.id === node; + if (parent.type === 'Property') return parent.key === node && parent.computed !== true && parent.shorthand !== true + || (parent.value === node && parents.get(parent)?.type === 'ObjectPattern'); + if (parent.type === 'MemberExpression') return parent.property === node && parent.computed !== true; + if (parent.type === 'AssignmentPattern' || parent.type === 'ArrayPattern' || parent.type === 'RestElement') return true; + if (parent.type === 'AssignmentExpression') return parent.left === node; + return isFunction(parent) && ((parent.params as AstNode[] | undefined) ?? []).includes(node); + }; + for (let fresh = [...aliases]; fresh.length > 0; fresh = [...aliases].filter(name => !judged.has(name))) { + for (const name of fresh) judged.add(name); + for (const node of identifiers) { + if (!fresh.includes(node.name!) || declaresName(node)) continue; + const parent = parents.get(node); + if (parent?.type === 'MemberExpression' && parent.object === node) continue; // its member name is tracked + if (!readOnly(node)) handedOn(node); + } + } return { methods, escaped, members }; } diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 576249b66..84bc83283 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -404,3 +404,20 @@ it('a plain alias with only named, supported methods still passes', async () => expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); expect(result.exit).toBe(0); }); +it.each([ + ['an alias handed to a call', 'const notion = f.notion; await globalThis.invokeHelper(notion, "page");'], + ['a rebound alias handed on', 'const a = f.notion; const b = a; await globalThis.invokeHelper({ b }, "page");'], + ['a destructured namespace handed on', 'const { notion } = f; await globalThis.invokeHelper(notion, "page");'], +])('keeps refusing when %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['a short-circuit guard', 'f.notion && await f.notion.createPage({ parent: "p", title: "t" });'], + ['an alias guard', 'const n = f.notion; if (n) await n.createPage({ parent: "p", title: "t" }); n && await n.createPage({ parent: "p", title: "u" });'], +])('does not over-refuse %s that only calls supported methods', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); From cc58961ba72e0a116222e26e65435057f55fbba0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:05:47 -0700 Subject: [PATCH 21/43] refactor(check): attribute appendBlock only for direct f.notion use, in its own module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking every way f.notion can be aliased kept missing shapes (captured aliases under a shadowed context, defaults, assignments as arguments) and grew helper-reference.ts to 459 lines. helper-operation-use.ts now decides with a rule that is closed by construction: the call is attributed precisely only while f.notion is used directly (named method calls) or read (conditions, typeof, comparisons, discarded guards); any other use — alias, destructuring, argument, return, property, computed member or namespace — makes the call unprovable and check refuses with the remedy. helper-reference.ts is back to its main size, only exporting its parse and scope primitives. Also: an activity refusal now keeps the helper diagnostics and integration requirements instead of discarding them. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli.ts | 10 +- packages/sdk/src/helper-operation-use.ts | 82 +++++++ packages/sdk/src/helper-preflight.ts | 28 +-- packages/sdk/src/helper-reference.ts | 216 +----------------- .../sdk/tests/check-helper-surface.test.ts | 25 +- 5 files changed, 129 insertions(+), 232 deletions(-) create mode 100644 packages/sdk/src/helper-operation-use.ts diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index d88d6ab6e..470628a39 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -452,7 +452,15 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR // unrelated CLI commands should not pay that startup cost on every run. const { checkAuthoredActivities } = await import('./cli/check-activities.js'); const activities = await checkAuthoredActivities(path); - if (!activities.report.ok) return activities; + if (!activities.report.ok) { + // Keep what is already provable about the flow: its helper warnings and + // integration requirements, so one check reports both problems. + const requirements = isAuthoredFlowPath(path) + ? (await checkAuthoredTriggers(path, invocation)).report.requirements : undefined; + return { report: { ...activities.report, + diagnostics: [...helper.report.diagnostics, ...activities.report.diagnostics], + ...(requirements === undefined ? {} : { requirements }) } }; + } const mcp = await checkTypeScriptFlow(path); const triggers = isAuthoredFlowPath(path) ? await checkAuthoredTriggers(path, invocation) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts new file mode 100644 index 000000000..f5c225d89 --- /dev/null +++ b/packages/sdk/src/helper-operation-use.ts @@ -0,0 +1,82 @@ +import { isFunction, isNode, memberName, parseFlowBody, walkReferences, type AstNode } from './helper-reference.js'; + +/** + * Whether a flow body calls `f..`. + * + * - `called`: a direct, named call such as `f.notion.appendBlock(...)`. + * - `absent`: the namespace is only ever used directly — `f.notion.x(...)` with + * named methods — or read (`if (f.notion)`, `typeof`, comparisons, a discarded + * `f.notion && ...`), so the call provably does not occur. + * - `unprovable`: the namespace or the whole context leaves direct use — an + * alias, destructuring, an argument, a return, a property, a computed member + * or namespace. Its later calls cannot be attributed statically, so callers + * that must fail closed treat this as the call. + * + * Same scoped parse as `helperNamespacesUsed`: strings and comments naming the + * call are not uses, and a nested binding of the context name is not the context. + * An unparseable body falls back to the text, permissively. + */ +export function helperOperationUse(body: string, root: string, namespace: string, method: string): 'called' | 'absent' | 'unprovable' { + const program = parseFlowBody(body); + if (program === null) return new RegExp(`\\.\\s*${method}\\b`).test(body) ? 'unprovable' : 'absent'; + const parents = new WeakMap(); + const link = (node: AstNode): void => { + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = node[key]; + for (const entry of Array.isArray(child) ? child : [child]) { + if (isNode(entry)) { parents.set(entry, node); link(entry); } + } + } + }; + link(program); + let result: 'called' | 'absent' | 'unprovable' = 'absent'; + const mark = (found: 'called' | 'unprovable') => { if (result !== 'unprovable') result = found; }; + walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { + if (node.type !== 'Identifier' || node.name !== root) return; + const parent = parents.get(node); + if (parent === undefined || declares(parent, node)) return; + if (parent.type === 'MemberExpression' && parent.object === node) { + const name = memberName(parent); + if (name === undefined) return mark('unprovable'); // f[expr] + if (name !== namespace) return; + const grand = parents.get(parent); + if (grand?.type === 'MemberExpression' && grand.object === parent) { + const operation = memberName(grand); + if (operation === undefined) mark('unprovable'); // f.notion[expr] + else if (operation === method) mark('called'); + return; + } + if (!readOrCall(parent, parents)) mark('unprovable'); + return; + } + // The whole context used as a value (`const { notion } = f`, `use(f)`). + if (!readOrCall(node, parents)) mark('unprovable'); + }); + return result; +} + +/** A use that cannot hand the value on: a direct call, a discarded value, or a read. */ +function readOrCall(value: AstNode, parents: WeakMap): boolean { + let current = value; + let parent = parents.get(current); + // Wrappers that pass their operand's value through unchanged. + while (parent !== undefined && ['ChainExpression', 'ParenthesizedExpression', 'AwaitExpression', + 'LogicalExpression', 'ConditionalExpression', 'SequenceExpression'].includes(parent.type) + && !(parent.type === 'ConditionalExpression' && parent.test === current)) { + current = parent; + parent = parents.get(current); + } + if (parent === undefined) return false; + if ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current) return true; + if (['ExpressionStatement', 'UnaryExpression', 'BinaryExpression'].includes(parent.type)) return true; + return ['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ConditionalExpression'].includes(parent.type) + && parent.test === current; +} + +/** The identifier is a name being declared or a property name, not a use of the context. */ +function declares(parent: AstNode, node: AstNode): boolean { + if (isFunction(parent)) return ((parent.params as AstNode[] | undefined) ?? []).includes(node); + if (parent.type === 'Property') return parent.key === node && parent.computed !== true && parent.shorthand !== true; + return parent.type === 'MemberExpression' && parent.property === node && parent.computed !== true; +} diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index ca95f7cc3..07088f499 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -1,6 +1,7 @@ import { helperProviders } from '@relayflows/surface/runtime'; import type { PreflightResult, PreflightDiagnostic } from './preflight.js'; -import { helperMemberUses, helperNamespacesUsed } from './helper-reference.js'; +import { helperNamespacesUsed } from './helper-reference.js'; +import { helperOperationUse } from './helper-operation-use.js'; /** Static discovery never executes the body; dynamic aliases are checked at call time. */ export function preflightHelpers( @@ -18,21 +19,14 @@ export function preflightHelpers( // comment, template quasi or regex is not used, and refusing on one demands // a mount the flow never touches. const referenced = root === undefined ? new Set() : helperNamespacesUsed(body, root); - // An unresolvable context name leaves only the text; keep refusing permissively - // there. An aliased or destructured f.notion cannot be followed statically, so - // any appendBlock member access then counts as the unsupported operation. - const uses = root === undefined ? undefined : helperMemberUses(body, root); - // Attributed only when f.notion is called directly; an escaped f.notion cannot be - // told apart from another object's appendBlock, so check refuses and says why. - const notionAppendInferred = uses !== undefined && !uses.methods.has('notion.appendBlock'); - const appendsNotionBlock = uses === undefined - ? /\.\s*appendBlock\b/.test(body) - : uses.methods.has('notion.appendBlock') || uses.methods.has('notion.*') - // Once f.notion escapes, any appendBlock the body names — a member, a - // computed access, or the string itself (Reflect.get, obj['appendBlock']) - // — may reach it. A name assembled at runtime still refuses at execution. - || ((uses.escaped.has('notion') || uses.escaped.has('*')) - && (uses.members.has('appendBlock') || uses.members.has('*') || /appendBlock/.test(body))); + // f.notion.appendBlock has no writeback route. It is attributed precisely only + // while f.notion is used directly; once it is aliased, passed on or reached by a + // computed name, the call cannot be ruled out and check refuses, saying why. + const notionAppend = root === undefined + ? (/\.\s*appendBlock\b/.test(body) ? 'unprovable' : 'absent') + : helperOperationUse(body, root, 'notion', 'appendBlock'); + const appendsNotionBlock = notionAppend !== 'absent'; + const notionAppendInferred = notionAppend === 'unprovable'; for (const { provider, namespace, supported } of helperProviders) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); @@ -53,7 +47,7 @@ export function preflightHelpers( diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' + (notionAppendInferred ? ' f.notion is aliased, passed on or called through a computed name in this body, so the call cannot be ruled out;' - + ' call f.notion methods directly by name (f.notion.createPage(...)) to let flows check tell them apart.' : '') }); + + ' call f.notion methods directly by name (f.notion.createPage(...)) so flows check can see which ones run.' : '') }); } } return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics }; diff --git a/packages/sdk/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index b06db0d43..5436669ad 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -38,212 +38,8 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet.` helper operations a flow body references, as - * `namespace.method`. Same parse and scoping as `helperNamespacesUsed`, so an - * unrelated object's method, a string or a comment naming one is not a use. - * - * A namespace used anywhere but a method access or a read-only check - * (`if (f.notion)`, `typeof f.notion`) is reported in `escaped` — aliases, - * destructuring, arguments, returns, arrow bodies and defaults alike — and a - * bare `f` in such a position escapes the whole context (`*`). This is an - * allowlist, so a new aliasing shape escapes by default. A computed method, - * `f.notion[expr]`, is recorded as `notion.*`; a computed namespace escapes `*`. - * `members` lists every member name the body accesses or destructures (`*` - * for a computed access), for callers that must stay conservative about an - * escaped namespace. - */ -export function helperMemberUses(body: string, root: string): { - methods: ReadonlySet; escaped: ReadonlySet; members: ReadonlySet; -} { - const program = parseFlowBody(body); - const methods = new Set(); - const escaped = new Set(); - const members = new Set(); - if (program === null) { - // Permissive, like textFallback: an unparseable body over-reports. - const escapedRoot = root.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); - const pattern = new RegExp(`(?:^|[^\\w$.])${escapedRoot}\\s*\\.\\s*([\\w$]+)\\s*\\.\\s*([\\w$]+)`, 'gu'); - for (const match of body.matchAll(pattern)) methods.add(`${match[1]}.${match[2]}`); - escaped.add('*'); - for (const match of body.matchAll(/\.\s*([\w$]+)/gu)) members.add(match[1]!); - return { methods, escaped, members }; - } - // Parent links for the whole body, so each visible use of the context can be - // judged by where it sits. - const parents = new WeakMap(); - const link = (node: AstNode): void => { - for (const key of Object.keys(node)) { - if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; - const child = node[key]; - for (const entry of Array.isArray(child) ? child : [child]) { - if (isNode(entry)) { parents.set(entry, node); link(entry); } - } - } - }; - link(program); - // Every destructured key, at any depth: `const { notion: { appendBlock } } = f` - // reads appendBlock as surely as a member access does. - const patternKeys = (pattern: AstNode | undefined): void => { - if (pattern === undefined) return; - if (pattern.type === 'AssignmentPattern') return patternKeys(pattern.left as AstNode); - if (pattern.type === 'ArrayPattern') { - for (const element of (pattern.elements as Array | undefined) ?? []) patternKeys(element ?? undefined); - return; - } - if (pattern.type !== 'ObjectPattern') return; - for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { - // `...rest` and `[expr]: x` can take any member, appendBlock included. - if (property.type !== 'Property') { members.add('*'); continue; } - members.add(keyName(property) ?? '*'); - patternKeys(property.value as AstNode); - } - }; - // A read that cannot hand the helper on: a member access (`f.notion.x`), an - // operand of typeof/!/comparison, or a condition. Anything else may alias it. - const readOnly = (node: AstNode): boolean => { - const parent = parents.get(node); - if (parent === undefined) return false; - switch (parent.type) { - case 'UnaryExpression': case 'BinaryExpression': return true; - case 'IfStatement': case 'WhileStatement': case 'DoWhileStatement': case 'ForStatement': - return parent.test === node; - case 'ConditionalExpression': return parent.test === node || readOnly(parent); - case 'LogicalExpression': case 'ChainExpression': return readOnly(parent); - default: return false; - } - }; - // Wrappers that pass their operand's value through unchanged. - const transparent = new Set(['ChainExpression', 'ParenthesizedExpression', 'AwaitExpression', - 'ConditionalExpression', 'LogicalExpression', 'SequenceExpression']); - // Where an escaping helper lands. Only a plain binding — `const n = f.notion`, - // `n = f.notion`, or destructuring — keeps it inside this body, where the - // names read from it are tracked. Anywhere else (a call or new argument, an - // object or array, a property, a return, a spread) hands it to code this scan - // cannot see, so any member may be read from it: record an unknown one. - // Names that hold a helper namespace (`const n = f.notion`, `const { notion } = f`). - // Their later uses are judged exactly like `f.notion` itself. - const aliases = new Set(); - const bindingNames = (pattern: AstNode | undefined, into: Set): void => { - if (pattern === undefined) return; - if (pattern.type === 'Identifier') { into.add(pattern.name!); return; } - if (pattern.type === 'AssignmentPattern') return bindingNames(pattern.left as AstNode, into); - if (pattern.type === 'RestElement') return bindingNames(pattern.argument as AstNode, into); - if (pattern.type === 'ArrayPattern') { - for (const element of (pattern.elements as Array | undefined) ?? []) bindingNames(element ?? undefined, into); - return; - } - if (pattern.type === 'ObjectPattern') { - for (const property of (pattern.properties as AstNode[] | undefined) ?? []) { - bindingNames((property.type === 'Property' ? property.value : property.argument) as AstNode, into); - } - } - }; - // Where a helper value lands. A plain binding keeps it inside this body as an - // alias whose uses are tracked; a discarded value or a condition reads it; a - // direct call uses it. Anywhere else (a call or new argument, an object or - // array, a property, a return, a spread) hands it to code this scan cannot - // see, so any member may be read from it: record an unknown one. `context` - // marks the whole `f`, whose plain alias cannot be followed member by member. - const handedOn = (value: AstNode, context = false): void => { - let current = value; - let parent = parents.get(current); - while (parent !== undefined && transparent.has(parent.type) - && !(parent.type === 'ConditionalExpression' && parent.test === current)) { - current = parent; - parent = parents.get(current); - } - if (parent === undefined) { members.add('*'); return; } - const discarded = parent.type === 'ExpressionStatement' || parent.type === 'UnaryExpression' - || parent.type === 'BinaryExpression' - || (['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ConditionalExpression'].includes(parent.type) - && parent.test === current); - const called = (parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current; - if (discarded || called) return; - const target = parent.type === 'VariableDeclarator' && parent.init === current ? parent.id as AstNode - : parent.type === 'AssignmentExpression' && parent.right === current ? parent.left as AstNode : undefined; - if (target === undefined || !['Identifier', 'ObjectPattern', 'ArrayPattern'].includes(target.type) - || (context && target.type !== 'ObjectPattern')) { members.add('*'); return; } - bindingNames(target, aliases); - }; - walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { - if (node.type === 'ObjectPattern' || node.type === 'ArrayPattern') { patternKeys(node); return; } - if (node.type === 'Identifier' && node.name === root) { - const parent = parents.get(node); - const declaresIt = parent !== undefined && isFunction(parent) - && ((parent.params as AstNode[] | undefined) ?? []).includes(node); - const isName = parent?.type === 'Property' && parent.key === node && parent.computed !== true && parent.shorthand !== true - || parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true; - if (declaresIt || isName) return; - if (parent?.type === 'MemberExpression' && parent.object === node) { - const namespace = memberName(parent); - const grand = parents.get(parent); - const method = grand?.type === 'MemberExpression' && grand.object === parent; - // `f[expr]` names no knowable namespace: whatever it reaches escapes. - if (namespace === undefined || (!method && !readOnly(parent))) escaped.add(namespace ?? '*'); - if (!method && !readOnly(parent)) handedOn(parent); - return; - } - if (!readOnly(node)) { escaped.add('*'); handedOn(node, true); } - return; - } - if (node.type !== 'MemberExpression') return; - const name = memberName(node); - // `x[expr]` could name any member, appendBlock included: record it as unknown. - members.add(name ?? '*'); - const object = node.object as AstNode | undefined; - if (object?.type !== 'MemberExpression') return; - const context = object.object as AstNode | undefined; - if (context?.type !== 'Identifier' || context.name !== root) return; - const namespace = memberName(object); - // `f.notion[expr]` could be any method; record it as unknown (`notion.*`). - if (namespace !== undefined) methods.add(`${namespace}.${name ?? '*'}`); - }); - // Follow aliases until no new one appears: each use of an alias name that is - // neither a member access nor a read is judged as a landing of the helper. - const judged = new Set(); - const identifiers: AstNode[] = []; - const collect = (node: AstNode): void => { - if (node.type === 'Identifier') identifiers.push(node); - for (const key of Object.keys(node)) { - if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; - const child = node[key]; - for (const entry of Array.isArray(child) ? child : [child]) if (isNode(entry)) collect(entry); - } - }; - collect(program); - const declaresName = (node: AstNode): boolean => { - const parent = parents.get(node); - if (parent === undefined) return false; - if (parent.type === 'VariableDeclarator') return parent.id === node; - if (parent.type === 'Property') return parent.key === node && parent.computed !== true && parent.shorthand !== true - || (parent.value === node && parents.get(parent)?.type === 'ObjectPattern'); - if (parent.type === 'MemberExpression') return parent.property === node && parent.computed !== true; - if (parent.type === 'AssignmentPattern' || parent.type === 'ArrayPattern' || parent.type === 'RestElement') return true; - if (parent.type === 'AssignmentExpression') return parent.left === node; - return isFunction(parent) && ((parent.params as AstNode[] | undefined) ?? []).includes(node); - }; - for (let fresh = [...aliases]; fresh.length > 0; fresh = [...aliases].filter(name => !judged.has(name))) { - for (const name of fresh) judged.add(name); - for (const node of identifiers) { - if (!fresh.includes(node.name!) || declaresName(node)) continue; - const parent = parents.get(node); - if (parent?.type === 'MemberExpression' && parent.object === node) continue; // its member name is tracked - if (!readOnly(node)) handedOn(node); - } - } - return { methods, escaped, members }; -} - -/** A non-computed property key's name, or a string-literal key. */ -function keyName(property: AstNode): string | undefined { - const key = property.key as AstNode | undefined; - if (key?.type === 'Identifier' && property.computed !== true) return key.name; - return key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; -} - /** `f.slack`, `f["slack"]`, `f?.slack` — but not `f[variable]`, which is unknowable. */ -function memberName(node: AstNode): string | undefined { +export function memberName(node: AstNode): string | undefined { const property = node.property as AstNode | undefined; if (property === undefined) return undefined; if (node.computed !== true) return property.type === 'Identifier' ? property.name : undefined; @@ -259,7 +55,7 @@ function memberName(node: AstNode): string | undefined { * here (Node strips before evaluating, and bundlers transpile), so this is * plain JavaScript. */ -function parseFlowBody(body: string): AstNode | null { +export function parseFlowBody(body: string): AstNode | null { const options = { ecmaVersion: 'latest' as const, allowAwaitOutsideFunction: true, allowReturnOutsideFunction: true }; // `parseExpressionAt` stops at the end of the first expression and does not // object to what follows, so `async post(f) { … }` parses as the identifier @@ -307,7 +103,7 @@ function textFallback(body: string, root: string): ReadonlySet { return used; } -type AstNode = { +export type AstNode = { type: string; end?: number; name?: string; @@ -324,7 +120,7 @@ type AstNode = { * would refuse the flow for a mount it never touches. Acorn gives us binding * shapes, so scopes are tracked as syntax instead of guessed from text. */ -function walkReferences( +export function walkReferences( node: AstNode, root: string, shadowed: boolean, @@ -369,7 +165,7 @@ function walkReferences( } } -function isFunction(node: AstNode): boolean { +export function isFunction(node: AstNode): boolean { return node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration'; @@ -454,6 +250,6 @@ function functionVarBinds(node: AstNode, root: string): boolean { return found; } -function isNode(value: unknown): value is AstNode { +export function isNode(value: unknown): value is AstNode { return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string'; } diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 84bc83283..9cdab317c 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -399,10 +399,10 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); -it('a plain alias with only named, supported methods still passes', async () => { +it('an aliased f.notion refuses with the remedy, since its calls cannot be attributed', async () => { const result = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); - expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); - expect(result.exit).toBe(0); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('call f.notion methods directly by name'); }); it.each([ ['an alias handed to a call', 'const notion = f.notion; await globalThis.invokeHelper(notion, "page");'], @@ -415,9 +415,26 @@ it.each([ }); it.each([ ['a short-circuit guard', 'f.notion && await f.notion.createPage({ parent: "p", title: "t" });'], - ['an alias guard', 'const n = f.notion; if (n) await n.createPage({ parent: "p", title: "t" }); n && await n.createPage({ parent: "p", title: "u" });'], + ['a direct guard and comparison', 'if (f.notion !== undefined && typeof f.notion === "object") await f.notion.createPage({ parent: "p", title: "t" });'], ])('does not over-refuse %s that only calls supported methods', async (_shape, body) => { const result = await check(fixture(body, '{ tools: { notion: true } },')); expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); expect(result.exit).toBe(0); }); +it.each([ + ['a captured alias inside a root-shadowing function', 'const notion = f.notion; function call(f) { return notion.appendBlock("p", {}); } await call(1);'], + ['an alias used as a default', 'const run = async (n = f.notion) => globalThis.invokeHelper(n); await run();'], + ['an assignment used as an argument', 'let n; await globalThis.invokeHelper(n = f.notion);'], +])('refuses %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('keeps the helper warning and requirements when activity validation refuses', async () => { + const result = await check(fixture('f.on(globalThis.source, { idle: "1h" });', '{ tools: { slack: true } },'), true); + const report = JSON.parse(result.stdout.join('')); + expect(report.ok).toBe(false); + expect(report.diagnostics.some((d: { message: string }) => d.message.includes('unbounded_subscription'))).toBe(true); + expect(report.diagnostics.some((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toBe(true); + expect(report.requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); +}); From 3d5b7bd3fe88c60f8405c81107f17611d2014d9a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:14:07 -0700 Subject: [PATCH 22/43] fix(check): inherited object members on f.notion are unprovable f.notion.valueOf() returns the helper itself (and constructor/__proto__ reach its prototype), so a call chained after one is not attributable. Helper methods return steps; any Object.prototype member on f.notion now makes the appendBlock call unprovable, keeping check fail-closed. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 3 +++ packages/sdk/tests/check-helper-surface.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index f5c225d89..6f5a7408a 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -45,6 +45,9 @@ export function helperOperationUse(body: string, root: string, namespace: string const operation = memberName(grand); if (operation === undefined) mark('unprovable'); // f.notion[expr] else if (operation === method) mark('called'); + // Inherited object members (valueOf, constructor, __proto__, ...) hand the + // helper back or reach its prototype; helper methods return steps. + else if (operation === '__proto__' || Object.prototype.hasOwnProperty.call(Object.prototype, operation)) mark('unprovable'); return; } if (!readOrCall(parent, parents)) mark('unprovable'); diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index 9cdab317c..d868bd02c 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -438,3 +438,11 @@ it('keeps the helper warning and requirements when activity validation refuses', expect(report.diagnostics.some((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toBe(true); expect(report.requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); }); +it.each([ + ['valueOf()', 'await f.notion.valueOf().appendBlock("page", {});'], + ['constructor', 'await globalThis.invokeHelper(f.notion.constructor);'], +])('refuses f.notion reached back through the inherited %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 970c370aa6457165bb0eb182a2dc7c4433f476b4 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:24:22 -0700 Subject: [PATCH 23/43] fix(check): earlier comma operands are discarded reads; split the helper-surface tests - Only a sequence's last operand carries its value, so `(f.notion, 1)` reads f.notion and discards it; it no longer makes appendBlock unprovable. - The 448-line check-helper-surface suite is split by concern: CLI/YAML surface (check-helper-surface), Notion appendBlock analysis (check-notion-append), communication and YAML remedies (check-helper-communication), Cloud connection gating (check-cloud-connect), with shared staging in helper-surface-fixture.ts. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 2 + .../sdk/tests/check-cloud-connect.test.ts | 25 ++ .../tests/check-helper-communication.test.ts | 78 +++++ .../sdk/tests/check-helper-surface.test.ts | 304 +----------------- .../sdk/tests/check-notion-append.test.ts | 172 ++++++++++ packages/sdk/tests/helper-surface-fixture.ts | 60 ++++ 6 files changed, 343 insertions(+), 298 deletions(-) create mode 100644 packages/sdk/tests/check-cloud-connect.test.ts create mode 100644 packages/sdk/tests/check-helper-communication.test.ts create mode 100644 packages/sdk/tests/check-notion-append.test.ts create mode 100644 packages/sdk/tests/helper-surface-fixture.ts diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 6f5a7408a..bf783bf5c 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -67,6 +67,8 @@ function readOrCall(value: AstNode, parents: WeakMap): boolean while (parent !== undefined && ['ChainExpression', 'ParenthesizedExpression', 'AwaitExpression', 'LogicalExpression', 'ConditionalExpression', 'SequenceExpression'].includes(parent.type) && !(parent.type === 'ConditionalExpression' && parent.test === current)) { + // Only a sequence's last operand is its value; earlier ones are discarded reads. + if (parent.type === 'SequenceExpression' && (parent.expressions as AstNode[]).at(-1) !== current) return true; current = parent; parent = parents.get(current); } diff --git a/packages/sdk/tests/check-cloud-connect.test.ts b/packages/sdk/tests/check-cloud-connect.test.ts new file mode 100644 index 000000000..1e129ef26 --- /dev/null +++ b/packages/sdk/tests/check-cloud-connect.test.ts @@ -0,0 +1,25 @@ +import { dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { expect, it, vi } from 'vitest'; +import { check, directories, fixture, shippedExample, useHelperSurfaceEnv } from './helper-surface-fixture.js'; + +useHelperSurfaceEnv(); + +it.each(['test.flow.mjs', 'test.flow.js'])('Cloud requirement reading covers %s, so submit can verify its integrations', async name => { + const { flowRequirementsForPath } = await import('../src/cli/cloud-connect-cli.js'); + const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); + writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); + const requirements = await flowRequirementsForPath(path); + expect(requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); +}); +it.each(['test.flow.mjs', 'test.flow.js'])('never prompts to connect integrations for %s, which Cloud submission refuses', async name => { + const { ensureFlowConnections } = await import('../src/cli/cloud-connect-cli.js'); + const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); + writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no network in this test')); + const prompt = vi.fn(async () => true); + expect(await ensureFlowConnections({ path, prompt } as never, { token: 'test-token' })).toBeUndefined(); + expect(prompt).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/tests/check-helper-communication.test.ts b/packages/sdk/tests/check-helper-communication.test.ts new file mode 100644 index 000000000..224ad8f7d --- /dev/null +++ b/packages/sdk/tests/check-helper-communication.test.ts @@ -0,0 +1,78 @@ +import { dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { expect, it, vi } from 'vitest'; +import { check, directories, fixture, shippedExample, useHelperSurfaceEnv } from './helper-surface-fixture.js'; +import { helperCredentialDiagnostics } from '../src/cli/check-helper-surface.js'; +import { checkAuthoredFlow } from '../src/cli/check.js'; +import type { FlowSpec } from '../src/spec.js'; + +useHelperSurfaceEnv(); + +function communicatingYaml(provider: 'slack' | 'linear' | 'github', mounted: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-comm-')); + directories.push(dir); + const cli = join(dir, 'codex'); + writeFileSync(cli, '#!/bin/sh\nexit 0\n'); + chmodSync(cli, 0o755); + if (mounted) { + mkdirSync(join(dir, provider)); + vi.stubEnv('RELAYFILE_MOUNT_PATH', dir); + } + const helper = provider === 'slack' ? { slack: { post: { channel: '#test', text: 'hi' } } } + : provider === 'linear' ? { linear: { createIssue: { teamId: 'eng', title: 'hi' } } } + : { github: { createIssue: { owner: 'o', repo: 'r', title: 'hi', body: 'hi' } } }; + const spec = { version: '0.1.0', cli, steps: [ + { id: 'a', type: 'agent', instruction: 'send' }, { id: 'b', type: 'agent', instruction: 'receive' }, + { id: 'notify', ...helper }, + ], communication: { links: [{ from: 'a', to: 'b' }] } } as unknown as FlowSpec; + const config = { directory: dir, models: [], executors: [] }; + return (warn: boolean) => checkAuthoredFlow(spec, join(dir, 'flow.yaml'), + { projectConfig: config }, { warnUnresolvedHelperCredential: warn }).report; +} +it('inspecting past a missing mount never admits the flow for execution', () => { + vi.stubEnv('RELAY_API_KEY', 'rk_live_test'); + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-admit-')); + directories.push(dir); + const cli = join(dir, 'codex'); + writeFileSync(cli, '#!/bin/sh\nexit 0\n'); + chmodSync(cli, 0o755); + const spec = { version: '0.1.0', cli, steps: [{ id: 'a', type: 'agent', instruction: 'go' }, + { id: 'notify', slack: { post: { channel: '#test', text: 'hi' } } }] } as unknown as FlowSpec; + const config = { directory: dir, models: [], executors: [] }; + const checked = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }, + { warnUnresolvedHelperCredential: true }); + expect(checked.report.ok).toBe(true); + expect(checked.flow).toBeUndefined(); + const strict = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }); + expect(strict.report.ok).toBe(false); + expect(strict.flow).toBeUndefined(); +}); +it.each([false, true])('keeps the communication refusal and budget warning whether or not Slack is mounted (mounted=%s)', mounted => { + vi.stubEnv('RELAY_API_KEY', ''); + const report = communicatingYaml('slack', mounted)(true); + expect(report.ok).toBe(false); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ severity: 'refusal', kind: 'probe_failed', message: expect.stringContaining('RELAY_API_KEY') }), + expect.objectContaining({ severity: 'warning', kind: 'budget_unmetered' }), + ])); + expect(report.diagnostics.some(d => d.kind === 'helper_credential_unresolved')).toBe(!mounted); +}); +it('YAML Slack keeps the mock remedy because the YAML execution path honours it', () => { + const report = communicatingYaml('slack', false)(true); + const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); + expect(warning?.message).toContain('RELAYFLOWS_SLACK_MOCK=1'); +}); +it.each(['linear', 'github'] as const)('YAML %s offers only remedies its execution path supports', provider => { + const check = communicatingYaml(provider, false); + const report = check(true); + const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); + expect(warning?.message).toContain(`relayfile ${provider} mount`); + expect(warning?.message).not.toMatch(/_MOCK=1/); + expect(warning?.message).toContain('[helper_mount_required]'); +}); +it('authored TS helpers keep their provider-specific mock remedy', () => { + const { diagnostics } = helperCredentialDiagnostics([{ severity: 'refusal', kind: 'helper_provider.mount_required', + message: 'f.linear requires a relayfile linear mount; direct-token transport is not implemented.' }]); + expect(diagnostics[0]?.message).toContain('RELAYFLOWS_LINEAR_MOCK=1'); +}); diff --git a/packages/sdk/tests/check-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts index d868bd02c..f0556eb25 100644 --- a/packages/sdk/tests/check-helper-surface.test.ts +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -1,64 +1,15 @@ -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { runCli } from '../src/cli.js'; +import { expect, it, vi } from 'vitest'; +import { check, directories, fixture, shippedExample, useHelperSurfaceEnv } from './helper-surface-fixture.js'; import { checkHelperBody } from '../src/cli/check-helper-body.js'; import { checkAuthoredTriggers } from '../src/cli/check-triggers.js'; import { CHECK_WARNING_KINDS } from '../src/failure-kinds.js'; import { helperCredentialDiagnostics } from '../src/cli/check-helper-surface.js'; -import { checkAuthoredFlow } from '../src/cli/check.js'; -import type { FlowSpec } from '../src/spec.js'; -const directories: string[] = []; -beforeEach(() => { - for (const key of ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFLOWS_LINEAR_MOCK', - 'RELAYFLOWS_NOTION_MOCK', 'RELAYFILE_MOUNT_PATH', 'WORKSPACE_ROOT', - 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT']) vi.stubEnv(key, ''); -}); -afterEach(() => { - vi.unstubAllEnvs(); - for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); -}); -function fixture(body = "await f.slack.post('#test', 'hi');", header = '', name = 'test.flow.ts') { - const dir = mkdtempSync(join(tmpdir(), 'helper-surface-')); - directories.push(dir); - writeFileSync(join(dir, 'flows.json'), '{}'); - mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); - symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); - const path = join(dir, name); - writeFileSync(path, `import { flow } from '@relayflows/surface'; -export default flow('test', ${header} async f => { ${body} f.done('success'); });`); - return path; -} -/** - * A shipped example, copied where `@relayflows/surface` resolves — the same - * staging `tests/flow-requirements.test.ts` uses. Copied rather than checked in - * place because `examples/` carries no `package.json`, so a checkout whose - * ancestry declares `"type": "commonjs"` cannot import an authored `.flow.ts` - * there at all; the temp copy declares its own module boundary. - */ -function shippedExample(name: string) { - const dir = mkdtempSync(join(tmpdir(), 'helper-surface-example-')); - directories.push(dir); - writeFileSync(join(dir, 'package.json'), '{"type":"module"}'); - writeFileSync(join(dir, 'flows.json'), '{}'); - mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); - symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); - const path = join(dir, `${name}.flow.ts`); - copyFileSync(resolve('../../examples', name, `${name}.flow.ts`), path); - return path; -} -async function check(path: string, json = false, verb = 'check') { - const lines: string[] = []; - const stdout: string[] = []; - const stderr: string[] = []; - const exit = await runCli([verb, ...(json ? ['--json'] : []), path, ...(verb === 'run' && path.endsWith('.flow.ts') ? ['--input', '{}'] : [])], { - stdout: line => { stdout.push(line); lines.push(line); }, - stderr: line => { stderr.push(line); lines.push(line); }, - }); - return { exit, lines, stdout, stderr }; -} +useHelperSurfaceEnv(); + it('declares the warning and preserves unrelated refusals and unknown shapes', () => { expect(CHECK_WARNING_KINDS).toContain('helper_credential_unresolved'); const diagnostics = [ @@ -103,14 +54,6 @@ it.each(['slack', 'linear'])('discovers body-only %s use', async provider => { expect(result.exit).toBe(0); expect(result.stdout.join('\n')).toContain(`REQUIRES ${provider} (f.${provider})`); }); -it.each([ - ['airtable', "await f.airtable.createRecord('base', 'table', {});"], - ['notion appendBlock', "await f.notion.appendBlock('page', {});"], -])('still refuses unsupported %s without a mount', async (_name, body) => { - const result = await check(fixture(body)); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); it('keeps default and explicit opt-out checks strict, and local run refuses before attach', async () => { const path = fixture(); for (const invocation of [undefined, {}, { warnUnresolvedHelperCredential: false }]) { @@ -203,233 +146,6 @@ steps: } }); -function communicatingYaml(provider: 'slack' | 'linear' | 'github', mounted: boolean) { - const dir = mkdtempSync(join(tmpdir(), 'helper-surface-comm-')); - directories.push(dir); - const cli = join(dir, 'codex'); - writeFileSync(cli, '#!/bin/sh\nexit 0\n'); - chmodSync(cli, 0o755); - if (mounted) { - mkdirSync(join(dir, provider)); - vi.stubEnv('RELAYFILE_MOUNT_PATH', dir); - } - const helper = provider === 'slack' ? { slack: { post: { channel: '#test', text: 'hi' } } } - : provider === 'linear' ? { linear: { createIssue: { teamId: 'eng', title: 'hi' } } } - : { github: { createIssue: { owner: 'o', repo: 'r', title: 'hi', body: 'hi' } } }; - const spec = { version: '0.1.0', cli, steps: [ - { id: 'a', type: 'agent', instruction: 'send' }, { id: 'b', type: 'agent', instruction: 'receive' }, - { id: 'notify', ...helper }, - ], communication: { links: [{ from: 'a', to: 'b' }] } } as unknown as FlowSpec; - const config = { directory: dir, models: [], executors: [] }; - return (warn: boolean) => checkAuthoredFlow(spec, join(dir, 'flow.yaml'), - { projectConfig: config }, { warnUnresolvedHelperCredential: warn }).report; -} -it('inspecting past a missing mount never admits the flow for execution', () => { - vi.stubEnv('RELAY_API_KEY', 'rk_live_test'); - const dir = mkdtempSync(join(tmpdir(), 'helper-surface-admit-')); - directories.push(dir); - const cli = join(dir, 'codex'); - writeFileSync(cli, '#!/bin/sh\nexit 0\n'); - chmodSync(cli, 0o755); - const spec = { version: '0.1.0', cli, steps: [{ id: 'a', type: 'agent', instruction: 'go' }, - { id: 'notify', slack: { post: { channel: '#test', text: 'hi' } } }] } as unknown as FlowSpec; - const config = { directory: dir, models: [], executors: [] }; - const checked = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }, - { warnUnresolvedHelperCredential: true }); - expect(checked.report.ok).toBe(true); - expect(checked.flow).toBeUndefined(); - const strict = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: config }); - expect(strict.report.ok).toBe(false); - expect(strict.flow).toBeUndefined(); -}); -it.each([false, true])('keeps the communication refusal and budget warning whether or not Slack is mounted (mounted=%s)', mounted => { - vi.stubEnv('RELAY_API_KEY', ''); - const report = communicatingYaml('slack', mounted)(true); - expect(report.ok).toBe(false); - expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ severity: 'refusal', kind: 'probe_failed', message: expect.stringContaining('RELAY_API_KEY') }), - expect.objectContaining({ severity: 'warning', kind: 'budget_unmetered' }), - ])); - expect(report.diagnostics.some(d => d.kind === 'helper_credential_unresolved')).toBe(!mounted); -}); -it('YAML Slack keeps the mock remedy because the YAML execution path honours it', () => { - const report = communicatingYaml('slack', false)(true); - const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); - expect(warning?.message).toContain('RELAYFLOWS_SLACK_MOCK=1'); -}); -it.each(['linear', 'github'] as const)('YAML %s offers only remedies its execution path supports', provider => { - const check = communicatingYaml(provider, false); - const report = check(true); - const warning = report.diagnostics.find(d => d.kind === 'helper_credential_unresolved'); - expect(warning?.message).toContain(`relayfile ${provider} mount`); - expect(warning?.message).not.toMatch(/_MOCK=1/); - expect(warning?.message).toContain('[helper_mount_required]'); -}); -it('authored TS helpers keep their provider-specific mock remedy', () => { - const { diagnostics } = helperCredentialDiagnostics([{ severity: 'refusal', kind: 'helper_provider.mount_required', - message: 'f.linear requires a relayfile linear mount; direct-token transport is not implemented.' }]); - expect(diagnostics[0]?.message).toContain('RELAYFLOWS_LINEAR_MOCK=1'); -}); -it('does not refuse an unrelated .appendBlock in a Notion flow with no local mount', async () => { - const body = `const doc = { appendBlock() { return 1; } }; - doc.appendBlock(); // f.notion.appendBlock is not called here - const note = 'x.appendBlock'; - await f.notion.createPage({ parent: 'p', title: note });`; - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); - expect(result.exit).toBe(0); - expect(result.stderr.join('\n')).toContain('[helper_credential_unresolved]'); -}); -it('still refuses a real f.notion.appendBlock through an aliased context name', async () => { - const path = fixture('', '{ tools: { notion: true } },'); - writeFileSync(path, `import { flow } from '@relayflows/surface'; -export default flow('test', { tools: { notion: true } }, async ctx => { await ctx.notion.appendBlock('page', {}); ctx.done('success'); });`); - const result = await check(path); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['an alias', 'const notion = f.notion; await notion.appendBlock("page", {});'], - ['a destructured helper', 'const { notion } = f; await notion.appendBlock("page", {});'], -])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it('keeps refusing a destructured f.notion.appendBlock method', async () => { - const result = await check(fixture('const { appendBlock } = f.notion; await appendBlock("page", {});', '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it('does not treat an existence check of f.notion as a handoff', async () => { - const body = `if (f.notion && typeof f.notion === 'object') await f.notion.createPage({ parent: 'p', title: 't' }); - const doc = { appendBlock() { return 1; } }; doc.appendBlock();`; - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); - expect(result.exit).toBe(0); -}); -it.each([ - ['nested destructuring', 'const { notion: { appendBlock } } = f; await appendBlock("page", {});'], - ['an arrow expression body', 'const get = () => f.notion; await get().appendBlock("page", {});'], - ['a parameter default', 'const run = async (n = f.notion) => n.appendBlock("page", {}); await run();'], - ['a nested destructuring default', 'const { appendBlock: add = f.notion.appendBlock } = {}; await add("page", {});'], -])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it('explains an alias-inferred appendBlock refusal and how to avoid it', async () => { - const aliased = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); - expect(aliased.exit).toBe(2); - expect(aliased.stderr.join('\n')).toContain('f.notion is aliased, passed on or called through a computed name'); - const direct = await check(fixture('await f.notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); - expect(direct.exit).toBe(0); - const real = await check(fixture('await f.notion.appendBlock("page", {});', '{ tools: { notion: true } },')); - expect(real.stderr.join('\n')).not.toContain('aliased, passed on'); -}); -it.each(['test.flow.mjs', 'test.flow.js'])('Cloud requirement reading covers %s, so submit can verify its integrations', async name => { - const { flowRequirementsForPath } = await import('../src/cli/cloud-connect-cli.js'); - const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); - writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); - const requirements = await flowRequirementsForPath(path); - expect(requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); -}); -it.each([ - ['a computed method', 'const method = "appendBlock"; await f.notion[method]("page", {});'], - ['a computed namespace', 'const ns = "notion"; await f[ns].appendBlock("page", {});'], -])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it('keeps refusing a computed call through an aliased f.notion', async () => { - const result = await check(fixture('const notion = f.notion; const method = "appendBlock"; await notion[method]("page", {});', '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['a computed destructuring key', 'const method = "appendBlock"; const { [method]: append } = f.notion; await append("page", {});'], - ['a rest element', 'const { createPage, ...rest } = f.notion; await rest.appendBlock("page", {});'], -])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each(['test.flow.mjs', 'test.flow.js'])('never prompts to connect integrations for %s, which Cloud submission refuses', async name => { - const { ensureFlowConnections } = await import('../src/cli/cloud-connect-cli.js'); - const path = fixture('', '{ tools: { slack: true } },', name.replace('test', 'cloud')); - writeFileSync(join(dirname(path), 'package.json'), '{"type":"module"}'); - const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('no network in this test')); - const prompt = vi.fn(async () => true); - expect(await ensureFlowConnections({ path, prompt } as never, { token: 'test-token' })).toBeUndefined(); - expect(prompt).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); -}); -it.each([ - ['Reflect.get', 'await Reflect.get(f.notion, "appendBlock")("page", {});'], - ['a string-keyed alias', 'const n = f.notion; await n["appendBlock"]("page", {});'], -])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['a dynamic Reflect.get key', 'const method = String(Date.now()); await Reflect.get(f.notion, method)("page", {});'], - ['a helper function', 'const call = (n, m) => n[m]; await call(f.notion, "append" + "Block")("page", {});'], -])('keeps refusing when f.notion is handed to %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['an optional chain', 'const method = String(Date.now()); await Reflect.get(f?.notion, method)("page", {});'], - ['a logical fallback', 'const method = String(Date.now()); await Reflect.get(f.notion ?? {}, method)("page", {});'], - ['a spread argument', 'const method = String(Date.now()); await Reflect.get(...[f.notion, method])("page", {});'], -])('keeps refusing when f.notion reaches a call through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['an object passed to a call', 'await globalThis.invokeHelper({ notion: f.notion }, "page");'], - ['a returned helper', 'const get = () => { return f.notion; }; await globalThis.invokeHelper(get, "page");'], - ['a property assignment', 'const box = {}; box.n = f.notion; await globalThis.invokeHelper(box, "page");'], -])('keeps refusing when f.notion escapes through %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it('an aliased f.notion refuses with the remedy, since its calls cannot be attributed', async () => { - const result = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('call f.notion methods directly by name'); -}); -it.each([ - ['an alias handed to a call', 'const notion = f.notion; await globalThis.invokeHelper(notion, "page");'], - ['a rebound alias handed on', 'const a = f.notion; const b = a; await globalThis.invokeHelper({ b }, "page");'], - ['a destructured namespace handed on', 'const { notion } = f; await globalThis.invokeHelper(notion, "page");'], -])('keeps refusing when %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); -it.each([ - ['a short-circuit guard', 'f.notion && await f.notion.createPage({ parent: "p", title: "t" });'], - ['a direct guard and comparison', 'if (f.notion !== undefined && typeof f.notion === "object") await f.notion.createPage({ parent: "p", title: "t" });'], -])('does not over-refuse %s that only calls supported methods', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); - expect(result.exit).toBe(0); -}); -it.each([ - ['a captured alias inside a root-shadowing function', 'const notion = f.notion; function call(f) { return notion.appendBlock("p", {}); } await call(1);'], - ['an alias used as a default', 'const run = async (n = f.notion) => globalThis.invokeHelper(n); await run();'], - ['an assignment used as an argument', 'let n; await globalThis.invokeHelper(n = f.notion);'], -])('refuses %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); it('keeps the helper warning and requirements when activity validation refuses', async () => { const result = await check(fixture('f.on(globalThis.source, { idle: "1h" });', '{ tools: { slack: true } },'), true); const report = JSON.parse(result.stdout.join('')); @@ -438,11 +154,3 @@ it('keeps the helper warning and requirements when activity validation refuses', expect(report.diagnostics.some((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toBe(true); expect(report.requirements?.integrations).toContainEqual(expect.objectContaining({ provider: 'slack' })); }); -it.each([ - ['valueOf()', 'await f.notion.valueOf().appendBlock("page", {});'], - ['constructor', 'await globalThis.invokeHelper(f.notion.constructor);'], -])('refuses f.notion reached back through the inherited %s', async (_shape, body) => { - const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.exit).toBe(2); - expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); -}); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts new file mode 100644 index 000000000..9bc2d5cba --- /dev/null +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -0,0 +1,172 @@ +import { dirname, join } from 'node:path'; +import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { expect, it, vi } from 'vitest'; +import { check, directories, fixture, shippedExample, useHelperSurfaceEnv } from './helper-surface-fixture.js'; + +useHelperSurfaceEnv(); + +it.each([ + ['airtable', "await f.airtable.createRecord('base', 'table', {});"], + ['notion appendBlock', "await f.notion.appendBlock('page', {});"], +])('still refuses unsupported %s without a mount', async (_name, body) => { + const result = await check(fixture(body)); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('does not refuse an unrelated .appendBlock in a Notion flow with no local mount', async () => { + const body = `const doc = { appendBlock() { return 1; } }; + doc.appendBlock(); // f.notion.appendBlock is not called here + const note = 'x.appendBlock'; + await f.notion.createPage({ parent: 'p', title: note });`; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); + expect(result.stderr.join('\n')).toContain('[helper_credential_unresolved]'); +}); +it('still refuses a real f.notion.appendBlock through an aliased context name', async () => { + const path = fixture('', '{ tools: { notion: true } },'); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', { tools: { notion: true } }, async ctx => { await ctx.notion.appendBlock('page', {}); ctx.done('success'); });`); + const result = await check(path); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['an alias', 'const notion = f.notion; await notion.appendBlock("page", {});'], + ['a destructured helper', 'const { notion } = f; await notion.appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('keeps refusing a destructured f.notion.appendBlock method', async () => { + const result = await check(fixture('const { appendBlock } = f.notion; await appendBlock("page", {});', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('does not treat an existence check of f.notion as a handoff', async () => { + const body = `if (f.notion && typeof f.notion === 'object') await f.notion.createPage({ parent: 'p', title: 't' }); + const doc = { appendBlock() { return 1; } }; doc.appendBlock();`; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it.each([ + ['nested destructuring', 'const { notion: { appendBlock } } = f; await appendBlock("page", {});'], + ['an arrow expression body', 'const get = () => f.notion; await get().appendBlock("page", {});'], + ['a parameter default', 'const run = async (n = f.notion) => n.appendBlock("page", {}); await run();'], + ['a nested destructuring default', 'const { appendBlock: add = f.notion.appendBlock } = {}; await add("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('explains an alias-inferred appendBlock refusal and how to avoid it', async () => { + const aliased = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); + expect(aliased.exit).toBe(2); + expect(aliased.stderr.join('\n')).toContain('f.notion is aliased, passed on or called through a computed name'); + const direct = await check(fixture('await f.notion.createPage({ parent: "p", title: "t" }); const doc = { appendBlock() {} }; doc.appendBlock();', '{ tools: { notion: true } },')); + expect(direct.exit).toBe(0); + const real = await check(fixture('await f.notion.appendBlock("page", {});', '{ tools: { notion: true } },')); + expect(real.stderr.join('\n')).not.toContain('aliased, passed on'); +}); +it.each([ + ['a computed method', 'const method = "appendBlock"; await f.notion[method]("page", {});'], + ['a computed namespace', 'const ns = "notion"; await f[ns].appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('keeps refusing a computed call through an aliased f.notion', async () => { + const result = await check(fixture('const notion = f.notion; const method = "appendBlock"; await notion[method]("page", {});', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['a computed destructuring key', 'const method = "appendBlock"; const { [method]: append } = f.notion; await append("page", {});'], + ['a rest element', 'const { createPage, ...rest } = f.notion; await rest.appendBlock("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['Reflect.get', 'await Reflect.get(f.notion, "appendBlock")("page", {});'], + ['a string-keyed alias', 'const n = f.notion; await n["appendBlock"]("page", {});'], +])('keeps refusing f.notion.appendBlock reached through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['a dynamic Reflect.get key', 'const method = String(Date.now()); await Reflect.get(f.notion, method)("page", {});'], + ['a helper function', 'const call = (n, m) => n[m]; await call(f.notion, "append" + "Block")("page", {});'], +])('keeps refusing when f.notion is handed to %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['an optional chain', 'const method = String(Date.now()); await Reflect.get(f?.notion, method)("page", {});'], + ['a logical fallback', 'const method = String(Date.now()); await Reflect.get(f.notion ?? {}, method)("page", {});'], + ['a spread argument', 'const method = String(Date.now()); await Reflect.get(...[f.notion, method])("page", {});'], +])('keeps refusing when f.notion reaches a call through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['an object passed to a call', 'await globalThis.invokeHelper({ notion: f.notion }, "page");'], + ['a returned helper', 'const get = () => { return f.notion; }; await globalThis.invokeHelper(get, "page");'], + ['a property assignment', 'const box = {}; box.n = f.notion; await globalThis.invokeHelper(box, "page");'], +])('keeps refusing when f.notion escapes through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('an aliased f.notion refuses with the remedy, since its calls cannot be attributed', async () => { + const result = await check(fixture('const notion = f.notion; await notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('call f.notion methods directly by name'); +}); +it.each([ + ['an alias handed to a call', 'const notion = f.notion; await globalThis.invokeHelper(notion, "page");'], + ['a rebound alias handed on', 'const a = f.notion; const b = a; await globalThis.invokeHelper({ b }, "page");'], + ['a destructured namespace handed on', 'const { notion } = f; await globalThis.invokeHelper(notion, "page");'], +])('keeps refusing when %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['a short-circuit guard', 'f.notion && await f.notion.createPage({ parent: "p", title: "t" });'], + ['a direct guard and comparison', 'if (f.notion !== undefined && typeof f.notion === "object") await f.notion.createPage({ parent: "p", title: "t" });'], +])('does not over-refuse %s that only calls supported methods', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it.each([ + ['a captured alias inside a root-shadowing function', 'const notion = f.notion; function call(f) { return notion.appendBlock("p", {}); } await call(1);'], + ['an alias used as a default', 'const run = async (n = f.notion) => globalThis.invokeHelper(n); await run();'], + ['an assignment used as an argument', 'let n; await globalThis.invokeHelper(n = f.notion);'], +])('refuses %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it.each([ + ['valueOf()', 'await f.notion.valueOf().appendBlock("page", {});'], + ['constructor', 'await globalThis.invokeHelper(f.notion.constructor);'], +])('refuses f.notion reached back through the inherited %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('an earlier comma operand is a discarded read, not a hand-off', async () => { + const result = await check(fixture('const x = (f.notion, 1); await f.notion.createPage({ parent: "p", title: String(x) });', '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); diff --git a/packages/sdk/tests/helper-surface-fixture.ts b/packages/sdk/tests/helper-surface-fixture.ts new file mode 100644 index 000000000..46c12f392 --- /dev/null +++ b/packages/sdk/tests/helper-surface-fixture.ts @@ -0,0 +1,60 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, beforeEach, vi } from 'vitest'; +import { runCli } from '../src/cli.js'; + +/** Shared staging for the helper-surface check suites. */ +export const directories: string[] = []; +/** Register the env isolation and temp cleanup every helper-surface suite needs. */ +export function useHelperSurfaceEnv(): void { + beforeEach(() => { + for (const key of ['SLACK_BOT_TOKEN', 'RELAYFLOWS_SLACK_MOCK', 'RELAYFLOWS_LINEAR_MOCK', + 'RELAYFLOWS_NOTION_MOCK', 'RELAYFILE_MOUNT_PATH', 'WORKSPACE_ROOT', + 'WORKFORCE_SANDBOX_ROOT', 'RELAYFILE_MOUNT_ROOT', 'RELAYFILE_ROOT']) vi.stubEnv(key, ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); +} +export function fixture(body = "await f.slack.post('#test', 'hi');", header = '', name = 'test.flow.ts') { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-')); + directories.push(dir); + writeFileSync(join(dir, 'flows.json'), '{}'); + mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); + symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); + const path = join(dir, name); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', ${header} async f => { ${body} f.done('success'); });`); + return path; +} +/** + * A shipped example, copied where `@relayflows/surface` resolves — the same + * staging `tests/flow-requirements.test.ts` uses. Copied rather than checked in + * place because `examples/` carries no `package.json`, so a checkout whose + * ancestry declares `"type": "commonjs"` cannot import an authored `.flow.ts` + * there at all; the temp copy declares its own module boundary. + */ +export function shippedExample(name: string) { + const dir = mkdtempSync(join(tmpdir(), 'helper-surface-example-')); + directories.push(dir); + writeFileSync(join(dir, 'package.json'), '{"type":"module"}'); + writeFileSync(join(dir, 'flows.json'), '{}'); + mkdirSync(join(dir, 'node_modules/@relayflows'), { recursive: true }); + symlinkSync(resolve('node_modules/@relayflows/surface'), join(dir, 'node_modules/@relayflows/surface')); + const path = join(dir, `${name}.flow.ts`); + copyFileSync(resolve('../../examples', name, `${name}.flow.ts`), path); + return path; +} +export async function check(path: string, json = false, verb = 'check') { + const lines: string[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const exit = await runCli([verb, ...(json ? ['--json'] : []), path, ...(verb === 'run' && path.endsWith('.flow.ts') ? ['--input', '{}'] : [])], { + stdout: line => { stdout.push(line); lines.push(line); }, + stderr: line => { stderr.push(line); lines.push(line); }, + }); + return { exit, lines, stdout, stderr }; +} From b0d467f839347823da6f6d31df0a1fba07a863e6 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:34:02 -0700 Subject: [PATCH 24/43] fix(check): primitive-returning inherited calls on f.notion stay provable hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toString and toLocaleString, called directly on f.notion, return primitives and cannot expose the helper, so they no longer make appendBlock unprovable. Every other inherited member (valueOf, constructor, __proto__, ...) and any inherited member that is referenced rather than called still does. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 14 +++++++++++--- packages/sdk/tests/check-notion-append.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index bf783bf5c..5f5a2f467 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -45,9 +45,14 @@ export function helperOperationUse(body: string, root: string, namespace: string const operation = memberName(grand); if (operation === undefined) mark('unprovable'); // f.notion[expr] else if (operation === method) mark('called'); - // Inherited object members (valueOf, constructor, __proto__, ...) hand the - // helper back or reach its prototype; helper methods return steps. - else if (operation === '__proto__' || Object.prototype.hasOwnProperty.call(Object.prototype, operation)) mark('unprovable'); + // Inherited object members: called directly, the primitive-returning ones + // cannot expose the helper; any other (valueOf, constructor, __proto__, a + // referenced member) can hand it back or reach its prototype. + else if (operation === '__proto__' || Object.prototype.hasOwnProperty.call(Object.prototype, operation)) { + const call = parents.get(grand); + const calledDirectly = call?.type === 'CallExpression' && call.callee === grand; + if (!(PRIMITIVE_RETURNING.has(operation) && calledDirectly)) mark('unprovable'); + } return; } if (!readOrCall(parent, parents)) mark('unprovable'); @@ -59,6 +64,9 @@ export function helperOperationUse(body: string, root: string, namespace: string return result; } +/** Object.prototype methods whose call returns a primitive, never the receiver. */ +const PRIMITIVE_RETURNING = new Set(['hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'toLocaleString']); + /** A use that cannot hand the value on: a direct call, a discarded value, or a read. */ function readOrCall(value: AstNode, parents: WeakMap): boolean { let current = value; diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 9bc2d5cba..4fd6fa597 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -170,3 +170,15 @@ it('an earlier comma operand is a discarded read, not a hand-off', async () => { expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); expect(result.exit).toBe(0); }); +it.each([ + ['hasOwnProperty', 'if (f.notion.hasOwnProperty("createPage")) await f.notion.createPage({ parent: "p", title: "t" });'], + ['toString', 'const label = f.notion.toString(); await f.notion.createPage({ parent: "p", title: label });'], +])('allows the primitive-returning inherited call %s', async (_name, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it('still refuses an inherited member that is referenced rather than called', async () => { + const result = await check(fixture('await globalThis.invokeHelper(f.notion.hasOwnProperty);', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From 5fc8bc784174e92120c333cf967ff47318279336 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:44:03 -0700 Subject: [PATCH 25/43] fix(check): eval and Function make a Notion appendBlock call unprovable Direct eval runs its source in the body's scope, and Function builds code from text; neither leaves an AST reference to f. Any call or new of eval/Function in the body now makes the appendBlock call unprovable, so check refuses. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 4 ++++ packages/sdk/tests/check-notion-append.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 5f5a2f467..899726269 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -33,6 +33,10 @@ export function helperOperationUse(body: string, root: string, namespace: string let result: 'called' | 'absent' | 'unprovable' = 'absent'; const mark = (found: 'called' | 'unprovable') => { if (result !== 'unprovable') result = found; }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { + // eval and Function run source text this scan never sees. + if ((node.type === 'CallExpression' || node.type === 'NewExpression') + && (node.callee as AstNode).type === 'Identifier' + && ['eval', 'Function'].includes((node.callee as AstNode).name!)) return mark('unprovable'); if (node.type !== 'Identifier' || node.name !== root) return; const parent = parents.get(node); if (parent === undefined || declares(parent, node)) return; diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 4fd6fa597..ad8459bc7 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -182,3 +182,11 @@ it('still refuses an inherited member that is referenced rather than called', as const result = await check(fixture('await globalThis.invokeHelper(f.notion.hasOwnProperty);', '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it.each([ + ['direct eval', 'await eval("f.notion.appendBlock(\'page\', {})");'], + ['the Function constructor', 'await new Function("f", "return f.notion.appendBlock(\'page\', {})")(f);'], +])('refuses code run through %s, which the scan cannot see', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 3dc4b858d17129d912747bf0748241484e432757 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 03:53:50 -0700 Subject: [PATCH 26/43] fix(check): template interpolation of f.notion is a read `${f.notion}` coerces the helper to a string and cannot hand it on, so it no longer makes the appendBlock call unprovable. A tagged template still does, since it passes the value to its tag function. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 2 ++ packages/sdk/tests/check-notion-append.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 899726269..7ed06710c 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -87,6 +87,8 @@ function readOrCall(value: AstNode, parents: WeakMap): boolean if (parent === undefined) return false; if ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current) return true; if (['ExpressionStatement', 'UnaryExpression', 'BinaryExpression'].includes(parent.type)) return true; + // `${f.notion}` coerces to a string; a tagged template hands the value to its tag. + if (parent.type === 'TemplateLiteral') return parents.get(parent)?.type !== 'TaggedTemplateExpression'; return ['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ConditionalExpression'].includes(parent.type) && parent.test === current; } diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index ad8459bc7..9b8a21776 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -190,3 +190,12 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it('treats template interpolation as a read', async () => { + const result = await check(fixture('const label = `${f.notion}`; await f.notion.createPage({ parent: "p", title: label });', '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it('still refuses a tagged template, which hands the value to its tag', async () => { + const result = await check(fixture('await globalThis.tagHelper`${f.notion}`;', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From caf375d3115f949c10dd5804bc78044fd9331291 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:04:05 -0700 Subject: [PATCH 27/43] fix(check): arguments reaches the context; reading appendBlock is not calling it - In a non-arrow body, arguments[0] is the flow context without naming it; any use of arguments now makes the appendBlock call unprovable. - f.notion.appendBlock counts as called only when it is invoked; a read (typeof, comparison) is not a call, and extracting the function reference (const add = f.notion.appendBlock) is unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 10 +++++++++- packages/sdk/tests/check-notion-append.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 7ed06710c..44b657c5a 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -37,6 +37,8 @@ export function helperOperationUse(body: string, root: string, namespace: string if ((node.type === 'CallExpression' || node.type === 'NewExpression') && (node.callee as AstNode).type === 'Identifier' && ['eval', 'Function'].includes((node.callee as AstNode).name!)) return mark('unprovable'); + // `arguments[0]` is the context in a non-arrow body without naming it. + if (node.type === 'Identifier' && node.name === 'arguments') return mark('unprovable'); if (node.type !== 'Identifier' || node.name !== root) return; const parent = parents.get(node); if (parent === undefined || declares(parent, node)) return; @@ -48,7 +50,13 @@ export function helperOperationUse(body: string, root: string, namespace: string if (grand?.type === 'MemberExpression' && grand.object === parent) { const operation = memberName(grand); if (operation === undefined) mark('unprovable'); // f.notion[expr] - else if (operation === method) mark('called'); + else if (operation === method) { + // Calling it is the operation; a read (typeof, comparison) is not; + // anything else hands the function on. + const call = parents.get(grand); + if (call?.type === 'CallExpression' && call.callee === grand) mark('called'); + else if (!readOrCall(grand, parents)) mark('unprovable'); + } // Inherited object members: called directly, the primitive-returning ones // cannot expose the helper; any other (valueOf, constructor, __proto__, a // referenced member) can hand it back or reach its prototype. diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 9b8a21776..d7bec8391 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -199,3 +199,20 @@ it('still refuses a tagged template, which hands the value to its tag', async () const result = await check(fixture('await globalThis.tagHelper`${f.notion}`;', '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('refuses the context reached through arguments in a non-arrow body', async () => { + const path = fixture('', '{ tools: { notion: true } },'); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', { tools: { notion: true } }, async function (f) { await arguments[0].notion.appendBlock('p', {}); f.done('success'); });`); + const result = await check(path); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('a typeof probe of appendBlock is not a call', async () => { + const result = await check(fixture('if (typeof f.notion.appendBlock === "function") await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it('an extracted appendBlock reference is unprovable', async () => { + const result = await check(fixture('const add = f.notion.appendBlock; await add("p", {});', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From da3a67a104a33bc84bb495bd66276e2060dea981 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:14:28 -0700 Subject: [PATCH 28/43] fix(check): instanceof escapes; arguments only for the root; wrapped appendBlock calls count - An instanceof operand is handed to Symbol.hasInstance, so it is no longer a passive read of f.notion. - arguments makes the call unprovable only when it is a use inside the root flow function itself; a nested non-arrow function's own arguments and an 'arguments' property name are not the context. - Any use of f.notion.appendBlock other than a pure read now counts as the operation, so calls through parentheses, a comma, ||/?:, or new are seen. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 35 ++++++++++++++----- .../sdk/tests/check-notion-append.test.ts | 22 ++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 44b657c5a..2879b563d 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -37,8 +37,8 @@ export function helperOperationUse(body: string, root: string, namespace: string if ((node.type === 'CallExpression' || node.type === 'NewExpression') && (node.callee as AstNode).type === 'Identifier' && ['eval', 'Function'].includes((node.callee as AstNode).name!)) return mark('unprovable'); - // `arguments[0]` is the context in a non-arrow body without naming it. - if (node.type === 'Identifier' && node.name === 'arguments') return mark('unprovable'); + // `arguments[0]` is the context in a non-arrow root body without naming it. + if (node.type === 'Identifier' && node.name === 'arguments' && rootArguments(node, root, parents)) return mark('unprovable'); if (node.type !== 'Identifier' || node.name !== root) return; const parent = parents.get(node); if (parent === undefined || declares(parent, node)) return; @@ -53,9 +53,9 @@ export function helperOperationUse(body: string, root: string, namespace: string else if (operation === method) { // Calling it is the operation; a read (typeof, comparison) is not; // anything else hands the function on. - const call = parents.get(grand); - if (call?.type === 'CallExpression' && call.callee === grand) mark('called'); - else if (!readOrCall(grand, parents)) mark('unprovable'); + // Any use but a pure read reaches the operation: a call, however + // wrapped (parentheses, comma, ||, ?:, new), or the function handed on. + if (!readOrCall(grand, parents, false)) mark('called'); } // Inherited object members: called directly, the primitive-returning ones // cannot expose the helper; any other (valueOf, constructor, __proto__, a @@ -79,8 +79,11 @@ export function helperOperationUse(body: string, root: string, namespace: string /** Object.prototype methods whose call returns a primitive, never the receiver. */ const PRIMITIVE_RETURNING = new Set(['hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'toLocaleString']); -/** A use that cannot hand the value on: a direct call, a discarded value, or a read. */ -function readOrCall(value: AstNode, parents: WeakMap): boolean { +/** + * A use that cannot hand the value on: a discarded value or a read, and — + * when `allowCall` — a direct call of it. + */ +function readOrCall(value: AstNode, parents: WeakMap, allowCall = true): boolean { let current = value; let parent = parents.get(current); // Wrappers that pass their operand's value through unchanged. @@ -93,14 +96,28 @@ function readOrCall(value: AstNode, parents: WeakMap): boolean parent = parents.get(current); } if (parent === undefined) return false; - if ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current) return true; - if (['ExpressionStatement', 'UnaryExpression', 'BinaryExpression'].includes(parent.type)) return true; + if ((parent.type === 'CallExpression' || parent.type === 'NewExpression') && parent.callee === current) return allowCall; + // `instanceof` hands its left operand to the right side's Symbol.hasInstance. + if (parent.type === 'BinaryExpression') return parent.operator !== 'instanceof'; + if (['ExpressionStatement', 'UnaryExpression'].includes(parent.type)) return true; // `${f.notion}` coerces to a string; a tagged template hands the value to its tag. if (parent.type === 'TemplateLiteral') return parents.get(parent)?.type !== 'TaggedTemplateExpression'; return ['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ConditionalExpression'].includes(parent.type) && parent.test === current; } +/** `arguments` used (not named) inside the root flow function itself, not a nested non-arrow one. */ +function rootArguments(node: AstNode, root: string, parents: WeakMap): boolean { + const parent = parents.get(node); + if (parent !== undefined && declares(parent, node)) return false; + for (let at = parents.get(node); at !== undefined; at = parents.get(at)) { + if (at.type === 'FunctionDeclaration' || at.type === 'FunctionExpression') { + return ((at.params as AstNode[] | undefined) ?? []).some(param => param.type === 'Identifier' && param.name === root); + } + } + return false; +} + /** The identifier is a name being declared or a property name, not a use of the context. */ function declares(parent: AstNode, node: AstNode): boolean { if (isFunction(parent)) return ((parent.params as AstNode[] | undefined) ?? []).includes(node); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index d7bec8391..8cbcb8a14 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -216,3 +216,25 @@ it('an extracted appendBlock reference is unprovable', async () => { const result = await check(fixture('const add = f.notion.appendBlock; await add("p", {});', '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('an instanceof operand hands the helper to Symbol.hasInstance', async () => { + const result = await check(fixture('if (f.notion instanceof globalThis.Probe) await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it.each([ + ['a nested function', 'function count() { return arguments.length; } await f.notion.createPage({ parent: "p", title: String(count()) });'], + ['a property name', 'const o = { arguments: 1 }; await f.notion.createPage({ parent: "p", title: String(o.arguments) });'], +])('does not refuse arguments belonging to %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); +it.each([ + ['parentheses', 'await (f.notion.appendBlock)("p", {});'], + ['a comma expression', 'await (0, f.notion.appendBlock)("p", {});'], + ['a logical fallback', 'await (f.notion.appendBlock || globalThis.noop)("p", {});'], + ['new', 'new f.notion.appendBlock("p", {});'], +])('counts an appendBlock call through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 58664e784e67624ef3632abdd741a7ce4b056c08 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:24:31 -0700 Subject: [PATCH 29/43] fix(check): the root's arguments regardless of its parameter shape; no context name is unprovable - rootArguments now identifies the root flow function as the outermost non-arrow function, so a defaulted or destructured context parameter does not hide arguments[0]. - When the context parameter has no name (destructured, say) and the body is not empty, a declared Notion flow cannot attribute its calls, so appendBlock is unprovable; the refusal names using a named context parameter. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 21 ++++++++++++------- packages/sdk/src/helper-preflight.ts | 10 +++++---- .../sdk/tests/check-notion-append.test.ts | 11 ++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 2879b563d..0c24e127a 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -38,7 +38,7 @@ export function helperOperationUse(body: string, root: string, namespace: string && (node.callee as AstNode).type === 'Identifier' && ['eval', 'Function'].includes((node.callee as AstNode).name!)) return mark('unprovable'); // `arguments[0]` is the context in a non-arrow root body without naming it. - if (node.type === 'Identifier' && node.name === 'arguments' && rootArguments(node, root, parents)) return mark('unprovable'); + if (node.type === 'Identifier' && node.name === 'arguments' && rootArguments(node, parents)) return mark('unprovable'); if (node.type !== 'Identifier' || node.name !== root) return; const parent = parents.get(node); if (parent === undefined || declares(parent, node)) return; @@ -106,16 +106,23 @@ function readOrCall(value: AstNode, parents: WeakMap, allowCal && parent.test === current; } -/** `arguments` used (not named) inside the root flow function itself, not a nested non-arrow one. */ -function rootArguments(node: AstNode, root: string, parents: WeakMap): boolean { +/** + * `arguments` used (not named) inside the root flow function itself — the + * outermost non-arrow function, whatever shape its context parameter has — not + * inside a nested non-arrow function with its own `arguments`. + */ +function rootArguments(node: AstNode, parents: WeakMap): boolean { const parent = parents.get(node); if (parent !== undefined && declares(parent, node)) return false; + let owner: AstNode | undefined; for (let at = parents.get(node); at !== undefined; at = parents.get(at)) { - if (at.type === 'FunctionDeclaration' || at.type === 'FunctionExpression') { - return ((at.params as AstNode[] | undefined) ?? []).some(param => param.type === 'Identifier' && param.name === root); - } + if (at.type === 'FunctionDeclaration' || at.type === 'FunctionExpression') { owner = at; break; } + } + if (owner === undefined) return false; + for (let at = parents.get(owner); at !== undefined; at = parents.get(at)) { + if (at.type === 'FunctionDeclaration' || at.type === 'FunctionExpression' || at.type === 'ArrowFunctionExpression') return false; } - return false; + return true; } /** The identifier is a name being declared or a property name, not a use of the context. */ diff --git a/packages/sdk/src/helper-preflight.ts b/packages/sdk/src/helper-preflight.ts index 07088f499..1d1921949 100644 --- a/packages/sdk/src/helper-preflight.ts +++ b/packages/sdk/src/helper-preflight.ts @@ -22,9 +22,11 @@ export function preflightHelpers( // f.notion.appendBlock has no writeback route. It is attributed precisely only // while f.notion is used directly; once it is aliased, passed on or reached by a // computed name, the call cannot be ruled out and check refuses, saying why. - const notionAppend = root === undefined - ? (/\.\s*appendBlock\b/.test(body) ? 'unprovable' : 'absent') - : helperOperationUse(body, root, 'notion', 'appendBlock'); + // Without a named context parameter (destructured, say) nothing can be + // attributed, so a declared Notion flow cannot rule the call out. + const notionAppend = body.trim() === '' ? 'absent' + : root === undefined ? 'unprovable' + : helperOperationUse(body, root, 'notion', 'appendBlock'); const appendsNotionBlock = notionAppend !== 'absent'; const notionAppendInferred = notionAppend === 'unprovable'; for (const { provider, namespace, supported } of helperProviders) { @@ -47,7 +49,7 @@ export function preflightHelpers( diagnostics.push({ severity: 'refusal', kind: 'helper_provider.unsupported', message: 'f.notion.appendBlock is mock-only: the Notion adapter has no append-block writeback route.' + (notionAppendInferred ? ' f.notion is aliased, passed on or called through a computed name in this body, so the call cannot be ruled out;' - + ' call f.notion methods directly by name (f.notion.createPage(...)) so flows check can see which ones run.' : '') }); + + ' call f.notion methods directly by name on a named context parameter (f.notion.createPage(...)) so flows check can see which ones run.' : '') }); } } return { ok: diagnostics.length === 0, gates: [], resolutions: [], diagnostics }; diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 8cbcb8a14..6c9f58f2b 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -238,3 +238,14 @@ it.each([ expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['a defaulted context parameter', "async function (f = {}) { await arguments[0].notion.appendBlock('p', {}); f.done('success'); }"], + ['a destructured context parameter', "async function ({ done }) { await arguments[0].notion[globalThis.method]('p', {}); done('success'); }"], +])('refuses appendBlock reached through %s', async (_shape, fn) => { + const path = fixture('', '{ tools: { notion: true } },'); + writeFileSync(path, `import { flow } from '@relayflows/surface'; +export default flow('test', { tools: { notion: true } }, ${fn});`); + const result = await check(path); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); From 5b99a20b290ce379cf82db4b5ed07f0fe341350a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:34:54 -0700 Subject: [PATCH 30/43] fix(check): keep helper diagnostics the trigger leg did not repeat The composed check dropped the helper leg's diagnostics whenever a trigger report existed, assuming it repeated them. An early trigger failure (a failed extension probe) reports none, so the missing-mount warning vanished until a later run. Helper diagnostics are now kept unless the trigger report contains the same kind and message. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli.ts | 7 ++++-- .../tests/check-composed-diagnostics.test.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/tests/check-composed-diagnostics.test.ts diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 470628a39..751e67192 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -466,8 +466,11 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR ? await checkAuthoredTriggers(path, invocation) : undefined; const triggerDiagnostics = triggers?.report.diagnostics ?? []; - // The trigger leg already includes the same helper diagnostics. - const helperDiagnostics = triggers === undefined ? helper.report.diagnostics : []; + // The trigger leg normally repeats the helper diagnostics; keep any it did not + // (an early trigger or config failure reports none of them). + const repeated = new Set(triggerDiagnostics.map(diagnostic => `${diagnostic.kind}\u0000${diagnostic.message}`)); + const helperDiagnostics = helper.report.diagnostics + .filter(diagnostic => !repeated.has(`${diagnostic.kind}\u0000${diagnostic.message}`)); const triggerOk = triggers?.report.ok ?? true; return { report: { diff --git a/packages/sdk/tests/check-composed-diagnostics.test.ts b/packages/sdk/tests/check-composed-diagnostics.test.ts new file mode 100644 index 000000000..05c9a2a49 --- /dev/null +++ b/packages/sdk/tests/check-composed-diagnostics.test.ts @@ -0,0 +1,22 @@ +import { expect, it, vi } from 'vitest'; +import { check, fixture, useHelperSurfaceEnv } from './helper-surface-fixture.js'; + +// An early trigger-leg failure (a failed extension probe, say) reports none of +// the helper diagnostics the helper leg already produced. +vi.mock('../src/cli/check-triggers.js', async original => ({ + ...await original(), + checkAuthoredTriggers: async () => ({ report: { ok: false, gates: [], resolutions: [], diagnostics: [ + { severity: 'refusal', kind: 'probe_failed', message: 'extension probe failed' }, + ] } }), +})); + +useHelperSurfaceEnv(); + +it('keeps the helper warning when trigger inspection fails early', async () => { + const result = await check(fixture('', '{ tools: { slack: true } },'), true); + const report = JSON.parse(result.stdout.join('')); + expect(report.ok).toBe(false); + expect(report.diagnostics.map((d: { kind: string }) => d.kind)).toEqual( + expect.arrayContaining(['probe_failed', 'helper_credential_unresolved'])); + expect(report.diagnostics.filter((d: { kind: string }) => d.kind === 'helper_credential_unresolved')).toHaveLength(1); +}); From 1c4dd1beac5ccf460e53baa59ddb80c6a46b6481 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:44:14 -0700 Subject: [PATCH 31/43] fix(check): every inherited member on f.notion is unprovable again A body can replace Object.prototype.toString (or any inherited method) to capture its receiver, so calling even a primitive-returning inherited method on f.notion can hand the helper on. The allowance for those names is removed: any Object.prototype member on f.notion makes appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 15 ++++----------- packages/sdk/tests/check-notion-append.test.ts | 8 ++++---- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 0c24e127a..998ed5bb1 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -57,14 +57,10 @@ export function helperOperationUse(body: string, root: string, namespace: string // wrapped (parentheses, comma, ||, ?:, new), or the function handed on. if (!readOrCall(grand, parents, false)) mark('called'); } - // Inherited object members: called directly, the primitive-returning ones - // cannot expose the helper; any other (valueOf, constructor, __proto__, a - // referenced member) can hand it back or reach its prototype. - else if (operation === '__proto__' || Object.prototype.hasOwnProperty.call(Object.prototype, operation)) { - const call = parents.get(grand); - const calledDirectly = call?.type === 'CallExpression' && call.callee === grand; - if (!(PRIMITIVE_RETURNING.has(operation) && calledDirectly)) mark('unprovable'); - } + // Inherited object members (valueOf, constructor, toString, ...) can hand + // the helper back — directly, or through a prototype the body replaced — + // so none is attributable; helper methods return steps. + else if (operation === '__proto__' || Object.prototype.hasOwnProperty.call(Object.prototype, operation)) mark('unprovable'); return; } if (!readOrCall(parent, parents)) mark('unprovable'); @@ -76,9 +72,6 @@ export function helperOperationUse(body: string, root: string, namespace: string return result; } -/** Object.prototype methods whose call returns a primitive, never the receiver. */ -const PRIMITIVE_RETURNING = new Set(['hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'toLocaleString']); - /** * A use that cannot hand the value on: a discarded value or a read, and — * when `allowCall` — a direct call of it. diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 6c9f58f2b..4fe068272 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -172,11 +172,11 @@ it('an earlier comma operand is a discarded read, not a hand-off', async () => { }); it.each([ ['hasOwnProperty', 'if (f.notion.hasOwnProperty("createPage")) await f.notion.createPage({ parent: "p", title: "t" });'], - ['toString', 'const label = f.notion.toString(); await f.notion.createPage({ parent: "p", title: label });'], -])('allows the primitive-returning inherited call %s', async (_name, body) => { + ['toString after replacing it', 'let held; Object.prototype.toString = function () { held = this; return ""; }; f.notion.toString(); await held.appendBlock("p", {});'], +])('refuses the inherited call %s, since a replaced prototype can capture the helper', async (_name, body) => { const result = await check(fixture(body, '{ tools: { notion: true } },')); - expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); - expect(result.exit).toBe(0); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); it('still refuses an inherited member that is referenced rather than called', async () => { const result = await check(fixture('await globalThis.invokeHelper(f.notion.hasOwnProperty);', '{ tools: { notion: true } },')); From f9e709c8597811e1bdf13c6f06c6e084bd06b2c3 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 04:54:38 -0700 Subject: [PATCH 32/43] fix(check): prototype tampering makes Notion appendBlock unprovable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coercion (template interpolation, ==, +) and inherited calls can be intercepted once a body rewrites the prototypes helpers inherit from — a Symbol.toPrimitive hook, a replaced Object.prototype method, a Proxy. Rather than refuse every coercion, a body that reaches prototype machinery (prototype, __proto__, defineProperty/ies, set/getPrototypeOf, toPrimitive, __defineGetter__/Setter__, Reflect, Proxy) now makes the call unprovable; without it, interpolation stays a read. A machinery name assembled at runtime remains outside static reach; execution still refuses the call. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 11 +++++++++++ packages/sdk/tests/check-notion-append.test.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 998ed5bb1..ef1bba870 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -33,6 +33,13 @@ export function helperOperationUse(body: string, root: string, namespace: string let result: 'called' | 'absent' | 'unprovable' = 'absent'; const mark = (found: 'called' | 'unprovable') => { if (result !== 'unprovable') result = found; }; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { + // Prototype machinery lets the body intercept the helper wherever it is + // coerced or inherited from (toPrimitive, a replaced Object.prototype + // method, a Proxy), so no use of it is attributable once present. + if ((node.type === 'MemberExpression' && node.computed !== true + && PROTOTYPE_MACHINERY.has((node.property as AstNode).name ?? '')) + || (node.type === 'Identifier' && (node.name === 'Reflect' || node.name === 'Proxy') + && !declares(parents.get(node) ?? node, node))) return mark('unprovable'); // eval and Function run source text this scan never sees. if ((node.type === 'CallExpression' || node.type === 'NewExpression') && (node.callee as AstNode).type === 'Identifier' @@ -72,6 +79,10 @@ export function helperOperationUse(body: string, root: string, namespace: string return result; } +/** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ +const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', + 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__']); + /** * A use that cannot hand the value on: a discarded value or a read, and — * when `allowCall` — a direct call of it. diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 4fe068272..a42fbaf9f 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -249,3 +249,17 @@ export default flow('test', { tools: { notion: true } }, ${fn});`); expect(result.exit).toBe(2); expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); }); +it.each([ + ['a Symbol.toPrimitive hook', 'let held; Object.defineProperty(Object.prototype, Symbol.toPrimitive, { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'], + ['__proto__ access', 'const proto = ({}).__proto__; proto.valueOf = function () { return this; }; await f.notion.createPage({ parent: "p", title: "t" });'], + ['getPrototypeOf', 'Object.getPrototypeOf({}).hook = 1; await f.notion.createPage({ parent: "p", title: "t" });'], + ['a Proxy', 'const p = new Proxy({}, {}); await f.notion.createPage({ parent: "p", title: String(p) });'], +])('refuses a body that tampers with prototypes through %s', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); + expect(result.stderr.join('\n')).toContain('[helper_provider.unsupported]'); +}); +it('plain template interpolation without prototype tampering stays a read', async () => { + const result = await check(fixture('const label = `${f.notion}`; await f.notion.createPage({ parent: "p", title: label });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); From ba49659c6c847371a3aa77f4d8bc67f1a545f9ea Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 05:04:50 -0700 Subject: [PATCH 33/43] fix(check): scan prototype machinery body-wide; local Proxy/Reflect bindings are not the globals - Prototype machinery, eval and Function are now detected over the whole body, independent of which scope binds the context name, so tampering inside a function that shadows it is still seen. - A Proxy, Reflect, eval or Function the body binds itself (declaration, parameter, function or class name, catch parameter, destructuring) is not the global and no longer makes appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 55 +++++++++++++++---- .../sdk/tests/check-notion-append.test.ts | 13 +++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index ef1bba870..b2ef08b79 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -20,7 +20,9 @@ export function helperOperationUse(body: string, root: string, namespace: string const program = parseFlowBody(body); if (program === null) return new RegExp(`\\.\\s*${method}\\b`).test(body) ? 'unprovable' : 'absent'; const parents = new WeakMap(); + const all: AstNode[] = []; const link = (node: AstNode): void => { + all.push(node); for (const key of Object.keys(node)) { if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; const child = node[key]; @@ -32,18 +34,12 @@ export function helperOperationUse(body: string, root: string, namespace: string link(program); let result: 'called' | 'absent' | 'unprovable' = 'absent'; const mark = (found: 'called' | 'unprovable') => { if (result !== 'unprovable') result = found; }; + // Body-wide, whatever scope binds the context name: code that rewrites the + // prototypes helpers inherit from (or runs source text) can intercept the + // helper anywhere, so nothing is attributable once it is present. + const local = localNames(all); + if (all.some(node => reachesMachinery(node, parents, local))) return 'unprovable'; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { - // Prototype machinery lets the body intercept the helper wherever it is - // coerced or inherited from (toPrimitive, a replaced Object.prototype - // method, a Proxy), so no use of it is attributable once present. - if ((node.type === 'MemberExpression' && node.computed !== true - && PROTOTYPE_MACHINERY.has((node.property as AstNode).name ?? '')) - || (node.type === 'Identifier' && (node.name === 'Reflect' || node.name === 'Proxy') - && !declares(parents.get(node) ?? node, node))) return mark('unprovable'); - // eval and Function run source text this scan never sees. - if ((node.type === 'CallExpression' || node.type === 'NewExpression') - && (node.callee as AstNode).type === 'Identifier' - && ['eval', 'Function'].includes((node.callee as AstNode).name!)) return mark('unprovable'); // `arguments[0]` is the context in a non-arrow root body without naming it. if (node.type === 'Identifier' && node.name === 'arguments' && rootArguments(node, parents)) return mark('unprovable'); if (node.type !== 'Identifier' || node.name !== root) return; @@ -79,6 +75,43 @@ export function helperOperationUse(body: string, root: string, namespace: string return result; } +/** Names the body binds itself, which therefore do not refer to the global of that name. */ +function localNames(all: readonly AstNode[]): ReadonlySet { + const names = new Set(); + const bind = (pattern: AstNode | undefined | null): void => { + if (!pattern) return; + if (pattern.type === 'Identifier') names.add(pattern.name!); + else if (pattern.type === 'AssignmentPattern') bind(pattern.left as AstNode); + else if (pattern.type === 'RestElement') bind(pattern.argument as AstNode); + else if (pattern.type === 'ArrayPattern') for (const element of (pattern.elements as Array)) bind(element); + else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties as AstNode[]) bind((property.type === 'Property' ? property.value : property.argument) as AstNode); + } + }; + for (const node of all) { + if (node.type === 'VariableDeclarator') bind(node.id as AstNode); + else if (isFunction(node)) { + bind(node.id as AstNode | undefined); + for (const param of (node.params as AstNode[] | undefined) ?? []) bind(param); + } else if (node.type === 'ClassDeclaration') bind(node.id as AstNode | undefined); + else if (node.type === 'CatchClause') bind(node.param as AstNode | undefined); + } + return names; +} + +/** Prototype machinery, eval or Function: anything that lets the body intercept or hide a helper use. */ +function reachesMachinery(node: AstNode, parents: WeakMap, local: ReadonlySet): boolean { + if (node.type === 'MemberExpression' && node.computed !== true + && PROTOTYPE_MACHINERY.has((node.property as AstNode).name ?? '')) return true; + if (node.type === 'Identifier' && (node.name === 'Reflect' || node.name === 'Proxy') && !local.has(node.name)) { + const parent = parents.get(node); + return parent === undefined || !declares(parent, node); + } + return (node.type === 'CallExpression' || node.type === 'NewExpression') + && (node.callee as AstNode).type === 'Identifier' + && ['eval', 'Function'].includes((node.callee as AstNode).name!) && !local.has((node.callee as AstNode).name!); +} + /** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__']); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index a42fbaf9f..c4eeea7cf 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -263,3 +263,16 @@ it('plain template interpolation without prototype tampering stays a read', asyn const result = await check(fixture('const label = `${f.notion}`; await f.notion.createPage({ parent: "p", title: label });', '{ tools: { notion: true } },')); expect(result.exit).toBe(0); }); +it('refuses prototype tampering hidden in a function that shadows the context name', async () => { + const body = 'let held; function install(f) { Object.defineProperty(Object.prototype, Symbol.toPrimitive, { value() { held = this; return ""; }, configurable: true }); } install(); const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it.each([ + ['const Proxy', 'const Proxy = { label: "x" }; await f.notion.createPage({ parent: "p", title: Proxy.label });'], + ['a shorthand of a local Reflect', 'const Reflect = 1; const o = { Reflect }; await f.notion.createPage({ parent: "p", title: String(o.Reflect) });'], +])('does not treat %s as prototype machinery', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); + expect(result.exit).toBe(0); +}); From 4965ddea1ffe8b8c590f1ee9f40db0500987792e Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 05:15:45 -0700 Subject: [PATCH 34/43] fix(check): string-keyed prototype members; scope local machinery names per use - Prototype machinery is matched through memberName, so static string-keyed members (Object['defineProperty'], Object['prototype'], Symbol['toPrimitive']) are recognised like their dotted forms. - A Proxy/Reflect/eval/Function use counts as local only when an enclosing scope of that use binds the name (parameters, function-expression name, catch parameter, block/program declarations), not when any scope in the body does, so a binding in one function cannot hide a global use in another. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 75 ++++++++++++------- .../sdk/tests/check-notion-append.test.ts | 22 ++++++ 2 files changed, 69 insertions(+), 28 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index b2ef08b79..44065ca3e 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -37,8 +37,7 @@ export function helperOperationUse(body: string, root: string, namespace: string // Body-wide, whatever scope binds the context name: code that rewrites the // prototypes helpers inherit from (or runs source text) can intercept the // helper anywhere, so nothing is attributable once it is present. - const local = localNames(all); - if (all.some(node => reachesMachinery(node, parents, local))) return 'unprovable'; + if (all.some(node => reachesMachinery(node, parents))) return 'unprovable'; walkReferences(program, root, false, { rootFunctionFound: false }, (node) => { // `arguments[0]` is the context in a non-arrow root body without naming it. if (node.type === 'Identifier' && node.name === 'arguments' && rootArguments(node, parents)) return mark('unprovable'); @@ -75,41 +74,61 @@ export function helperOperationUse(body: string, root: string, namespace: string return result; } -/** Names the body binds itself, which therefore do not refer to the global of that name. */ -function localNames(all: readonly AstNode[]): ReadonlySet { +/** Every name a binding pattern introduces. */ +function patternNames(pattern: AstNode | undefined | null, into: Set): void { + if (!pattern) return; + if (pattern.type === 'Identifier') into.add(pattern.name!); + else if (pattern.type === 'AssignmentPattern') patternNames(pattern.left as AstNode, into); + else if (pattern.type === 'RestElement') patternNames(pattern.argument as AstNode, into); + else if (pattern.type === 'ArrayPattern') for (const element of pattern.elements as Array) patternNames(element, into); + else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties as AstNode[]) patternNames((property.type === 'Property' ? property.value : property.argument) as AstNode, into); + } +} + +/** The names one scope node binds directly: its parameters, function name, catch parameter, or declarations. */ +function scopeBindings(scope: AstNode): Set { const names = new Set(); - const bind = (pattern: AstNode | undefined | null): void => { - if (!pattern) return; - if (pattern.type === 'Identifier') names.add(pattern.name!); - else if (pattern.type === 'AssignmentPattern') bind(pattern.left as AstNode); - else if (pattern.type === 'RestElement') bind(pattern.argument as AstNode); - else if (pattern.type === 'ArrayPattern') for (const element of (pattern.elements as Array)) bind(element); - else if (pattern.type === 'ObjectPattern') { - for (const property of pattern.properties as AstNode[]) bind((property.type === 'Property' ? property.value : property.argument) as AstNode); + if (isFunction(scope)) { + if (scope.type === 'FunctionExpression') patternNames(scope.id as AstNode | undefined, names); + for (const param of (scope.params as AstNode[] | undefined) ?? []) patternNames(param, names); + } + if (scope.type === 'CatchClause') patternNames(scope.param as AstNode | undefined, names); + const statements = scope.type === 'BlockStatement' || scope.type === 'Program' ? scope.body as AstNode[] + : ['ForStatement', 'ForInStatement', 'ForOfStatement'].includes(scope.type) ? [(scope.init ?? scope.left) as AstNode] : []; + for (const statement of statements ?? []) { + if (statement?.type === 'VariableDeclaration') { + for (const declarator of statement.declarations as AstNode[]) patternNames(declarator.id as AstNode, names); + } else if (statement?.type === 'FunctionDeclaration' || statement?.type === 'ClassDeclaration') { + patternNames(statement.id as AstNode | undefined, names); } - }; - for (const node of all) { - if (node.type === 'VariableDeclarator') bind(node.id as AstNode); - else if (isFunction(node)) { - bind(node.id as AstNode | undefined); - for (const param of (node.params as AstNode[] | undefined) ?? []) bind(param); - } else if (node.type === 'ClassDeclaration') bind(node.id as AstNode | undefined); - else if (node.type === 'CatchClause') bind(node.param as AstNode | undefined); } return names; } +/** Whether `node`, a use of `name`, resolves to a binding in an enclosing scope rather than the global. */ +function boundLocally(node: AstNode, name: string, parents: WeakMap): boolean { + for (let at = parents.get(node); at !== undefined; at = parents.get(at)) { + if (scopeBindings(at).has(name)) return true; + } + return false; +} + /** Prototype machinery, eval or Function: anything that lets the body intercept or hide a helper use. */ -function reachesMachinery(node: AstNode, parents: WeakMap, local: ReadonlySet): boolean { - if (node.type === 'MemberExpression' && node.computed !== true - && PROTOTYPE_MACHINERY.has((node.property as AstNode).name ?? '')) return true; - if (node.type === 'Identifier' && (node.name === 'Reflect' || node.name === 'Proxy') && !local.has(node.name)) { +function reachesMachinery(node: AstNode, parents: WeakMap): boolean { + // memberName also reads string-literal computed keys (Object['prototype']). + if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) return true; + if (node.type === 'Identifier' && ['Reflect', 'Proxy', 'eval', 'Function'].includes(node.name!)) { const parent = parents.get(node); - return parent === undefined || !declares(parent, node); + if (parent !== undefined && declares(parent, node)) return false; + if (parent?.type === 'VariableDeclarator' && parent.id === node) return false; + // eval and Function matter only when called; a use resolving to a local + // binding (scope by scope, not body-wide) is not the global. + if ((node.name === 'eval' || node.name === 'Function') + && !((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === node)) return false; + return !boundLocally(node, node.name!, parents); } - return (node.type === 'CallExpression' || node.type === 'NewExpression') - && (node.callee as AstNode).type === 'Identifier' - && ['eval', 'Function'].includes((node.callee as AstNode).name!) && !local.has((node.callee as AstNode).name!); + return false; } /** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index c4eeea7cf..7bc801bc1 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -276,3 +276,25 @@ it.each([ expect(result.stderr.join('\n')).not.toContain('[helper_provider.unsupported]'); expect(result.exit).toBe(0); }); +it('refuses prototype machinery spelled with string-keyed members', async () => { + const body = 'let held; Object["defineProperty"](Object["prototype"], Symbol["toPrimitive"], { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it.each([ + ['a nested parameter named Proxy', 'function wrap(Proxy) { return Proxy; } const p = new Proxy({}, {}); await f.notion.createPage({ parent: "p", title: String(wrap(p)) });'], + ['a catch binding named Reflect', 'try { throw 1; } catch (Reflect) { void Reflect; } await f.notion[Reflect.ownKeys({})[0] ?? "createPage"]({ parent: "p", title: "t" });'], +])('still refuses a global use when %s exists elsewhere', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('scopes local machinery names: a parameter in one function does not hide the global in another', async () => { + const local = await check(fixture('function wrap(Proxy) { return Proxy; } await f.notion.createPage({ parent: "p", title: String(wrap(1)) });', '{ tools: { notion: true } },')); + expect(local.exit).toBe(0); + const global = await check(fixture('function wrap(Proxy) { return Proxy; } const p = new Proxy({}, {}); wrap(p); await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(global.exit).toBe(2); +}); +it('a parameter named Function in one function does not hide new Function elsewhere', async () => { + const result = await check(fixture('function wrap(Function) { return Function; } wrap(1); await new Function("n", "return n.appendBlock(\'p\', {})")(globalThis.helper);', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From a6d253d0b7c0566fcc0edb8ac39ab502d4d685c2 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 05:54:41 -0700 Subject: [PATCH 35/43] fix(check): daemon validation also gates flows inspected past a missing mount; destructured machinery - With main's daemon validation merged in, a YAML flow whose only refusal was a downgraded helper mount still skipped the daemon gate (validation keyed off the original result.ok), so --against-daemon could pass without a kernel verdict. The validator now runs for inspect-only flows too. - Destructuring a prototype-machinery member (const { prototype: p } = Object, const { toPrimitive: t } = Symbol) is detected like the member access. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/cli/check.ts | 4 +++- packages/sdk/src/helper-operation-use.ts | 7 +++++++ .../sdk/tests/check-helper-communication.test.ts | 16 ++++++++++++++++ packages/sdk/tests/check-notion-append.test.ts | 5 +++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index e1c714d59..cc08769bd 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -263,8 +263,10 @@ export function checkAuthoredFlow( const workerSurface = invocation.warnUnresolvedAgentWorker === true ? agentWorkerDiagnostics(authoring) : []; + // A flow inspected past a missing helper mount is still a compiled spec the + // daemon must judge; skipping it would let a downgraded warning pass the gate. const daemon = invocation.daemonValidation === undefined ? undefined - : result.ok && flow !== undefined + : (result.ok || inspectOnly) && flow !== undefined ? validateSpecWithDaemon(flow, invocation.daemonValidation, invocation.daemonValidationDeps) : { validation: { mode: 'local' as const, reason: 'not_reached' as const }, diagnostics: [] }; if (daemon?.diagnostics.some(d => d.severity === 'refusal')) result.ok = false; diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 44065ca3e..da5d8d843 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -118,6 +118,13 @@ function boundLocally(node: AstNode, name: string, parents: WeakMap): boolean { // memberName also reads string-literal computed keys (Object['prototype']). if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) return true; + // Destructuring reads the same members: const { prototype: p } = Object. + if (node.type === 'Property' && parents.get(node)?.type === 'ObjectPattern') { + const key = node.key as AstNode | undefined; + const name = key?.type === 'Identifier' && node.computed !== true ? key.name + : key?.type === 'Literal' && typeof key.value === 'string' ? key.value : undefined; + if (name !== undefined && PROTOTYPE_MACHINERY.has(name)) return true; + } if (node.type === 'Identifier' && ['Reflect', 'Proxy', 'eval', 'Function'].includes(node.name!)) { const parent = parents.get(node); if (parent !== undefined && declares(parent, node)) return false; diff --git a/packages/sdk/tests/check-helper-communication.test.ts b/packages/sdk/tests/check-helper-communication.test.ts index 224ad8f7d..36f0ad069 100644 --- a/packages/sdk/tests/check-helper-communication.test.ts +++ b/packages/sdk/tests/check-helper-communication.test.ts @@ -76,3 +76,19 @@ it('authored TS helpers keep their provider-specific mock remedy', () => { message: 'f.linear requires a relayfile linear mount; direct-token transport is not implemented.' }]); expect(diagnostics[0]?.message).toContain('RELAYFLOWS_LINEAR_MOCK=1'); }); + +it('runs required daemon validation for a flow inspected past a missing helper mount', async () => { + const { checkAuthoredFlow } = await import('../src/cli/check.js'); + const dir = mkdtempSync(join(tmpdir(), 'helper-daemon-')); + directories.push(dir); + const cli = join(dir, 'codex'); + writeFileSync(cli, '#!/bin/sh\nexit 0\n'); + chmodSync(cli, 0o755); + const spec = { version: '0.1.0', cli, steps: [{ id: 'notify', slack: { post: { channel: '#c', text: 'hi' } } }] } as unknown as FlowSpec; + const run = vi.fn(() => ({ status: 2, stdout: JSON.stringify({ ok: false, error: { code: 'invalid_spec', message: 'daemon says no' } }), stderr: '' })); + const report = checkAuthoredFlow(spec, join(dir, 'flow.yaml'), { projectConfig: { directory: dir, models: [], executors: [] } }, + { warnUnresolvedHelperCredential: true, daemonValidation: 'required', daemonValidationDeps: { resolveBinary: () => '/stub/relayflowd', run } }).report; + expect(run).toHaveBeenCalled(); + expect(report.ok).toBe(false); + expect(report.validation?.reason).not.toBe('not_reached'); +}); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 7bc801bc1..573f374bf 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -298,3 +298,8 @@ it('a parameter named Function in one function does not hide new Function elsewh const result = await check(fixture('function wrap(Function) { return Function; } wrap(1); await new Function("n", "return n.appendBlock(\'p\', {})")(globalThis.helper);', '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('refuses prototype machinery reached through destructuring', async () => { + const body = 'let held; const { prototype: p } = Object; const { toPrimitive: t } = Symbol; p[t] = function () { held = this; return ""; }; const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From d1bb4620997771cfdb5a12acce3ee80fa80fe8e0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:04:34 -0700 Subject: [PATCH 36/43] fix(check): dynamic keys on prototype-bearing globals are unprovable Object[d], Symbol[t] and the like with a key built at runtime may name any prototype machinery, so a computed non-literal member on an unshadowed Object/Symbol/Reflect/Function/globalThis/Proxy now makes Notion appendBlock unprovable. Dynamic indexing of ordinary values stays allowed. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 9 +++++++++ packages/sdk/tests/check-notion-append.test.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index da5d8d843..fb2dd5507 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -118,6 +118,12 @@ function boundLocally(node: AstNode, name: string, parents: WeakMap): boolean { // memberName also reads string-literal computed keys (Object['prototype']). if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) return true; + // A key built at runtime on a prototype-bearing global (Object[d], Symbol[t]) + // may name any of that machinery. + if (node.type === 'MemberExpression' && memberName(node) === undefined) { + const object = node.object as AstNode | undefined; + if (object?.type === 'Identifier' && MACHINERY_GLOBALS.has(object.name!) && !boundLocally(object, object.name!, parents)) return true; + } // Destructuring reads the same members: const { prototype: p } = Object. if (node.type === 'Property' && parents.get(node)?.type === 'ObjectPattern') { const key = node.key as AstNode | undefined; @@ -138,6 +144,9 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo return false; } +/** Globals whose members include the prototype machinery. */ +const MACHINERY_GLOBALS = new Set(['Object', 'Symbol', 'Reflect', 'Function', 'globalThis', 'Proxy']); + /** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__']); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 573f374bf..b8178f065 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -303,3 +303,12 @@ it('refuses prototype machinery reached through destructuring', async () => { const result = await check(fixture(body, '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('refuses dynamic computed members on the prototype-bearing globals', async () => { + const body = 'let held; const d = "define" + "Property", p = "proto" + "type", t = "to" + "Primitive"; Object[d](Object[p], Symbol[t], { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('allows dynamic indexing of ordinary values', async () => { + const result = await check(fixture('const titles = ["a", "b"]; const i = Number("1"); await f.notion.createPage({ parent: "p", title: titles[i] });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); From ac06f686ca2759b0f98efd005577cd9b7f2bded0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:14:44 -0700 Subject: [PATCH 37/43] fix(check): destructuring a prototype-bearing global is unprovable Computed keys (const { [d]: define } = Object) and rest elements can pick out any machinery the literal-key check cannot name, so destructuring an unshadowed Object/Symbol/Reflect/Function/globalThis/Proxy at all now makes Notion appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 7 +++++++ packages/sdk/tests/check-notion-append.test.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index fb2dd5507..b7568a744 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -124,6 +124,13 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo const object = node.object as AstNode | undefined; if (object?.type === 'Identifier' && MACHINERY_GLOBALS.has(object.name!) && !boundLocally(object, object.name!, parents)) return true; } + // Destructuring a prototype-bearing global at all (computed keys, rest) + // can pick out any of its machinery. + if ((node.type === 'VariableDeclarator' && (node.id as AstNode).type === 'ObjectPattern') + || (node.type === 'AssignmentExpression' && (node.left as AstNode).type === 'ObjectPattern')) { + const source = (node.type === 'VariableDeclarator' ? node.init : node.right) as AstNode | undefined; + if (source?.type === 'Identifier' && MACHINERY_GLOBALS.has(source.name!) && !boundLocally(source, source.name!, parents)) return true; + } // Destructuring reads the same members: const { prototype: p } = Object. if (node.type === 'Property' && parents.get(node)?.type === 'ObjectPattern') { const key = node.key as AstNode | undefined; diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index b8178f065..339e7f0ab 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -312,3 +312,10 @@ it('allows dynamic indexing of ordinary values', async () => { const result = await check(fixture('const titles = ["a", "b"]; const i = Number("1"); await f.notion.createPage({ parent: "p", title: titles[i] });', '{ tools: { notion: true } },')); expect(result.exit).toBe(0); }); +it.each([ + ['computed keys', 'let held; const d = "define" + "Property", p = "proto" + "type", t = "to" + "Primitive"; const { [d]: define, [p]: proto } = Object; const { [t]: prim } = Symbol; define(proto, prim, { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'], + ['a rest element', 'const { ...all } = Object; await f.notion.createPage({ parent: "p", title: String(Object.keys(all).length) });'], +])('refuses destructuring %s from a prototype-bearing global', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From ec7b706e35a755fda299d94ee07d4914e6781a60 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:24:36 -0700 Subject: [PATCH 38/43] fix(check): a prototype-bearing global is safe only as a static non-machinery member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aliasing Object/Symbol/Reflect/Function/globalThis/Proxy (const O = Object) let runtime-keyed access reach the machinery under another name. Instead of chasing alias shapes, an unshadowed use of these globals is now accepted only as the object of a static member that is not prototype machinery (Object.keys, Symbol.iterator); any other use — alias, argument, destructuring, computed key — makes Notion appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 16 +++++++++++----- packages/sdk/tests/check-notion-append.test.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index b7568a744..afc3578a4 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -118,11 +118,17 @@ function boundLocally(node: AstNode, name: string, parents: WeakMap): boolean { // memberName also reads string-literal computed keys (Object['prototype']). if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) return true; - // A key built at runtime on a prototype-bearing global (Object[d], Symbol[t]) - // may name any of that machinery. - if (node.type === 'MemberExpression' && memberName(node) === undefined) { - const object = node.object as AstNode | undefined; - if (object?.type === 'Identifier' && MACHINERY_GLOBALS.has(object.name!) && !boundLocally(object, object.name!, parents)) return true; + // A prototype-bearing global is safe only as the object of a static, + // non-machinery member (Object.keys, Symbol.iterator). Aliasing it, passing + // it, destructuring it or indexing it with a runtime key can reach the + // machinery under another name. + if (node.type === 'Identifier' && MACHINERY_GLOBALS.has(node.name!)) { + const parent = parents.get(node); + if (parent !== undefined && declares(parent, node)) return false; + if (parent?.type === 'VariableDeclarator' && parent.id === node) return false; + if (boundLocally(node, node.name!, parents)) return false; + const member = parent?.type === 'MemberExpression' && parent.object === node ? memberName(parent) : undefined; + return member === undefined || PROTOTYPE_MACHINERY.has(member); } // Destructuring a prototype-bearing global at all (computed keys, rest) // can pick out any of its machinery. diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 339e7f0ab..d07ea9453 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -319,3 +319,12 @@ it.each([ const result = await check(fixture(body, '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('refuses an aliased prototype-bearing global used to reach machinery', async () => { + const body = 'let held; const O = Object, S = Symbol; const d = "define" + "Property", p = "proto" + "type", t = "to" + "Primitive"; O[d](O[p], S[t], { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('allows static non-machinery members of those globals', async () => { + const result = await check(fixture('const keys = Object.keys({ a: 1 }); const it = Symbol.iterator; await f.notion.createPage({ parent: "p", title: String(keys.length) + String(typeof it) });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); From f568c07cf6061500a435d686260810c91532b2ac Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:35:15 -0700 Subject: [PATCH 39/43] fix(check): a locally defined class or function prototype is not machinery Every member named prototype counted as helper prototype machinery, so a flow with class Local {}; Local.prototype.flag = true was refused although it can neither expose nor modify f.notion. X.prototype is now allowed when X is a class or function the body defines itself (declaration or const initializer) and never reassigns; other routes to a prototype (__proto__, getPrototypeOf, constructor.prototype, the globals) stay refused. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 44 ++++++++++++++++++- .../sdk/tests/check-notion-append.test.ts | 15 +++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index afc3578a4..75f1852fa 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -117,7 +117,12 @@ function boundLocally(node: AstNode, name: string, parents: WeakMap): boolean { // memberName also reads string-literal computed keys (Object['prototype']). - if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) return true; + if (node.type === 'MemberExpression' && PROTOTYPE_MACHINERY.has(memberName(node) ?? '')) { + // A class or function the body defines has its own prototype, which cannot + // reach the helper: Local.prototype.flag = true is not machinery. + const object = node.object as AstNode | undefined; + return !(memberName(node) === 'prototype' && object?.type === 'Identifier' && ownConstructor(object, parents)); + } // A prototype-bearing global is safe only as the object of a static, // non-machinery member (Object.keys, Symbol.iterator). Aliasing it, passing // it, destructuring it or indexing it with a runtime key can reach the @@ -157,6 +162,43 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo return false; } +/** Whether `node` names a class or function the body itself defines, in an enclosing scope. */ +function ownConstructor(node: AstNode, parents: WeakMap): boolean { + const name = node.name!; + // Class and function bindings are reassignable; one that is reassigned + // anywhere could hold any constructor. + let program: AstNode = node; + for (let at = parents.get(node); at !== undefined; at = parents.get(at)) program = at; + if (reassigns(program, name)) return false; + const constructs = (value: AstNode | undefined) => value?.type === 'ClassExpression' || value?.type === 'FunctionExpression'; + for (let scope = parents.get(node); scope !== undefined; scope = parents.get(scope)) { + const statements = scope.type === 'BlockStatement' || scope.type === 'Program' ? scope.body as AstNode[] : []; + for (const statement of statements) { + if ((statement.type === 'ClassDeclaration' || statement.type === 'FunctionDeclaration') + && (statement.id as AstNode | undefined)?.name === name) return true; + if (statement.type === 'VariableDeclaration' && statement.kind === 'const') { + for (const declarator of statement.declarations as AstNode[]) { + if ((declarator.id as AstNode).type === 'Identifier' && (declarator.id as AstNode).name === name) return constructs(declarator.init as AstNode | undefined); + } + } + } + if (scopeBindings(scope).has(name)) return false; // bound here to something else + } + return false; +} + +/** Whether any assignment or update anywhere under `node` targets the identifier `name`. */ +function reassigns(node: AstNode, name: string): boolean { + if ((node.type === 'AssignmentExpression' && (node.left as AstNode).type === 'Identifier' && (node.left as AstNode).name === name) + || (node.type === 'UpdateExpression' && (node.argument as AstNode).type === 'Identifier' && (node.argument as AstNode).name === name)) return true; + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = node[key]; + for (const entry of Array.isArray(child) ? child : [child]) if (isNode(entry) && reassigns(entry, name)) return true; + } + return false; +} + /** Globals whose members include the prototype machinery. */ const MACHINERY_GLOBALS = new Set(['Object', 'Symbol', 'Reflect', 'Function', 'globalThis', 'Proxy']); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index d07ea9453..fd5b8c29a 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -328,3 +328,18 @@ it('allows static non-machinery members of those globals', async () => { const result = await check(fixture('const keys = Object.keys({ a: 1 }); const it = Symbol.iterator; await f.notion.createPage({ parent: "p", title: String(keys.length) + String(typeof it) });', '{ tools: { notion: true } },')); expect(result.exit).toBe(0); }); +it.each([ + ['a local class', 'class Local {} Local.prototype.flag = true; await f.notion.createPage({ parent: "p", title: "t" });'], + ['a local function', 'function Thing() {} Thing.prototype.kind = "x"; const Other = class {}; Other.prototype.k = 1; await f.notion.createPage({ parent: "p", title: "t" });'], +])('allows the prototype of %s the body defines', async (_shape, body) => { + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); +it('still refuses a prototype reached through a constructor property', async () => { + const result = await check(fixture('const C = ({}).constructor; C.prototype.hook = 1; await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('refuses a local constructor name that is later reassigned', async () => { + const result = await check(fixture('class Local {} Local = ({}).constructor; Local.prototype.hook = 1; await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From 14bb42e2a0c9bd73c5377b6705585b25d107fb15 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:44:57 -0700 Subject: [PATCH 40/43] fix(check): globals reached through globalThis follow the bare-global rule globalThis.Object and globalThis.Symbol are the same globals by another route; extracting them (const O = globalThis.Object) let runtime-keyed access reach prototype machinery. A globalThis member naming a prototype-bearing global is now judged like the bare global: safe only as the object of a static, non-machinery member. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 16 ++++++++++++++-- packages/sdk/tests/check-notion-append.test.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 75f1852fa..bc1eecbb0 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -132,8 +132,10 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo if (parent !== undefined && declares(parent, node)) return false; if (parent?.type === 'VariableDeclarator' && parent.id === node) return false; if (boundLocally(node, node.name!, parents)) return false; - const member = parent?.type === 'MemberExpression' && parent.object === node ? memberName(parent) : undefined; - return member === undefined || PROTOTYPE_MACHINERY.has(member); + // globalThis.Object is Object by another route: judge it the same way. + if (node.name === 'globalThis' && parent?.type === 'MemberExpression' && parent.object === node + && MACHINERY_GLOBALS.has(memberName(parent) ?? '')) return onlyStaticSafeMember(parent, parents); + return onlyStaticSafeMember(node, parents) === true; } // Destructuring a prototype-bearing global at all (computed keys, rest) // can pick out any of its machinery. @@ -199,6 +201,16 @@ function reassigns(node: AstNode, name: string): boolean { return false; } +/** + * For a value that is a prototype-bearing global: true (machinery reached) + * unless it is only the object of a static, non-machinery member. + */ +function onlyStaticSafeMember(value: AstNode, parents: WeakMap): boolean { + const parent = parents.get(value); + const member = parent?.type === 'MemberExpression' && parent.object === value ? memberName(parent) : undefined; + return member === undefined || PROTOTYPE_MACHINERY.has(member); +} + /** Globals whose members include the prototype machinery. */ const MACHINERY_GLOBALS = new Set(['Object', 'Symbol', 'Reflect', 'Function', 'globalThis', 'Proxy']); diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index fd5b8c29a..0db718488 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -343,3 +343,12 @@ it('refuses a local constructor name that is later reassigned', async () => { const result = await check(fixture('class Local {} Local = ({}).constructor; Local.prototype.hook = 1; await f.notion.createPage({ parent: "p", title: "t" });', '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it('refuses prototype-bearing globals reached through globalThis', async () => { + const body = 'let held; const O = globalThis.Object, S = globalThis.Symbol; const d = "define" + "Property", p = "proto" + "type", t = "to" + "Primitive"; O[d](O[p], S[t], { value() { held = this; return ""; }, configurable: true }); const s = `${f.notion}`; await held.appendBlock("p", {});'; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('allows static non-machinery members reached through globalThis', async () => { + const result = await check(fixture('const n = globalThis.Object.keys({ a: 1 }).length; await f.notion.createPage({ parent: "p", title: String(n) });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); From aaac44894fe5268ae32861fe469aa5f5da0f6f0a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 06:54:48 -0700 Subject: [PATCH 41/43] fix(check): all global-object roots and their chains lead to the guarded globals Only globalThis, one level deep, was treated as a route to Object/Symbol/...; Node's global, window, self and nested chains (globalThis.globalThis.Object, globalThis.global.Symbol) were not. Global roots now follow root-to-root chains and judge a named prototype-bearing global like the bare one; any other use of a root (alias, computed key) makes Notion appendBlock unprovable. Ordinary root members (global.setTimeout) stay allowed. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 21 +++++++++++++------ .../sdk/tests/check-notion-append.test.ts | 13 ++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index bc1eecbb0..96579b025 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -127,15 +127,22 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo // non-machinery member (Object.keys, Symbol.iterator). Aliasing it, passing // it, destructuring it or indexing it with a runtime key can reach the // machinery under another name. - if (node.type === 'Identifier' && MACHINERY_GLOBALS.has(node.name!)) { + if (node.type === 'Identifier' && (MACHINERY_GLOBALS.has(node.name!) || GLOBAL_ROOTS.has(node.name!))) { const parent = parents.get(node); if (parent !== undefined && declares(parent, node)) return false; if (parent?.type === 'VariableDeclarator' && parent.id === node) return false; if (boundLocally(node, node.name!, parents)) return false; - // globalThis.Object is Object by another route: judge it the same way. - if (node.name === 'globalThis' && parent?.type === 'MemberExpression' && parent.object === node - && MACHINERY_GLOBALS.has(memberName(parent) ?? '')) return onlyStaticSafeMember(parent, parents); - return onlyStaticSafeMember(node, parents) === true; + if (!GLOBAL_ROOTS.has(node.name!)) return onlyStaticSafeMember(node, parents); + // A global root (globalThis, global, window, self) reaches every global: + // follow root-to-root chains, then judge a named prototype-bearing global + // like the bare one. Any other use of a root can reach them by any name. + let value: AstNode = node; + for (let up = parents.get(value); up?.type === 'MemberExpression' && up.object === value + && GLOBAL_ROOTS.has(memberName(up) ?? ''); up = parents.get(value)) value = up; + const up = parents.get(value); + const named = up?.type === 'MemberExpression' && up.object === value ? memberName(up) : undefined; + if (named !== undefined && MACHINERY_GLOBALS.has(named)) return onlyStaticSafeMember(up!, parents); + return named === undefined || PROTOTYPE_MACHINERY.has(named); } // Destructuring a prototype-bearing global at all (computed keys, rest) // can pick out any of its machinery. @@ -212,7 +219,9 @@ function onlyStaticSafeMember(value: AstNode, parents: WeakMap } /** Globals whose members include the prototype machinery. */ -const MACHINERY_GLOBALS = new Set(['Object', 'Symbol', 'Reflect', 'Function', 'globalThis', 'Proxy']); +const MACHINERY_GLOBALS = new Set(['Object', 'Symbol', 'Reflect', 'Function', 'Proxy']); +/** Names for the global object itself, through which every global is reachable. */ +const GLOBAL_ROOTS = new Set(['globalThis', 'global', 'window', 'self']); /** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 0db718488..bdc8f1703 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -352,3 +352,16 @@ it('allows static non-machinery members reached through globalThis', async () => const result = await check(fixture('const n = globalThis.Object.keys({ a: 1 }).length; await f.notion.createPage({ parent: "p", title: String(n) });', '{ tools: { notion: true } },')); expect(result.exit).toBe(0); }); +it.each([ + ["Node's global", 'const O = global.Object, S = global.Symbol;'], + ['a nested globalThis chain', 'const O = globalThis.globalThis.Object, S = globalThis.global.Symbol;'], + ['an aliased global root', 'const g = globalThis; const O = g.Object, S = g.Symbol;'], +])('refuses prototype-bearing globals reached through %s', async (_shape, prefix) => { + const body = `let held; ${prefix} const d = "define" + "Property", p = "proto" + "type", t = "to" + "Primitive"; O[d](O[p], S[t], { value() { held = this; return ""; }, configurable: true }); const s = \`\${f.notion}\`; await held.appendBlock("p", {});`; + const result = await check(fixture(body, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); +it('allows ordinary members of the global roots', async () => { + const result = await check(fixture('const t = typeof global.setTimeout + typeof globalThis.structuredClone; await f.notion.createPage({ parent: "p", title: t });', '{ tools: { notion: true } },')); + expect(result.exit).toBe(0); +}); From a96c88b775437e77de73589bf5cfabfbb542cbcf Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 07:04:38 -0700 Subject: [PATCH 42/43] fix(check): constructor chains reach Function and are prototype machinery Object.constructor, (() => {}).constructor and the like are the Function constructor, which compiles source text the scan never sees. Any member named constructor (and the legacy __lookupGetter__/__lookupSetter__) now makes Notion appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 5 ++++- packages/sdk/tests/check-notion-append.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 96579b025..22b7ca432 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -225,7 +225,10 @@ const GLOBAL_ROOTS = new Set(['globalThis', 'global', 'window', 'self']); /** Members through which a body can reach or rewrite the prototypes every helper inherits from. */ const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', - 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__']); + 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__', + '__lookupGetter__', '__lookupSetter__', + // Any function's (or global's) constructor chain reaches Function, which compiles source text. + 'constructor']); /** * A use that cannot hand the value on: a discarded value or a read, and — diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index bdc8f1703..19cdbf375 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -365,3 +365,10 @@ it('allows ordinary members of the global roots', async () => { const result = await check(fixture('const t = typeof global.setTimeout + typeof globalThis.structuredClone; await f.notion.createPage({ parent: "p", title: t });', '{ tools: { notion: true } },')); expect(result.exit).toBe(0); }); +it.each([ + ['Object.constructor', 'Object.constructor("globalThis.x = 1")();'], + ['a function constructor', '(() => {}).constructor("globalThis.x = 1")();'], +])('refuses an indirect Function constructor through %s', async (_shape, prefix) => { + const result = await check(fixture(`${prefix} await f.notion.createPage({ parent: "p", title: "t" });`, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +}); From 2b03dd483f526cdde43176479d65e488e555e3a0 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 8 Oct 2026 07:14:54 -0700 Subject: [PATCH 43/43] fix(check): any unshadowed use of eval is unprovable (0, eval)(src), const run = eval and globalThis.eval(src) all run source text, but only a direct eval(...) call was refused. Any unshadowed eval identifier, and any member named eval (globalThis.eval), now makes Notion appendBlock unprovable. Co-Authored-By: Claude Opus 5.5 (1M context) Session-Id: b8262468-ea26-4a6e-bee9-d193832e77dc --- packages/sdk/src/helper-operation-use.ts | 9 +++++---- packages/sdk/tests/check-notion-append.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/helper-operation-use.ts b/packages/sdk/src/helper-operation-use.ts index 22b7ca432..824ca0733 100644 --- a/packages/sdk/src/helper-operation-use.ts +++ b/packages/sdk/src/helper-operation-use.ts @@ -164,8 +164,8 @@ function reachesMachinery(node: AstNode, parents: WeakMap): bo if (parent?.type === 'VariableDeclarator' && parent.id === node) return false; // eval and Function matter only when called; a use resolving to a local // binding (scope by scope, not body-wide) is not the global. - if ((node.name === 'eval' || node.name === 'Function') - && !((parent?.type === 'CallExpression' || parent?.type === 'NewExpression') && parent.callee === node)) return false; + // Any use of eval (indirect call, alias) runs source text the scan never + // sees; Function is judged with the other prototype-bearing globals. return !boundLocally(node, node.name!, parents); } return false; @@ -227,8 +227,9 @@ const GLOBAL_ROOTS = new Set(['globalThis', 'global', 'window', 'self']); const PROTOTYPE_MACHINERY = new Set(['prototype', '__proto__', 'defineProperty', 'defineProperties', 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', - // Any function's (or global's) constructor chain reaches Function, which compiles source text. - 'constructor']); + // Any function's (or global's) constructor chain reaches Function, which compiles source text, + // and eval (globalThis.eval, ...) runs it directly. + 'constructor', 'eval']); /** * A use that cannot hand the value on: a discarded value or a read, and — diff --git a/packages/sdk/tests/check-notion-append.test.ts b/packages/sdk/tests/check-notion-append.test.ts index 19cdbf375..2d45708b7 100644 --- a/packages/sdk/tests/check-notion-append.test.ts +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -372,3 +372,11 @@ it.each([ const result = await check(fixture(`${prefix} await f.notion.createPage({ parent: "p", title: "t" });`, '{ tools: { notion: true } },')); expect(result.exit).toBe(2); }); +it.each([ + ['an indirect call', '(0, eval)("globalThis.x = 1");'], + ['an alias', 'const run = eval; run("globalThis.x = 1");'], + ['a global root', 'globalThis.eval("globalThis.x = 1");'], +])('refuses eval used through %s', async (_shape, prefix) => { + const result = await check(fixture(`${prefix} await f.notion.createPage({ parent: "p", title: "t" });`, '{ tools: { notion: true } },')); + expect(result.exit).toBe(2); +});