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 9db6a32e5..823eac82e 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -1414,6 +1414,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/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/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-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-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/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-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/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 56dee05b3..9aa2b66b4 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -324,7 +324,7 @@ export async function runCli( // Authored bodies are only lowered at run time, so their acceptance is unproven. const checked = /\.(?:[cm]?[jt]s)$/.test(parsed.value) ? await checkAuthoredFlowComposed(parsed.value) - : checkFlow(parsed.value, { warnUnresolvedAgentWorker: true, daemonValidation: parsed.daemonValidation }); + : checkFlow(parsed.value, { warnUnresolvedAgentWorker: true, warnUnresolvedHelperCredential: true, daemonValidation: parsed.daemonValidation }); if (checked.report.validation === undefined) { const unavailable = unavailableValidation(parsed.daemonValidation, checked.report.ok ? (parsed.daemonValidation === 'off' ? 'disabled' : 'authored_body') : 'not_reached'); @@ -485,18 +485,32 @@ 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. 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) + ? await checkAuthoredTriggers(path, invocation) : undefined; const triggerDiagnostics = triggers?.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: { @@ -507,7 +521,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, }, }; @@ -1116,15 +1130,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> = { @@ -1148,8 +1159,8 @@ function emitCheckReport(report: CheckReport, json: boolean, io: CliIo, explainW io.stdout(JSON.stringify(report)); return; } - const deferred = report.diagnostics.filter(isWorkerSurfaceWarning); - emitDiagnostics(report.diagnostics.filter((diagnostic) => !isWorkerSurfaceWarning(diagnostic)), io, foldOptions); + const deferred = report.diagnostics.filter(isRequiresFootnote); + emitDiagnostics(report.diagnostics.filter((diagnostic) => !isRequiresFootnote(diagnostic)), io, foldOptions); 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..5cfac853b --- /dev/null +++ b/packages/sdk/src/cli/check-helper-surface.ts @@ -0,0 +1,43 @@ +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); + // 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)` + + (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-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 b49f2b6de..cc08769bd 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -6,6 +6,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'; @@ -127,6 +128,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; daemonValidation?: DaemonValidationMode; daemonValidationDeps?: DaemonValidationDeps; /** Environment used only by provider executable/auth/model probes. */ @@ -183,7 +186,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; } @@ -224,7 +230,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, @@ -250,20 +263,25 @@ 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; + const diagnostics = invocation.warnUnresolvedHelperCredential === true + ? helperCredentialDiagnostics(result.diagnostics).diagnostics : result.diagnostics; return { report: { ...(daemon === undefined ? {} : { validation: daemon.validation }), - ok: result.ok, + ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal') + && !(daemon?.diagnostics.some(diagnostic => diagnostic.severity === 'refusal') ?? false), path, ...(config.path !== undefined ? { projectConfigPath: config.path } : {}), gates: result.gates, resolutions: result.resolutions, - diagnostics: [...result.diagnostics, ...workerSurface, ...(daemon?.diagnostics ?? [])], + diagnostics: [...diagnostics, ...workerSurface, ...(daemon?.diagnostics ?? [])], requirements: safeRequirements(authoring, config.cli), }, ...(result.ok && flow !== undefined ? { flow } : {}), diff --git a/packages/sdk/src/cli/cloud-connect-cli.ts b/packages/sdk/src/cli/cloud-connect-cli.ts index 03d18083f..818d2f64a 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); } @@ -77,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/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index fbc06490f..d84e98785 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -174,7 +174,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', 'daemon_unvalidated', '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', 'daemon_unvalidated', '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-operation-use.ts b/packages/sdk/src/helper-operation-use.ts new file mode 100644 index 000000000..824ca0733 --- /dev/null +++ b/packages/sdk/src/helper-operation-use.ts @@ -0,0 +1,285 @@ +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 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]; + 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; }; + // 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. + 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'); + 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) { + // Calling it is the operation; a read (typeof, comparison) is not; + // anything else hands the function on. + // 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 (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'); + return; + } + // The whole context used as a value (`const { notion } = f`, `use(f)`). + if (!readOrCall(node, parents)) mark('unprovable'); + }); + return result; +} + +/** 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(); + 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); + } + } + 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): boolean { + // memberName also reads string-literal computed keys (Object['prototype']). + 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 + // machinery under another 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; + 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. + 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; + 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; + 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. + // 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; +} + +/** 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; +} + +/** + * 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', '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', + 'setPrototypeOf', 'getPrototypeOf', 'toPrimitive', '__defineGetter__', '__defineSetter__', + '__lookupGetter__', '__lookupSetter__', + // 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 — + * 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. + 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); + } + if (parent === undefined) return false; + 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 — 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') { 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 true; +} + +/** 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 112192c2a..1d1921949 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 { 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,6 +19,16 @@ 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); + // 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. + // 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) { const used = definition.header?.tools?.[namespace] === true || referenced.has(namespace); @@ -32,9 +43,13 @@ 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 && 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, 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 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/src/helper-reference.ts b/packages/sdk/src/helper-reference.ts index 37749e614..5436669ad 100644 --- a/packages/sdk/src/helper-reference.ts +++ b/packages/sdk/src/helper-reference.ts @@ -39,7 +39,7 @@ export function helperNamespacesUsed(body: string, root: string): ReadonlySet { return used; } -type AstNode = { +export type AstNode = { type: string; end?: number; name?: string; @@ -120,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, @@ -165,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'; @@ -250,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/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-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-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); +}); 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..36f0ad069 --- /dev/null +++ b/packages/sdk/tests/check-helper-communication.test.ts @@ -0,0 +1,94 @@ +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'); +}); + +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-helper-surface.test.ts b/packages/sdk/tests/check-helper-surface.test.ts new file mode 100644 index 000000000..f0556eb25 --- /dev/null +++ b/packages/sdk/tests/check-helper-surface.test.ts @@ -0,0 +1,156 @@ +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 { 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'; + +useHelperSurfaceEnv(); + +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('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); + 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('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]'); +}); + +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"' }); + } +}); + +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' })); +}); 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..2d45708b7 --- /dev/null +++ b/packages/sdk/tests/check-notion-append.test.ts @@ -0,0 +1,382 @@ +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); +}); +it.each([ + ['hasOwnProperty', 'if (f.notion.hasOwnProperty("createPage")) await f.notion.createPage({ parent: "p", title: "t" });'], + ['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.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 } },')); + 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]'); +}); +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); +}); +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); +}); +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]'); +}); +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]'); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); +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); +}); 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 }; +}