Skip to content

Commit bb81125

Browse files
committed
fix(secrets): ignore Note blocks in fork mapping and secret references, refetch references on return
1 parent 48d6ef5 commit bb81125

6 files changed

Lines changed: 166 additions & 10 deletions

File tree

‎.agents/skills/ship/SKILL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ When the user runs `/ship`:
6666
# Runs every audit CI runs, concurrently, and replays the output of any that fail.
6767
# The audit list is derived in scripts/run-audits.ts — do not hand-list audits here.
6868
bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; }
69+
# CI's "Verify docs manifest is in sync" step is not a `check:*` script, so the runner above
70+
# does not cover it. (CI's "Security audit" `bun audit` step is `continue-on-error` — advisory
71+
# only, not a gate — so it is deliberately not run here.)
72+
bun run docs-manifest:check || { echo "❌ docs manifest out of sync — do not ship"; exit 1; }
6973
```
7074
If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here.
7175
7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6

‎apps/docs/content/docs/platform/enterprise/forks.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ Servers that **publish workflows as MCP tools**.
331331
| **Fork** | **Values never leave the source.** Workflow text still contains `{{KEY}}` names. Create matching secrets (or the names you will map to) under the child’s **Secrets**. |
332332
| **Sync** | Map source key names to target key names. Values stay in each workspace. Unmapped required secrets block Sync. |
333333

334+
Notes are documentation: a `{{KEY}}` inside a Note block never needs mapping and never blocks Sync.
335+
334336
**Example:** Workflows use `{{OPENAI_API_KEY}}`. After fork, add that secret in the child (or map `OPENAI_API_KEY` to whatever name the child uses) before runs and syncs succeed.
335337

336338
---

‎apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,75 @@ describe('canonical mode policy (fork/promote)', () => {
18351835
expect(scan.references).toEqual([])
18361836
})
18371837

1838+
it('does NOT detect {{ENV}} in a Note block (an annotation never executes)', () => {
1839+
vi.mocked(getBlock).mockReturnValue(
1840+
blockWith([{ id: 'content', title: 'Content', type: 'long-input' }])
1841+
)
1842+
const scan = scanWorkflowReferences(
1843+
[
1844+
{
1845+
id: 'b1',
1846+
name: 'Setup notes',
1847+
type: 'note',
1848+
subBlocks: {
1849+
content: entry('content', 'long-input', 'Set {{OPENAI_API_KEY}} before running.'),
1850+
},
1851+
},
1852+
],
1853+
() => null
1854+
)
1855+
expect(scan.references).toEqual([])
1856+
expect(scan.unmapped).toEqual([])
1857+
})
1858+
1859+
it('still detects {{ENV}} named by both a Note and an executing block, attributed to the executing block', () => {
1860+
vi.mocked(getBlock).mockReturnValue(
1861+
blockWith([
1862+
{ id: 'content', title: 'Content', type: 'long-input' },
1863+
{ id: 'apiKey', title: 'API Key', type: 'short-input' },
1864+
])
1865+
)
1866+
const scan = scanWorkflowReferences(
1867+
[
1868+
{
1869+
id: 'b1',
1870+
name: 'Setup notes',
1871+
type: 'note',
1872+
subBlocks: {
1873+
content: entry('content', 'long-input', 'Set {{OPENAI_API_KEY}} before running.'),
1874+
},
1875+
},
1876+
{
1877+
id: 'b2',
1878+
name: 'Agent',
1879+
type: 'agent',
1880+
subBlocks: { apiKey: entry('apiKey', 'short-input', '{{OPENAI_API_KEY}}') },
1881+
},
1882+
],
1883+
() => null
1884+
)
1885+
expect(
1886+
scan.references.map((ref) => [ref.kind, ref.sourceId, ref.blockId, ref.subBlockKey])
1887+
).toEqual([['env-var', 'OPENAI_API_KEY', 'b2', 'apiKey']])
1888+
expect(scan.unmapped.map((ref) => ref.sourceId)).toEqual(['OPENAI_API_KEY'])
1889+
})
1890+
1891+
it('still rewrites a mapped {{ENV}} inside a Note on promote, so the note names the target key', () => {
1892+
vi.mocked(getBlock).mockReturnValue(
1893+
blockWith([{ id: 'content', title: 'Content', type: 'long-input' }])
1894+
)
1895+
const result = remapForkSubBlocks(
1896+
{ content: entry('content', 'long-input', 'Set {{OPENAI_API_KEY}} before running.') },
1897+
(kind, sourceId) =>
1898+
kind === 'env-var' && sourceId === 'OPENAI_API_KEY' ? 'OPENAI_KEY_PROD' : null,
1899+
'promote',
1900+
{ blockType: 'note' }
1901+
)
1902+
expect(result.subBlocks.content.value).toBe('Set {{OPENAI_KEY_PROD}} before running.')
1903+
expect(result.references).toEqual([])
1904+
expect(result.unmapped).toEqual([])
1905+
})
1906+
18381907
it('an active manual member keeps its RESOURCE-id escape hatch while its {{ENV}} is detected', () => {
18391908
vi.mocked(getBlock).mockReturnValue(
18401909
blockWith([

‎apps/sim/ee/workspace-forking/lib/remap/remap-references.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { isRecordLike, omit } from '@sim/utils/object'
44
import type { SubBlockType } from '@sim/workflow-types/blocks'
5+
import { isWorkflowAnnotationOnlyBlockType } from '@sim/workflow-types/workflow'
56
import type { z } from 'zod'
67
import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork'
78
import { readFolderPaths, replaceFolderPath } from '@/lib/folders/selection'
@@ -580,7 +581,10 @@ export function createCanonicalModeGates(
580581
export interface RemapForkContext {
581582
blockId?: string
582583
blockName?: string
583-
/** Block type, to build the canonical index for active-member DETECTION gating (rewrite unaffected). */
584+
/**
585+
* Block type, to build the canonical index for active-member DETECTION gating and to recognise
586+
* an annotation-only block, whose values are never detected. Rewrite is unaffected by either.
587+
*/
584588
blockType?: string
585589
/** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */
586590
canonicalModes?: CanonicalModeOverrides
@@ -1126,9 +1130,9 @@ export function remapForkSubBlocks(
11261130
// active ADVANCED (manual) member - and every dependent scoped to it - passes through VERBATIM
11271131
// (user-owned, never remapped, never a mapping requirement); a DORMANT member's value is
11281132
// CLEARED outright (below) so no stale id ever survives in an inactive slot. A condition-hidden
1129-
// subblock is still rewritten but not detected. Needs `blockType` for the config; an unknown
1130-
// block type gets no gating (everything detected, nothing passed through - the conservative
1131-
// default).
1133+
// subblock is still rewritten but not detected, and so is every field of an annotation-only
1134+
// block (see `annotationOnly` below). Needs `blockType` for the config; an unknown block type
1135+
// gets no gating (everything detected, nothing passed through - the conservative default).
11321136
const blockSubBlocks = context?.blockType ? getBlock(context.blockType)?.subBlocks : undefined
11331137
const configByBaseKey = new Map(
11341138
(blockSubBlocks ?? []).filter((config) => config.id).map((config) => [config.id, config])
@@ -1140,6 +1144,15 @@ export function remapForkSubBlocks(
11401144
context?.triggerMode === true
11411145
)
11421146

1147+
/**
1148+
* An annotation-only block (a Note) never executes, so nothing in it is a reference. Its
1149+
* `{{KEY}}` is prose about a secret, not a use of one: it must not become a mapping entry or a
1150+
* sync blocker, and the References tab — which walks blocks through this same policy — must not
1151+
* send someone rotating a key to edit a note. The rewrite side is unchanged, exactly like a
1152+
* condition-hidden field's: a mapped key is still renamed so the note names the target's key.
1153+
*/
1154+
const annotationOnly = isWorkflowAnnotationOnlyBlockType(context?.blockType)
1155+
11431156
for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) {
11441157
if (!subBlock || typeof subBlock !== 'object') {
11451158
result[subBlockKey] = subBlock
@@ -1169,7 +1182,8 @@ export function remapForkSubBlocks(
11691182
const verbatimManual =
11701183
!dormant &&
11711184
(gates.isActiveManualMember(subBlockKey) || gates.isManualParentDependent(subBlockKey))
1172-
const detectionSkipped = dormant || verbatimManual || gates.isConditionHidden(subBlockKey)
1185+
const detectionSkipped =
1186+
annotationOnly || dormant || verbatimManual || gates.isConditionHidden(subBlockKey)
11731187
// `{{ENV}}` detection is gated on EXECUTION, not on ownership. A dormant member and a
11741188
// condition-hidden field never execute, so their refs must not become sync blockers - but an
11751189
// ACTIVE MANUAL member is exactly the value that DOES execute, and its `{{KEY}}` is a live
@@ -1179,7 +1193,7 @@ export function remapForkSubBlocks(
11791193
// missing that secret silently passed the required-env gate instead of blocking the sync.
11801194
// Resource-id detection keeps `verbatimManual` (a hand-typed id stays a user-owned escape
11811195
// hatch); only env refs, which are never workspace-scoped ids, are detected here.
1182-
const envDetectionSkipped = dormant || gates.isConditionHidden(subBlockKey)
1196+
const envDetectionSkipped = annotationOnly || dormant || gates.isConditionHidden(subBlockKey)
11831197
if (dormant && isNonEmptyValue(value)) {
11841198
value = ''
11851199
}

‎apps/sim/hooks/queries/credentials.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,13 @@ export function useSecretUsage({ workspaceId, name, scope }: SecretUsageParams,
319319
}
320320

321321
/**
322-
* References only move when someone edits a workflow, a custom tool, or an MCP server — far
323-
* less often than the usage trail, which every run appends to. A longer window keeps the scan
324-
* (which reads every candidate block in the workspace) off the wire on tab switches.
322+
* Always stale: the list mirrors the canvas, and the reader has usually just come from editing
323+
* it — deleting the block a row pointed at, then returning here to see it gone. A stale window
324+
* served the old list for its whole length, and no invalidation can cover a workflow someone
325+
* else changed. Cached data still shows while the scan refreshes, so a tab switch costs one
326+
* bounded, prefiltered query rather than a blank panel.
325327
*/
326-
export const SECRET_REFERENCES_STALE_TIME = 5 * 60 * 1000
328+
export const SECRET_REFERENCES_STALE_TIME = 0
327329

328330
interface SecretReferencesParams {
329331
workspaceId?: string
@@ -334,6 +336,11 @@ interface SecretReferencesParams {
334336
* Reads where one secret is wired in. Takes no scope: a reference names a key, not a scope, and
335337
* the server authorizes against what the name resolves to. Only credential admins of a workspace
336338
* secret — or the owner of a personal one — are authorized server-side.
339+
*
340+
* Refetches on window focus even on the web, where the app default leaves it off: the edit that
341+
* moves a reference happens on a canvas, often in another tab, and this panel has no other
342+
* signal for it. `'always'`, not `true`: `true` refetches only a STALE query, which would tie
343+
* this guarantee to `SECRET_REFERENCES_STALE_TIME` staying exactly 0.
337344
*/
338345
export function useSecretReferences({ workspaceId, name }: SecretReferencesParams, enabled = true) {
339346
return useQuery({
@@ -345,5 +352,6 @@ export function useSecretReferences({ workspaceId, name }: SecretReferencesParam
345352
}),
346353
enabled: Boolean(workspaceId && name) && enabled,
347354
staleTime: SECRET_REFERENCES_STALE_TIME,
355+
refetchOnWindowFocus: 'always',
348356
})
349357
}

‎apps/sim/lib/secrets/references/scan.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,65 @@ describe('scanSecretReferences', () => {
114114
expect(scan.workflows).toEqual([])
115115
})
116116

117+
/**
118+
* A Note is documentation on the canvas: its text never resolves at run time, so a `{{KEY}}`
119+
* in it is prose about the secret, not a use of it. The remapper drops it for the same reason
120+
* it drops a condition-hidden field, and the tab has to agree — rotating a key does not mean
121+
* editing every note that mentions it.
122+
*/
123+
it('drops a Note block that mentions the secret', async () => {
124+
queueTableRows(schemaMock.workflowBlocks, [
125+
blockRow({
126+
blockId: 'block-1',
127+
blockName: 'Setup notes',
128+
blockType: 'note',
129+
workflowId: 'workflow-1',
130+
workflowName: 'Nightly sync',
131+
subBlocks: {
132+
content: { id: 'content', type: 'long-input', value: 'Uses {{API_KEY}} for auth.' },
133+
},
134+
}),
135+
])
136+
137+
const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
138+
139+
expect(scan.workflows).toEqual([])
140+
})
141+
142+
it('lists only the executing block when a Note and a block both name the secret', async () => {
143+
queueTableRows(schemaMock.workflowBlocks, [
144+
blockRow({
145+
blockId: 'block-1',
146+
blockName: 'Setup notes',
147+
blockType: 'note',
148+
workflowId: 'workflow-1',
149+
workflowName: 'Nightly sync',
150+
subBlocks: {
151+
content: { id: 'content', type: 'long-input', value: 'Uses {{API_KEY}} for auth.' },
152+
},
153+
}),
154+
blockRow({
155+
blockId: 'block-2',
156+
blockName: 'Fetch orders',
157+
workflowId: 'workflow-1',
158+
workflowName: 'Nightly sync',
159+
subBlocks: shortInput('apiKey', '{{API_KEY}}'),
160+
}),
161+
])
162+
163+
const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' })
164+
165+
expect(scan.workflows).toEqual([
166+
{
167+
workflowId: 'workflow-1',
168+
workflowName: 'Nightly sync',
169+
blocks: [
170+
{ blockId: 'block-2', blockName: 'Fetch orders', blockType: 'agent', field: 'apiKey' },
171+
],
172+
},
173+
])
174+
})
175+
117176
/**
118177
* The fork remapper collapses a block's references to one per `(kind, sourceId)`, so a block
119178
* naming the secret twice yields one entry, not two. Pinned here because the panel renders

0 commit comments

Comments
 (0)