Skip to content

Commit ba674ca

Browse files
alex-strukclaude
andcommitted
Address PR #230 review: infra, security, cache correctness, editor bugs, cleanup
Works through the PR #230 review of the visual workflow builder. All bug and blocker findings across sections 1-6 are fixed with tests; two items (artifact split, generic list editor) were deliberately deferred/declined with documented rationale. Infra: wire deno-runner env into worker + backend pods; build+push the deno-runner image (CI matrix, oc-build-push.sh, per-instance overlay); run the graph-workflow jest suite in CI; add linux-arm64 native-binary pins; document the deno-runner NetworkPolicy DNS rationale; fail fast on missing PLATFORM_API_KEY. Security/tenancy: agent workflow tools assert the workflow's group (closes cross-tenant read+write); getNodeStatuses scopes runId to the lineage; deleteDynamicNode slug bounded+encoded; conversation resume rejects group mismatch. Engine/cache: leaf-scoped cache snapshot + deep-merge restore (no sibling clobber); best-effort source-node write; P2002 dead-path made best-effort; honor DynamicNodeVersion.deterministic; version-pin resolution for name-referenced children; validator accepts source-produced ctx keys; Map fan-out propagates workflowLineageId; getInputCtx fail-safe 404 on retention + fail-closed cross-lineage. Frontend: NodePicker typeable on large graphs; Auto-arrange moves nodes; agent create->edit route; agent chat no longer stomps unsaved edits; chat-queued uploads fire; run-drawer duplicate run removed; cache-evicted alert gated on replay; renameCtxKey rewrites all refs; switch-case edge labels, error-fallback edges, fresh-Try polling, errorPolicy handle, and literal-input type-flip fixed. Housekeeping: remove the unimplemented Join "Any" strategy; gate the dev-only form-preview route behind import.meta.env.DEV; document the HumanGate continue-path test skips (test-env hang, not a bug); drop the last explicit any (deno-runner harness); delete the dead subprocess-harness placeholder. Cleanup: move benchmark-OCR replay knowledge to an ActivityCatalogEntry metadata field (engine stays workload-generic); add builderFetch (shared auth + cookies + ApiService 401 refresh) and migrate the 9 fetch-based builder hooks; extract a generic LruTtlCache and consolidate the two backend LRU+TTL caches; add a shared replaceNode helper + shared artifact-shape detectors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dc50da7 commit ba674ca

93 files changed

Lines changed: 2688 additions & 1069 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/deploy-instance.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ jobs:
123123
- service: ches-adapter
124124
context: "."
125125
dockerfile: apps/ches-adapter/Dockerfile
126+
# Phase 6 dynamic-nodes Deno sandbox. Its Dockerfile COPYs src/ and
127+
# deno.json relative to the build context, so the context is the app
128+
# dir (not the repo root like the others).
129+
- service: deno-runner
130+
context: apps/deno-runner
131+
dockerfile: apps/deno-runner/Dockerfile
126132
steps:
127133
- name: Checkout
128134
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -265,6 +271,7 @@ jobs:
265271
--backend-image "${IMAGE_BASE}/backend-services" \
266272
--frontend-image "${IMAGE_BASE}/frontend" \
267273
--worker-image "${IMAGE_BASE}/temporal" \
274+
--deno-runner-image "${IMAGE_BASE}/deno-runner" \
268275
--image-tag "${IMAGE_TAG}" \
269276
--sso-auth-server-url "${SSO_AUTH_SERVER_URL}" \
270277
--sso-realm "${SSO_REALM}" \

.github/workflows/frontend-qa.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ jobs:
3131
run: npm install --ignore-scripts --no-package-lock
3232
- name: Build graph-workflow package
3333
run: npm run build -w packages/graph-workflow
34+
# The shared graph-workflow package (validator, cache-hash, ctx-binding)
35+
# is consumed by the backend save-path, the temporal worker, and the
36+
# canvas. This QA job triggers on packages/** so its jest suite runs here
37+
# rather than shipping with zero CI-enforced coverage.
38+
- name: Test graph-workflow package
39+
run: npm run test -w packages/graph-workflow
3440
- name: Run Linter
3541
working-directory: apps/frontend
3642
run: npm run lint

apps/backend-services/src/agent/agent.service.startchat.spec.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ForbiddenException } from "@nestjs/common";
1+
import { ForbiddenException, NotFoundException } from "@nestjs/common";
22
import type { OnFinishEvent, StreamTextResult, ToolSet } from "ai";
33

44
// Mock the AI SDK boundary so startChat's orchestration is unit-testable
@@ -75,6 +75,9 @@ function makeHarness(opts: {
7575
id: "conv-1",
7676
workflowId: null,
7777
title: "existing",
78+
// Must match baseInput.groupId — startChat rejects a resume whose
79+
// stored group differs from the request's group.
80+
groupId: "g1",
7881
}
7982
: opts.conversation;
8083

@@ -160,6 +163,30 @@ describe("AgentService.startChat — per-conversation budget (ITEM 26)", () => {
160163
});
161164
});
162165

166+
describe("AgentService.startChat — resume group scoping (SECURITY §2.4)", () => {
167+
it("rejects resuming a conversation whose stored group differs from the request group", async () => {
168+
const { service, chatRepository } = makeHarness({
169+
conversation: {
170+
id: "conv-1",
171+
workflowId: "wf-in-group-B",
172+
title: "existing",
173+
groupId: "group-B",
174+
},
175+
});
176+
177+
await expect(
178+
service.startChat({
179+
...baseInput,
180+
// Request presents group A ("g1") but the conversation belongs to B.
181+
messages: [userMsg("hi")],
182+
} as never),
183+
).rejects.toBeInstanceOf(NotFoundException);
184+
185+
// The mismatch is caught before any message is persisted.
186+
expect(chatRepository.createMessage).not.toHaveBeenCalled();
187+
});
188+
});
189+
163190
describe("AgentService.startChat — onFinish persistence (ITEM 23 + 26)", () => {
164191
it("persists full assistant parts (tool calls included) and totals tokens", async () => {
165192
const { service, chatRepository } = makeHarness({});
@@ -251,7 +278,12 @@ describe("AgentService.startChat — abort cleanup (ITEM 24/25)", () => {
251278
describe("AgentService.startChat — ctx binding via onWorkflowCreated (ITEM 25)", () => {
252279
it("binds the conversation workflowId when the agent's onWorkflowCreated hook fires", async () => {
253280
const { service, setWorkflowId } = makeHarness({
254-
conversation: { id: "conv-1", workflowId: null, title: "t" },
281+
conversation: {
282+
id: "conv-1",
283+
workflowId: null,
284+
title: "t",
285+
groupId: "g1",
286+
},
255287
});
256288
await service.startChat({
257289
...baseInput,
@@ -269,7 +301,12 @@ describe("AgentService.startChat — ctx binding via onWorkflowCreated (ITEM 25)
269301

270302
it("does not rebind when the conversation already has a workflowId", async () => {
271303
const { service, setWorkflowId } = makeHarness({
272-
conversation: { id: "conv-1", workflowId: "wf-existing", title: "t" },
304+
conversation: {
305+
id: "conv-1",
306+
workflowId: "wf-existing",
307+
title: "t",
308+
groupId: "g1",
309+
},
273310
});
274311
await service.startChat({
275312
...baseInput,

apps/backend-services/src/agent/agent.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ export class AgentService {
8080
throw new NotFoundException("Conversation not found");
8181
}
8282

83+
// A conversation is bound to the group it was created in. Resuming it
84+
// under a different group (a multi-group user presenting group A while the
85+
// stored conversation — and its bound workflowId — belong to group B)
86+
// would build the tool context against a mismatched group. Reject rather
87+
// than operate cross-group; NotFound (not Forbidden) matches the id-probing
88+
// convention used elsewhere.
89+
if (conversation !== null && conversation.groupId !== input.groupId) {
90+
throw new NotFoundException("Conversation not found");
91+
}
92+
8393
if (conversation === null) {
8494
conversation = await this.chatRepository.createConversation({
8595
workflowId: input.workflowId,

apps/backend-services/src/agent/tools.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ function makeCtx(overrides: Partial<AgentToolContext> = {}): {
6767
description: null,
6868
slug: "wf",
6969
version: 3,
70+
// Tenancy: the agent tools assert the fetched workflow belongs to
71+
// ctx.groupId. Default the mock to the same group so the happy-path
72+
// tests pass; the cross-group test overrides getWorkflow to return a
73+
// foreign group.
74+
groupId: "group-1",
7075
config: state.config,
7176
}));
7277
const updateWorkflow = jest.fn(
@@ -299,6 +304,74 @@ describe("wrapToolData (ITEM 27 delimiting + ITEM 26 truncation)", () => {
299304
});
300305
});
301306

307+
// ── Tenancy: agent tools must not read/write cross-group workflows ───
308+
describe("agent tools enforce group ownership (SECURITY §2.1)", () => {
309+
function makeForeignCtx() {
310+
// getWorkflow resolves a workflow in a DIFFERENT group than ctx.groupId —
311+
// simulating a model-supplied workflowId (reachable via prompt injection)
312+
// that points at another tenant's row.
313+
const foreignConfig = emptyConfig();
314+
const getWorkflow = jest.fn(async () => ({
315+
id: "victim-wf",
316+
name: "Victim",
317+
description: null,
318+
slug: "victim",
319+
version: 1,
320+
groupId: "group-OTHER",
321+
config: foreignConfig,
322+
}));
323+
const updateWorkflow = jest.fn(async () => ({
324+
id: "victim-wf",
325+
name: "Victim",
326+
}));
327+
const ctx = {
328+
actorId: "actor-1",
329+
groupId: "group-1",
330+
workflowId: null,
331+
apiKey: "key-1",
332+
backendBaseUrl: "http://backend",
333+
workflowService: { getWorkflow, updateWorkflow } as unknown,
334+
dynamicNodesService: {
335+
getMergedCatalogForGroup: jest.fn(async () => []),
336+
} as unknown,
337+
} as unknown as AgentToolContext;
338+
return { ctx, getWorkflow, updateWorkflow };
339+
}
340+
341+
it("getWorkflow throws NotFound for a cross-group workflow id", async () => {
342+
const { ctx, getWorkflow } = makeForeignCtx();
343+
const tools = createAgentTools(ctx);
344+
await expect(
345+
exec(tools, "getWorkflow", { workflowId: "victim-wf" }),
346+
).rejects.toMatchObject({ status: 404 });
347+
expect(getWorkflow).toHaveBeenCalledWith("victim-wf", "actor-1");
348+
});
349+
350+
it("a write tool refuses a cross-group workflow and never calls updateWorkflow", async () => {
351+
const { ctx, updateWorkflow } = makeForeignCtx();
352+
const tools = createAgentTools(ctx);
353+
await expect(
354+
exec(tools, "updateWorkflowMetadata", {
355+
workflowId: "victim-wf",
356+
name: "hijacked",
357+
}),
358+
).rejects.toMatchObject({ status: 404 });
359+
expect(updateWorkflow).not.toHaveBeenCalled();
360+
});
361+
362+
it("addNode (read+write path) refuses a cross-group workflow", async () => {
363+
const { ctx, updateWorkflow } = makeForeignCtx();
364+
const tools = createAgentTools(ctx);
365+
await expect(
366+
exec(tools, "addNode", {
367+
workflowId: "victim-wf",
368+
node: { id: "n1", type: "file.prepare" },
369+
}),
370+
).rejects.toMatchObject({ status: 404 });
371+
expect(updateWorkflow).not.toHaveBeenCalled();
372+
});
373+
});
374+
302375
// ── ITEM 25 — tools.ts write / validation / retry logic ─────────────
303376

304377
describe("connectNodes validation (ITEM 25)", () => {
@@ -499,6 +572,35 @@ describe("publishDynamicNode 409 → PUT republish (ITEM 25)", () => {
499572
});
500573
});
501574

575+
describe("deleteDynamicNode slug bounding (SECURITY §2.3)", () => {
576+
it("rejects a path-traversal slug without issuing any self-call", async () => {
577+
const { ctx, internalFetchMock } = makeCtx();
578+
const tools = createAgentTools(ctx);
579+
const result = await exec<{ ok: boolean; error?: { code: string } }>(
580+
tools,
581+
"deleteDynamicNode",
582+
{ slug: "../workflows/victim-wf-id" },
583+
);
584+
expect(result.ok).toBe(false);
585+
expect(result.error?.code).toBe("invalid-slug");
586+
expect(internalFetchMock).not.toHaveBeenCalled();
587+
});
588+
589+
it("issues a DELETE to the slug-scoped endpoint for a valid slug", async () => {
590+
const { ctx, internalFetchMock } = makeCtx();
591+
internalFetchMock.mockResolvedValueOnce(fetchResponse(200, { ok: true }));
592+
const tools = createAgentTools(ctx);
593+
const result = await exec<{ ok: boolean }>(tools, "deleteDynamicNode", {
594+
slug: "my-node",
595+
});
596+
expect(result.ok).toBe(true);
597+
const url = internalFetchMock.mock.calls[0][0] as string;
598+
const init = internalFetchMock.mock.calls[0][1] as RequestInit;
599+
expect(url).toContain("/api/dynamic-nodes/my-node");
600+
expect(init.method).toBe("DELETE");
601+
});
602+
});
603+
502604
describe("createWorkflow binds the conversation (ITEM 25)", () => {
503605
it("invokes onWorkflowCreated with the new id", async () => {
504606
const onWorkflowCreated = jest.fn();

apps/backend-services/src/agent/tools.ts

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
// bridge instantiates pathologically (TS2589, ~15M instantiations/tool, OOM on
99
// a full type-check) against Zod's v3 API; the v4 API resolves in ~23k
1010
// instantiations. The runtime API used here is identical across v3/v4.
11+
import { NotFoundException } from "@nestjs/common";
1112
import { type ToolSet, tool } from "ai";
1213
import { z } from "zod/v4";
1314
import type { DynamicNodesService } from "@/dynamic-nodes/dynamic-nodes.service";
@@ -16,7 +17,10 @@ import type {
1617
GraphNode,
1718
GraphWorkflowConfig,
1819
} from "@/workflow/graph-workflow-types";
19-
import type { WorkflowService } from "@/workflow/workflow.service";
20+
import type {
21+
WorkflowInfo,
22+
WorkflowService,
23+
} from "@/workflow/workflow.service";
2024

2125
/**
2226
* Default cap on the number of characters of a single tool result that
@@ -27,6 +31,17 @@ import type { WorkflowService } from "@/workflow/workflow.service";
2731
*/
2832
export const DEFAULT_MAX_TOOL_RESULT_CHARS = 20000;
2933

34+
/**
35+
* Canonical dynamic-node slug grammar: a lowercase letter followed by
36+
* lowercase alphanumerics/hyphens. Matches the `@name` capture the publish
37+
* path is bounded by. Used to reject model-supplied slugs BEFORE they are
38+
* interpolated into a self-call path — an unbounded slug like
39+
* `../workflows/<id>` would path-normalise into `DELETE /api/workflows/<id>`,
40+
* converting a soft-delete tool into an arbitrary same-privilege DELETE
41+
* (reachable via prompt injection).
42+
*/
43+
const DYNAMIC_NODE_SLUG_RE = /^[a-z][a-z0-9-]*$/;
44+
3045
/**
3146
* Visible marker bracketing tool-result content returned to the model.
3247
* The system prompt instructs the model to treat anything between these
@@ -151,11 +166,40 @@ function ensureNonNullWorkflowId(
151166
return resolved;
152167
}
153168

169+
/**
170+
* Fetch a workflow and enforce that it belongs to the agent's bound group.
171+
*
172+
* The REST `WorkflowController` guards every endpoint with
173+
* `identityCanAccessGroup`; the agent tools call `WorkflowService` directly,
174+
* so tenancy MUST be re-asserted here. `getWorkflow` does a bare
175+
* `findUnique({ where: { id } })` with no group filter (by design — the
176+
* controller checks the returned `groupId` against the caller's possibly-many
177+
* groups). The agent, by contrast, operates in exactly one group
178+
* (`ctx.groupId`), so any workflow it reads or writes must live in that group.
179+
*
180+
* The tool schema accepts a model-supplied `workflowId`, reachable via prompt
181+
* injection from untrusted document/OCR text surfaced through the read tools —
182+
* so this is the security boundary, not a convenience. On mismatch we throw
183+
* `NotFoundException` (not Forbidden) so a cross-group id is indistinguishable
184+
* from a missing one and can't be used to probe existence, matching the
185+
* service's own 404-vs-403 convention.
186+
*/
187+
async function fetchWorkflowInGroup(
188+
ctx: AgentToolContext,
189+
workflowId: string,
190+
): Promise<WorkflowInfo> {
191+
const wf = await ctx.workflowService.getWorkflow(workflowId, ctx.actorId);
192+
if (wf.groupId !== ctx.groupId) {
193+
throw new NotFoundException(`Workflow not found: ${workflowId}`);
194+
}
195+
return wf;
196+
}
197+
154198
async function readWorkflow(
155199
ctx: AgentToolContext,
156200
workflowId: string,
157201
): Promise<GraphWorkflowConfig> {
158-
const wf = await ctx.workflowService.getWorkflow(workflowId, ctx.actorId);
202+
const wf = await fetchWorkflowInGroup(ctx, workflowId);
159203
return wf.config;
160204
}
161205

@@ -180,6 +224,12 @@ async function writeWorkflow(
180224
workflowId: string,
181225
config: GraphWorkflowConfig,
182226
): Promise<void> {
227+
// Assert group ownership BEFORE writing: updateWorkflow re-validates the
228+
// config against the *target* row's group_id, so without this a cross-group
229+
// write would validate and succeed. Callers that first read via
230+
// readWorkflow are already guarded, but the assertion is cheap and keeps
231+
// writeWorkflow safe on its own.
232+
await fetchWorkflowInGroup(ctx, workflowId);
183233
const resolved = resolveConfigForPersist(config);
184234
await ctx.workflowService.updateWorkflow(workflowId, ctx.actorId, {
185235
config: resolved,
@@ -274,7 +324,7 @@ export function createAgentTools(ctx: AgentToolContext): ToolSet {
274324
}),
275325
execute: async ({ workflowId }) => {
276326
const id = ensureNonNullWorkflowId(ctx, workflowId);
277-
const wf = await ctx.workflowService.getWorkflow(id, ctx.actorId);
327+
const wf = await fetchWorkflowInGroup(ctx, id);
278328
// Workflow name/description/node params are user-controlled; wrap
279329
// as DATA + size-cap before it enters the model context.
280330
return {
@@ -359,6 +409,9 @@ export function createAgentTools(ctx: AgentToolContext): ToolSet {
359409
}),
360410
execute: async ({ workflowId, name, description }) => {
361411
const id = ensureNonNullWorkflowId(ctx, workflowId);
412+
// Assert group ownership before the metadata write (same tenancy
413+
// boundary as the graph-edit tools).
414+
await fetchWorkflowInGroup(ctx, id);
362415
const patch: { name?: string; description?: string } = {};
363416
if (name !== undefined) patch.name = name;
364417
if (description !== undefined) patch.description = description;
@@ -711,7 +764,7 @@ export function createAgentTools(ctx: AgentToolContext): ToolSet {
711764
const slug = parsed[1];
712765
const putResult = await internalFetch(
713766
ctx,
714-
`/api/dynamic-nodes/${slug}`,
767+
`/api/dynamic-nodes/${encodeURIComponent(slug)}`,
715768
{ method: "PUT", body: JSON.stringify({ script }) },
716769
);
717770
return putResult.ok
@@ -727,9 +780,24 @@ export function createAgentTools(ctx: AgentToolContext): ToolSet {
727780
description: "Soft-delete a published dynamic node by slug.",
728781
inputSchema: z.object({ slug: z.string() }),
729782
execute: async ({ slug }) => {
730-
const result = await internalFetch(ctx, `/api/dynamic-nodes/${slug}`, {
731-
method: "DELETE",
732-
});
783+
// Reject anything that isn't a bare dynamic-node slug before it reaches
784+
// the self-call path — otherwise `slug='../workflows/<id>'` normalises
785+
// to a DELETE against an arbitrary endpoint.
786+
if (!DYNAMIC_NODE_SLUG_RE.test(slug)) {
787+
return {
788+
ok: false,
789+
error: {
790+
code: "invalid-slug",
791+
message:
792+
"slug must match /^[a-z][a-z0-9-]*$/ (a bare dynamic-node slug).",
793+
},
794+
};
795+
}
796+
const result = await internalFetch(
797+
ctx,
798+
`/api/dynamic-nodes/${encodeURIComponent(slug)}`,
799+
{ method: "DELETE" },
800+
);
733801
return result.ok
734802
? { ok: true, ...(result.body as object) }
735803
: { ok: false, error: result.body };

0 commit comments

Comments
 (0)