@xdelivered/emberflow 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
package/server/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync, watch } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join, resolve } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -9,6 +10,7 @@ import {
9
10
  mergeMocks,
10
11
  validateFlow,
11
12
  verifyArtifact,
13
+ type LogLine,
12
14
  type ScenarioDefinition,
13
15
  type WorkflowDefinition,
14
16
  } from '../src/engine';
@@ -32,8 +34,9 @@ import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './pr
32
34
  import { nodesPayload } from './nodesPayload';
33
35
  import { openBrowser } from './openBrowser';
34
36
  import { AgentRunManager, AgentStartError } from './agents/runManager';
37
+ import { isGitRepo } from './agents/gitScope';
35
38
  import { isMountablePath } from './pathGuard';
36
- import { detectAgents } from './agents/detect';
39
+ import { detectAgentsCached } from './agents/detect';
37
40
  import type { AgentEvent } from './agents/types';
38
41
  import type { AgentIntent } from './agents/prompt';
39
42
  import { ExpressAdapter } from './runtime/expressAdapter';
@@ -44,6 +47,7 @@ import { isPathWithin } from './pathSafety';
44
47
  import { attachCredential } from './authAttach';
45
48
  import { redactSecrets } from './redact';
46
49
  import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner';
50
+ import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck';
47
51
  import { diagnoseOperation } from '../src/engine/diagnostics';
48
52
  import { seedParamDefaults } from './normalizeFlow';
49
53
 
@@ -160,6 +164,27 @@ const agentRuns = new AgentRunManager(
160
164
  // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
161
165
  // earlier this session primes later prompts. Malformed → null (agent guesses).
162
166
  () => loadInfrastructure(project ? project.root : projectDir),
167
+ // Runner-verified setup snapshot for guided-setup prompts: the same facts
168
+ // /setup-status reports, computed fresh at run start, so the agent starts
169
+ // from KNOWN state instead of probing for it.
170
+ () => {
171
+ const root = project ? project.root : process.cwd();
172
+ let envs = environmentsFile;
173
+ try {
174
+ envs = loadEnvironments(root);
175
+ } catch {
176
+ /* keep last good */
177
+ }
178
+ const ops = apiStore.list();
179
+ return {
180
+ gitRepo: isGitRepo(root),
181
+ skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
182
+ environmentsConfigured: envs.configured,
183
+ infrastructurePresent: readInfrastructure() !== null,
184
+ opCount: ops.length,
185
+ onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
186
+ };
187
+ },
163
188
  );
164
189
 
165
190
  // Hot-reload agent-authored nodes: watch the project's config and rebuild the
@@ -717,6 +742,8 @@ api.post('/runs', async (req: Request, res: Response) => {
717
742
  input: effectiveInput,
718
743
  mockRun,
719
744
  mocks: effectiveMocks,
745
+ // Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
746
+ stepped: mode === 'step',
720
747
  }));
721
748
  } catch (err) {
722
749
  res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
@@ -731,23 +758,101 @@ api.post('/runs', async (req: Request, res: Response) => {
731
758
  res.status(201).json({ runId });
732
759
  });
733
760
 
761
+ // Run a single node in isolation (studio's "Run node" affordance). Executes the
762
+ // node's implementation in-process against the resolved environment's
763
+ // secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
764
+ // output through the same choke point runs use. Unknown type → 404. The studio
765
+ // bundles no implementations, so this is the ONLY path to a lone node run.
766
+ api.post('/node-run', async (req: Request, res: Response) => {
767
+ const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
768
+ if (typeof type !== 'string' || type.length === 0) {
769
+ res.status(400).json({ error: 'Body must include a node "type"' });
770
+ return;
771
+ }
772
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
773
+ res.status(400).json({ error: 'input must be a plain object' });
774
+ return;
775
+ }
776
+ if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
777
+ res.status(400).json({ error: 'config must be a plain object' });
778
+ return;
779
+ }
780
+ if (environment !== undefined && typeof environment !== 'string') {
781
+ res.status(400).json({ error: 'environment must be a string' });
782
+ return;
783
+ }
784
+ if (safeMode !== undefined && typeof safeMode !== 'boolean') {
785
+ res.status(400).json({ error: 'safeMode must be a boolean' });
786
+ return;
787
+ }
788
+ if (!runs.executionRegistry.has(type)) {
789
+ res.status(404).json({ error: `Unknown node type: ${type}` });
790
+ return;
791
+ }
792
+ const environmentName = environment ?? environmentsFile.defaultEnvironment;
793
+ const environmentDef = environmentsFile.environments[environmentName];
794
+ if (!environmentDef) {
795
+ res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
796
+ return;
797
+ }
798
+ const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
799
+ if (!safety.ok) {
800
+ res.status(400).json({ error: safety.error });
801
+ return;
802
+ }
803
+
804
+ const { implementation } = runs.executionRegistry.get(type);
805
+ const runId = `node-run-${randomUUID().slice(0, 8)}`;
806
+ const logs: LogLine[] = [];
807
+ const log = (level: LogLine['level'], message: string): void => {
808
+ logs.push({ timestamp: new Date().toISOString(), level, runId, message });
809
+ };
810
+ const nodeInput = (input ?? {}) as Record<string, unknown>;
811
+
812
+ let result: { output?: unknown; error?: string; logs: LogLine[] };
813
+ try {
814
+ const output = await implementation({
815
+ input: nodeInput,
816
+ config: (config ?? {}) as Record<string, unknown>,
817
+ secrets: environmentDef.secrets,
818
+ vars: environmentDef.vars,
819
+ environment: environmentName,
820
+ safeMode: safety.safeMode,
821
+ runInput: nodeInput,
822
+ log,
823
+ // A node run in isolation has no flow context to resolve child workflows.
824
+ runSubflow: async () => ({
825
+ status: 'failed',
826
+ error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
827
+ }),
828
+ });
829
+ result = { output, logs };
830
+ } catch (err) {
831
+ result = { error: err instanceof Error ? err.message : String(err), logs };
832
+ }
833
+ // Redact secret values from the output AND captured logs before responding.
834
+ res.json(redactSecrets(result, environmentDef.secrets));
835
+ });
836
+
734
837
  api.post('/runs/:id/step', async (req, res) => {
735
- const handle = runs.get(req.params.id);
736
- if (!handle) {
838
+ // Drill-aware composite step: stepping INTO a Subflow reports
839
+ // `entered: { workflowId, nodeId }`, completing the child reports
840
+ // `exited: true` (see RunRegistry.step). Plain steps report just `done`.
841
+ const result = await runs.step(req.params.id);
842
+ if (!result) {
737
843
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
738
844
  return;
739
845
  }
740
- const more = await handle.step();
741
- res.json({ done: !more });
846
+ res.json(result);
742
847
  });
743
848
 
744
849
  api.post('/runs/:id/cancel', (req, res) => {
745
- const handle = runs.get(req.params.id);
746
- if (!handle) {
850
+ // Drill-aware: a stepped run drilled into subflows also cancels every
851
+ // stacked child and unblocks the parent executions (see RunRegistry.cancel).
852
+ if (!runs.cancel(req.params.id)) {
747
853
  res.status(404).json({ error: `Unknown run: ${req.params.id}` });
748
854
  return;
749
855
  }
750
- handle.cancel();
751
856
  res.status(204).end();
752
857
  });
753
858
 
@@ -773,7 +878,7 @@ api.get('/runs/:id/events', (req, res) => {
773
878
  });
774
879
 
775
880
  api.get('/agent/available', (_req: Request, res: Response) => {
776
- res.json({ agents: detectAgents() });
881
+ res.json({ agents: detectAgentsCached() });
777
882
  });
778
883
 
779
884
  // First-run onboarding aggregate for the Welcome checklist (studio's
@@ -796,7 +901,10 @@ api.get('/setup-status', (_req: Request, res: Response) => {
796
901
  // A pristine project ships exactly the single `default/hello` example op.
797
902
  const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
798
903
  res.json({
799
- agents: detectAgents(),
904
+ agents: detectAgentsCached(),
905
+ // Agent features snapshot changes with git (server/agents/runManager.ts);
906
+ // the checklist gates them on this. Same notion gitScope's isGitRepo uses.
907
+ git: { repo: isGitRepo(root) },
800
908
  environments: {
801
909
  configured: envs.configured,
802
910
  // The synthesized "local" fallback is "no environments yet" — report 0.
@@ -826,6 +934,53 @@ function skillInstalled(root: string, harness: 'claude' | 'codex'): boolean {
826
934
  );
827
935
  }
828
936
 
937
+ // Package update notifier: the studio asks once per page load; checkForUpdate
938
+ // caches registry hits for an hour and fails silent — an unreachable registry
939
+ // (or a pre-publish package) just reports "no update".
940
+ api.get('/update-status', async (_req: Request, res: Response) => {
941
+ const current = ownVersion() ?? '0.0.0';
942
+ const check = await checkForUpdate(current);
943
+ if (!check) {
944
+ res.json({ current, updateAvailable: false });
945
+ return;
946
+ }
947
+ res.json(check);
948
+ });
949
+
950
+ // One-click updater: npm-installs the latest package into the consumer
951
+ // project. Single-flight — a second click while npm runs gets a 409, not a
952
+ // second npm process fighting over node_modules.
953
+ let updateInFlight = false;
954
+ api.post('/update', async (_req: Request, res: Response) => {
955
+ if (!project) {
956
+ res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
957
+ return;
958
+ }
959
+ if (isLinkedInstall(project.root)) {
960
+ res.status(400).json({
961
+ ok: false,
962
+ error:
963
+ 'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
964
+ });
965
+ return;
966
+ }
967
+ if (updateInFlight) {
968
+ res.status(409).json({ ok: false, error: 'update already running' });
969
+ return;
970
+ }
971
+ updateInFlight = true;
972
+ try {
973
+ const result = await runNpmInstall(project.root);
974
+ if (result.ok) {
975
+ res.json({ ok: true, restartRequired: true });
976
+ } else {
977
+ res.status(500).json({ ok: false, error: result.error });
978
+ }
979
+ } finally {
980
+ updateInFlight = false;
981
+ }
982
+ });
983
+
829
984
  /** The /setup-status `infrastructure` field: presence + a shallow summary. */
830
985
  function infraStatus(): { present: boolean; scannedAt?: string; itemCount?: number } {
831
986
  const manifest = readInfrastructure();
@@ -855,11 +1010,12 @@ api.post('/agent', (req: Request, res: Response) => {
855
1010
  intent.action !== 'setup-auth' &&
856
1011
  intent.action !== 'setup-environments' &&
857
1012
  intent.action !== 'scout-infrastructure' &&
1013
+ intent.action !== 'guided-setup' &&
858
1014
  intent.action !== 'cover-operation' &&
859
1015
  intent.action !== 'ask'
860
1016
  ) {
861
1017
  res.status(400).json({
862
- error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, cover-operation, ask.`,
1018
+ error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, guided-setup, cover-operation, ask.`,
863
1019
  });
864
1020
  return;
865
1021
  }
@@ -873,7 +1029,11 @@ api.post('/agent', (req: Request, res: Response) => {
873
1029
  res.status(400).json({ error: 'Body must include intent: { action: "setup-auth", environment, instruction }' });
874
1030
  return;
875
1031
  }
876
- } else if (intent.action === 'setup-environments' || intent.action === 'scout-infrastructure') {
1032
+ } else if (
1033
+ intent.action === 'setup-environments' ||
1034
+ intent.action === 'scout-infrastructure' ||
1035
+ intent.action === 'guided-setup'
1036
+ ) {
877
1037
  // instruction alone is required, already validated above; no flowId/environment needed.
878
1038
  } else if (intent.action === 'ask') {
879
1039
  if (intent.flowId !== undefined && typeof intent.flowId !== 'string') {
@@ -975,7 +1135,7 @@ const operationRunEnv = {
975
1135
  // operation whose http.path collides with one of these would silently never
976
1136
  // be reached (the internal route, registered first, always wins) — guarded
977
1137
  // against below rather than left as a silent trap.
978
- const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
1138
+ const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
979
1139
 
980
1140
  function collidesWithReservedPath(path: string): boolean {
981
1141
  return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
@@ -1097,6 +1257,22 @@ if (serveStudio) {
1097
1257
  }
1098
1258
  }
1099
1259
 
1260
+ // Agent CLIs and node executions must not outlive the runner: agents are
1261
+ // spawned detached (their own process group), so without this a killed runner
1262
+ // leaves an orphaned agent editing the project — and possibly writing files
1263
+ // long after the studio is gone (observed in the wild). Cancel everything,
1264
+ // then exit.
1265
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
1266
+ process.on(signal, () => {
1267
+ try {
1268
+ agentRuns.shutdown();
1269
+ runs.shutdown();
1270
+ } finally {
1271
+ process.exit(0);
1272
+ }
1273
+ });
1274
+ }
1275
+
1100
1276
  app.listen(PORT, HOST, () => {
1101
1277
  console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
1102
1278
  if (serveStudio) {
@@ -0,0 +1,189 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ // Verifies POST /node-run: the studio's "Run node" affordance runs a single
8
+ // node in-process on the runner (execution moved server-side; the studio bundles
9
+ // no node implementations). Boots the actual runner subprocess against a scratch
10
+ // project dir, mirroring runsAuth.test.ts / apiMount.test.ts.
11
+
12
+ let proc: ChildProcess;
13
+ const PORT = 8155;
14
+ const base = `http://127.0.0.1:${PORT}`;
15
+ let projectDir: string;
16
+
17
+ async function waitHealthy(url: string, tries = 40): Promise<void> {
18
+ for (let i = 0; i < tries; i++) {
19
+ try {
20
+ if ((await fetch(url)).ok) return;
21
+ } catch {
22
+ /* not up yet */
23
+ }
24
+ await new Promise((r) => setTimeout(r, 150));
25
+ }
26
+ throw new Error('runner did not become healthy');
27
+ }
28
+
29
+ function bootRunner(port: number, env: Record<string, string | undefined>): ChildProcess {
30
+ return spawn('npx', ['tsx', 'server/index.ts'], {
31
+ env: { ...process.env, EMBERFLOW_RUNNER_PORT: String(port), ...env },
32
+ stdio: 'ignore',
33
+ });
34
+ }
35
+
36
+ async function nodeRun(body: unknown): Promise<{ status: number; json: any }> {
37
+ const res = await fetch(`${base}/api/node-run`, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+ return { status: res.status, json: await res.json().catch(() => undefined) };
43
+ }
44
+
45
+ // Fixture secret value — looks like a real credential to redaction logic
46
+ // (>= 6 chars) but is clearly a test fixture, never a live secret.
47
+ const FIXTURE_SECRET = 'fixture-secret-123';
48
+
49
+ beforeAll(async () => {
50
+ projectDir = mkdtempSync(join(tmpdir(), 'noderun-'));
51
+ mkdirSync(join(projectDir, 'emberflow', 'apis', 'default'), { recursive: true });
52
+ writeFileSync(
53
+ join(projectDir, 'emberflow.config.mjs'),
54
+ `export default {
55
+ registerNodes(registry) {
56
+ // Embeds a secret in its output AND in a log line — exercises redaction
57
+ // across both response fields.
58
+ registry.register(
59
+ { type: 'EchoSecret', label: 'Echo Secret', traceKind: 'pure', inputSchema: { fields: [] } },
60
+ async (ctx) => {
61
+ ctx.log('info', \`fetched key \${ctx.secrets.API_KEY}\`);
62
+ return { message: \`key is \${ctx.secrets.API_KEY}\` };
63
+ },
64
+ );
65
+ // Throws with the secret embedded in the error message — exercises
66
+ // redaction on the error path.
67
+ registry.register(
68
+ { type: 'ThrowSecret', label: 'Throw Secret', traceKind: 'pure', inputSchema: { fields: [] } },
69
+ async (ctx) => {
70
+ throw new Error(\`boom \${ctx.secrets.API_KEY}\`);
71
+ },
72
+ );
73
+ // Echoes ctx.safeMode so tests can observe the resolved safety mode.
74
+ registry.register(
75
+ { type: 'EchoSafeMode', label: 'Echo Safe Mode', traceKind: 'pure', inputSchema: { fields: [] } },
76
+ async (ctx) => ({ safeMode: ctx.safeMode }),
77
+ );
78
+ },
79
+ };\n`,
80
+ );
81
+ writeFileSync(
82
+ join(projectDir, 'emberflow.environments.json'),
83
+ JSON.stringify({
84
+ defaultEnvironment: 'local',
85
+ environments: {
86
+ local: { vars: {}, secrets: {} },
87
+ leaky: { vars: {}, secrets: { API_KEY: FIXTURE_SECRET } },
88
+ prod: { protected: true, vars: {}, secrets: {} },
89
+ },
90
+ }),
91
+ );
92
+
93
+ proc = bootRunner(PORT, { EMBERFLOW_PROJECT: projectDir });
94
+ await waitHealthy(`${base}/healthz`);
95
+ }, 20_000);
96
+
97
+ afterAll(() => {
98
+ proc?.kill();
99
+ if (projectDir) rmSync(projectDir, { recursive: true, force: true });
100
+ });
101
+
102
+ describe('POST /node-run', () => {
103
+ it('runs a single built-in node in-process and returns its output + logs', async () => {
104
+ // CheckPlan is a pure built-in: input.user.plan -> { plan, features } + a log.
105
+ const { status, json } = await nodeRun({
106
+ type: 'CheckPlan',
107
+ input: { user: { plan: 'pro' } },
108
+ });
109
+ expect(status).toBe(200);
110
+ expect(json.output).toEqual({ plan: 'pro', features: ['sso', 'audit-log', 'priority-support'] });
111
+ expect(json.error).toBeUndefined();
112
+ expect(json.logs.some((l: { message: string }) => l.message.includes('pro'))).toBe(true);
113
+ });
114
+
115
+ it('404s for an unknown node type', async () => {
116
+ const { status, json } = await nodeRun({ type: 'NoSuchNode', input: {} });
117
+ expect(status).toBe(404);
118
+ expect(json.error).toContain('Unknown node type');
119
+ });
120
+
121
+ it('400s when the body omits a node type', async () => {
122
+ const { status } = await nodeRun({ input: {} });
123
+ expect(status).toBe(400);
124
+ });
125
+
126
+ it('400s for an unknown environment', async () => {
127
+ const { status, json } = await nodeRun({
128
+ type: 'CheckPlan',
129
+ input: { user: {} },
130
+ environment: 'ghost',
131
+ });
132
+ expect(status).toBe(400);
133
+ expect(json.error).toContain('Unknown environment');
134
+ });
135
+ });
136
+
137
+ describe('POST /node-run redacts secrets at the boundary', () => {
138
+ it('redacts a secret value embedded in the output and logs, never leaking the raw value', async () => {
139
+ const { status, json } = await nodeRun({ type: 'EchoSecret', input: {}, environment: 'leaky' });
140
+ expect(status).toBe(200);
141
+ expect(json.error).toBeUndefined();
142
+
143
+ expect(json.output.message).toContain('«secret:API_KEY»');
144
+ expect(json.output.message).not.toContain(FIXTURE_SECRET);
145
+
146
+ const logMessages = json.logs.map((l: { message: string }) => l.message).join('\n');
147
+ expect(logMessages).toContain('«secret:API_KEY»');
148
+ expect(logMessages).not.toContain(FIXTURE_SECRET);
149
+ });
150
+
151
+ it('redacts a secret value embedded in a thrown error message', async () => {
152
+ const { status, json } = await nodeRun({ type: 'ThrowSecret', input: {}, environment: 'leaky' });
153
+ expect(status).toBe(200);
154
+ expect(json.output).toBeUndefined();
155
+ expect(json.error).toContain('«secret:API_KEY»');
156
+ expect(json.error).not.toContain(FIXTURE_SECRET);
157
+ });
158
+ });
159
+
160
+ describe('POST /node-run gates unsafe runs on protected environments', () => {
161
+ it('400s an explicit safeMode:false on a protected environment without a matching confirm', async () => {
162
+ const { status, json } = await nodeRun({
163
+ type: 'EchoSafeMode',
164
+ input: {},
165
+ environment: 'prod',
166
+ safeMode: false,
167
+ });
168
+ expect(status).toBe(400);
169
+ expect(json.error).toContain("unsafe run on protected environment 'prod' requires confirm");
170
+ });
171
+
172
+ it('accepts safeMode:false on a protected environment with the matching confirm, and the node observes it', async () => {
173
+ const { status, json } = await nodeRun({
174
+ type: 'EchoSafeMode',
175
+ input: {},
176
+ environment: 'prod',
177
+ safeMode: false,
178
+ confirm: 'prod',
179
+ });
180
+ expect(status).toBe(200);
181
+ expect(json.output).toEqual({ safeMode: false });
182
+ });
183
+
184
+ it('defaults to safeMode:true on a protected environment when unspecified', async () => {
185
+ const { status, json } = await nodeRun({ type: 'EchoSafeMode', input: {}, environment: 'prod' });
186
+ expect(status).toBe(200);
187
+ expect(json.output).toEqual({ safeMode: true });
188
+ });
189
+ });