@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.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +185 -14
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/package.json +27 -6
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +189 -13
- package/server/nodeRun.test.ts +189 -0
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +383 -312
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-CAIDjNhv.css +1 -0
- package/studio-dist/assets/index-CGwEx82J.js +65 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-model-process/SKILL.md +82 -9
- package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- 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 {
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
741
|
-
res.json({ done: !more });
|
|
846
|
+
res.json(result);
|
|
742
847
|
});
|
|
743
848
|
|
|
744
849
|
api.post('/runs/:id/cancel', (req, res) => {
|
|
745
|
-
|
|
746
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
+
});
|