@growthub/cli 0.14.1 → 0.14.3
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/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/agent-outcomes/route.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +187 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +36 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/patch/preflight/route.js +152 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/route.js +88 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/login/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/logout/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-agent-auth/status/route.js +3 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +86 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/swarm-condition/route.js +2 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +21 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +338 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +49 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +54 -11
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +113 -36
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SandboxAgentAuthPanel.jsx +34 -14
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +7 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +35 -169
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/sandbox-environment-primitive.md +26 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/adapters/local-intelligence-browser-access.js +516 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +85 -7
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-process.js +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/index.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/sandbox-adapter-registry.js +5 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/data-model/field-contracts.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +8 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +3 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-publish.js +179 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-agent-auth.js +82 -27
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +4 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-activation.js +89 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-app-registry.js +539 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +11 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +24 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-outcome-receipts.js +157 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-patch-policy.js +400 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +6 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +3 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +364 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +10 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +203 -0
- package/package.json +2 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Outcome Receipt V1 writer — the unified receipt every mutation lane
|
|
3
|
+
* emits (contract: `@growthub/api-contract/workspace-outcome`).
|
|
4
|
+
*
|
|
5
|
+
* One canonical stream answers, for any agent action:
|
|
6
|
+
* intent → what changed → was it preflighted → was it proven →
|
|
7
|
+
* was it published → what runId/sourceId/hash/version proves it →
|
|
8
|
+
* how does the next agent replay / rollback / continue.
|
|
9
|
+
*
|
|
10
|
+
* Storage is the EXISTING source-record sidecar (`growthub.source-records.json`)
|
|
11
|
+
* under the stable source id `workspace:agent-outcomes` — no new persistence
|
|
12
|
+
* backend. The stream is a rolling window (last 200 receipts). Existing
|
|
13
|
+
* helper-apply receipts and sandbox run records are untouched; outcome
|
|
14
|
+
* receipts LINK to them via `sourceId` / `runId` / `rollbackRef`.
|
|
15
|
+
*
|
|
16
|
+
* Safety rules enforced here, not trusted from callers:
|
|
17
|
+
* - every string field is secret-redacted (`redactSecrets`) and truncated;
|
|
18
|
+
* - receipt append failures are NEVER fatal to the mutation route
|
|
19
|
+
* (read-only runtimes simply do not accumulate a stream);
|
|
20
|
+
* - receipts carry summaries and references — never raw payloads.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import {
|
|
25
|
+
readWorkspaceSourceRecords,
|
|
26
|
+
writeWorkspaceSourceRecords,
|
|
27
|
+
describePersistenceMode
|
|
28
|
+
} from "@/lib/workspace-config";
|
|
29
|
+
import { redactSecrets } from "@/lib/sandbox-agent-auth-redaction";
|
|
30
|
+
import { stableStringify } from "@/lib/workspace-patch-policy";
|
|
31
|
+
|
|
32
|
+
const AGENT_OUTCOMES_SOURCE_ID = "workspace:agent-outcomes";
|
|
33
|
+
const MAX_RECEIPTS = 200;
|
|
34
|
+
const MAX_SUMMARY_CHARS = 400;
|
|
35
|
+
const MAX_INTENT_CHARS = 280;
|
|
36
|
+
const MAX_LIST_ENTRIES = 24;
|
|
37
|
+
|
|
38
|
+
function newReceiptId() {
|
|
39
|
+
return `aor_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function safeText(value, maxChars) {
|
|
43
|
+
const text = redactSecrets(String(value ?? "")).trim();
|
|
44
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeList(values, maxChars = 160) {
|
|
48
|
+
return (Array.isArray(values) ? values : [])
|
|
49
|
+
.slice(0, MAX_LIST_ENTRIES)
|
|
50
|
+
.map((v) => safeText(v, maxChars))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a canonical receipt from partial fields. Unknown/raw payloads are
|
|
56
|
+
* not accepted — only the contract's named fields survive.
|
|
57
|
+
*/
|
|
58
|
+
function buildOutcomeReceipt(fields) {
|
|
59
|
+
const f = fields && typeof fields === "object" ? fields : {};
|
|
60
|
+
const receipt = {
|
|
61
|
+
receiptId: newReceiptId(),
|
|
62
|
+
kind: safeText(f.kind || "agent-outcome", 40),
|
|
63
|
+
lane: safeText(f.lane || "untrusted-direct", 40),
|
|
64
|
+
outcomeStatus: safeText(f.outcomeStatus || "failed", 24),
|
|
65
|
+
summary: safeText(f.summary || "", MAX_SUMMARY_CHARS) || "(no summary)",
|
|
66
|
+
createdAt: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
if (f.intent) receipt.intent = safeText(f.intent, MAX_INTENT_CHARS);
|
|
69
|
+
if (f.actor) receipt.actor = safeText(f.actor, 80);
|
|
70
|
+
if (Array.isArray(f.objectRefs)) {
|
|
71
|
+
receipt.objectRefs = f.objectRefs.slice(0, MAX_LIST_ENTRIES).map((ref) => {
|
|
72
|
+
const out = { objectId: safeText(ref?.objectId, 120) };
|
|
73
|
+
if (ref?.rowName) out.rowName = safeText(ref.rowName, 120);
|
|
74
|
+
if (ref?.objectType) out.objectType = safeText(ref.objectType, 60);
|
|
75
|
+
return out;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(f.changedFields)) receipt.changedFields = safeList(f.changedFields, 60);
|
|
79
|
+
if (f.policyVerdict && typeof f.policyVerdict === "object") {
|
|
80
|
+
receipt.policyVerdict = {
|
|
81
|
+
ok: f.policyVerdict.ok === true,
|
|
82
|
+
...(Array.isArray(f.policyVerdict.violationCodes)
|
|
83
|
+
? { violationCodes: safeList(f.policyVerdict.violationCodes, 60) }
|
|
84
|
+
: {})
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (f.schemaVerdict && typeof f.schemaVerdict === "object") {
|
|
88
|
+
receipt.schemaVerdict = {
|
|
89
|
+
ok: f.schemaVerdict.ok === true,
|
|
90
|
+
...(Number.isFinite(f.schemaVerdict.errorCount) ? { errorCount: f.schemaVerdict.errorCount } : {})
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
for (const key of ["runId", "sourceId", "draftSha256", "publishedSha256", "version", "appId"]) {
|
|
94
|
+
if (f[key]) receipt[key] = safeText(f[key], 160);
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(f.nextActions)) receipt.nextActions = safeList(f.nextActions, 240);
|
|
97
|
+
if (f.rollbackRef && typeof f.rollbackRef === "object") {
|
|
98
|
+
const rb = {};
|
|
99
|
+
for (const key of ["objectId", "rowName", "liveField", "previousVersion", "sourceId"]) {
|
|
100
|
+
if (f.rollbackRef[key]) rb[key] = safeText(f.rollbackRef[key], 120);
|
|
101
|
+
}
|
|
102
|
+
if (Number.isFinite(f.rollbackRef.deltaIndex)) rb.deltaIndex = f.rollbackRef.deltaIndex;
|
|
103
|
+
if (Object.keys(rb).length) receipt.rollbackRef = rb;
|
|
104
|
+
}
|
|
105
|
+
return receipt;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Append a receipt to the stream. NEVER throws — mutation routes must not
|
|
110
|
+
* fail because the receipt sidecar is read-only or momentarily unwritable.
|
|
111
|
+
* Returns the receipt (with `persisted` flag) so routes can echo its id.
|
|
112
|
+
*/
|
|
113
|
+
async function appendOutcomeReceipt(fields) {
|
|
114
|
+
const receipt = buildOutcomeReceipt(fields);
|
|
115
|
+
try {
|
|
116
|
+
const persistence = describePersistenceMode();
|
|
117
|
+
if (!persistence.canSave) return { receipt, persisted: false };
|
|
118
|
+
const existing = await readWorkspaceSourceRecords(AGENT_OUTCOMES_SOURCE_ID);
|
|
119
|
+
const prior = Array.isArray(existing?.records) ? existing.records : [];
|
|
120
|
+
// Tamper-evidence (Paperclip pattern, scoped to what this runtime can
|
|
121
|
+
// honestly provide): server-side monotonic sequence + hash chain. Each
|
|
122
|
+
// receipt carries sha256(stableStringify(previous receipt)); a mutated
|
|
123
|
+
// or removed receipt breaks every subsequent link. No signing key /
|
|
124
|
+
// TEE exists in this runtime — that stronger anchor is named future work.
|
|
125
|
+
const last = prior.length > 0 ? prior[prior.length - 1] : null;
|
|
126
|
+
receipt.seq = (Number.isFinite(last?.seq) ? last.seq : prior.length - 1) + 1;
|
|
127
|
+
receipt.prevReceiptSha256 = last
|
|
128
|
+
? createHash("sha256").update(stableStringify(last), "utf8").digest("hex")
|
|
129
|
+
: null;
|
|
130
|
+
await writeWorkspaceSourceRecords(
|
|
131
|
+
AGENT_OUTCOMES_SOURCE_ID,
|
|
132
|
+
[...prior, receipt].slice(-MAX_RECEIPTS),
|
|
133
|
+
{ integrationId: AGENT_OUTCOMES_SOURCE_ID, fetchedAt: receipt.createdAt }
|
|
134
|
+
);
|
|
135
|
+
return { receipt, persisted: true };
|
|
136
|
+
} catch {
|
|
137
|
+
return { receipt, persisted: false };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read the stream, newest first. Returns [] when absent/unreadable. */
|
|
142
|
+
async function readOutcomeReceipts(limit = MAX_RECEIPTS) {
|
|
143
|
+
try {
|
|
144
|
+
const existing = await readWorkspaceSourceRecords(AGENT_OUTCOMES_SOURCE_ID);
|
|
145
|
+
const records = Array.isArray(existing?.records) ? existing.records : [];
|
|
146
|
+
return records.slice(-Math.max(1, limit)).reverse();
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export {
|
|
153
|
+
AGENT_OUTCOMES_SOURCE_ID,
|
|
154
|
+
appendOutcomeReceipt,
|
|
155
|
+
buildOutcomeReceipt,
|
|
156
|
+
readOutcomeReceipts
|
|
157
|
+
};
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace PATCH policy — server-authoritative guard for `PATCH /api/workspace`.
|
|
3
|
+
*
|
|
4
|
+
* `validateWorkspaceConfig` (workspace-schema.js) answers "is the merged
|
|
5
|
+
* config shaped correctly?". This module answers a different question:
|
|
6
|
+
* "is this *mutation* allowed to happen through direct PATCH at all?" —
|
|
7
|
+
* independent of whether the resulting config would validate.
|
|
8
|
+
*
|
|
9
|
+
* Enforced here, before `writeWorkspaceConfig`:
|
|
10
|
+
*
|
|
11
|
+
* 1. Allowlist — only `dashboards`, `widgetTypes`, `canvas`, `dataModel`.
|
|
12
|
+
* Bodies that look like a full workspace config get a dedicated reason.
|
|
13
|
+
* 2. `workspaceSourceRecords` never travels through PATCH (sidecar writes
|
|
14
|
+
* flow through POST /api/workspace/refresh-sources).
|
|
15
|
+
* 3. Live workflow fields on sandbox-environment rows are publish-owned.
|
|
16
|
+
* Direct PATCH may save drafts; only POST /api/workspace/workflow/publish
|
|
17
|
+
* may move a draft to the live fields, bump `version`, stamp
|
|
18
|
+
* `orchestrationPublishedAt`, append `orchestrationDeltas`, or set
|
|
19
|
+
* `lifecycleStatus: "live"`.
|
|
20
|
+
* 4. Size ceilings — oversized patches, oversized rows, oversized
|
|
21
|
+
* orchestration node configs, and history blobs smuggled into rows
|
|
22
|
+
* are rejected. Run history belongs in `growthub.source-records.json`.
|
|
23
|
+
* 5. Credential-shaped fields on sandbox rows are rejected here too, so
|
|
24
|
+
* `POST /api/workspace/patch/preflight` reports them without running
|
|
25
|
+
* full schema validation.
|
|
26
|
+
*
|
|
27
|
+
* Echo-safety: the Data Model grid and the Builder round-trip whole objects.
|
|
28
|
+
* A field that is byte-identical (stable JSON) to the currently persisted
|
|
29
|
+
* value is never a violation — only *changes* to protected fields are.
|
|
30
|
+
*
|
|
31
|
+
* Dependency-free on purpose: unit tests import this file directly
|
|
32
|
+
* (scripts/unit-workspace-patch-policy.test.mjs in the source repo).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const WORKSPACE_PATCH_ALLOWED_FIELDS = Object.freeze([
|
|
36
|
+
"dashboards",
|
|
37
|
+
"widgetTypes",
|
|
38
|
+
"canvas",
|
|
39
|
+
"dataModel"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Live workflow fields — only the publish route may change these. */
|
|
43
|
+
const LIVE_WORKFLOW_ROW_FIELDS = Object.freeze([
|
|
44
|
+
"orchestrationGraph",
|
|
45
|
+
"orchestrationConfig",
|
|
46
|
+
"orchestrationPublishedAt",
|
|
47
|
+
"orchestrationDeltas"
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** Draft workflow fields — direct PATCH may save these freely. */
|
|
51
|
+
const DRAFT_WORKFLOW_ROW_FIELDS = Object.freeze([
|
|
52
|
+
"orchestrationDraftGraph",
|
|
53
|
+
"orchestrationDraftConfig",
|
|
54
|
+
"orchestrationDraftStatus",
|
|
55
|
+
"orchestrationDraftUpdatedAt",
|
|
56
|
+
"orchestrationDraftBaseVersion",
|
|
57
|
+
"orchestrationDraftTestPassed",
|
|
58
|
+
"orchestrationDraftTestedConfig",
|
|
59
|
+
"orchestrationDraftLastRunId",
|
|
60
|
+
"orchestrationDraftLastTested",
|
|
61
|
+
"orchestrationDraftLastResponse"
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Same set the schema rejects; duplicated here so preflight can report early. */
|
|
65
|
+
const CREDENTIAL_ROW_FIELDS = Object.freeze([
|
|
66
|
+
"token",
|
|
67
|
+
"apiKey",
|
|
68
|
+
"accessToken",
|
|
69
|
+
"refreshToken",
|
|
70
|
+
"bearer",
|
|
71
|
+
"password",
|
|
72
|
+
"secret",
|
|
73
|
+
"sessionKey"
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** Row fields whose presence as a populated array means history smuggling. */
|
|
77
|
+
const HISTORY_BLOB_ROW_FIELDS = Object.freeze([
|
|
78
|
+
"records",
|
|
79
|
+
"versions",
|
|
80
|
+
"history",
|
|
81
|
+
"runHistory",
|
|
82
|
+
"sourceRecords"
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/** Top-level keys that signal "this is a whole workspace config, not a patch". */
|
|
86
|
+
const FULL_CONFIG_SIGNATURE_FIELDS = Object.freeze([
|
|
87
|
+
"id",
|
|
88
|
+
"name",
|
|
89
|
+
"description",
|
|
90
|
+
"branding",
|
|
91
|
+
"capabilities",
|
|
92
|
+
"pipelines",
|
|
93
|
+
"integrations",
|
|
94
|
+
"provenance"
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const WORKSPACE_PATCH_LIMITS = Object.freeze({
|
|
98
|
+
/** Serialized PATCH body ceiling (bytes of JSON text). */
|
|
99
|
+
maxPatchBytes: 2_000_000,
|
|
100
|
+
/** Serialized single-row ceiling, unless byte-identical to the persisted row. */
|
|
101
|
+
maxRowBytes: 131_072,
|
|
102
|
+
/** Rows per dataModel object. */
|
|
103
|
+
maxRowsPerObject: 500,
|
|
104
|
+
/** Serialized single orchestration-node config ceiling. */
|
|
105
|
+
maxNodeConfigBytes: 65_536
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** Stable stringify (sorted object keys) so echo comparison is order-proof. */
|
|
109
|
+
function stableStringify(value) {
|
|
110
|
+
if (value === undefined) return "undefined";
|
|
111
|
+
return JSON.stringify(value, function replacer(key, v) {
|
|
112
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
113
|
+
const sorted = {};
|
|
114
|
+
for (const k of Object.keys(v).sort()) sorted[k] = v[k];
|
|
115
|
+
return sorted;
|
|
116
|
+
}
|
|
117
|
+
return v;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sameValue(a, b) {
|
|
122
|
+
return stableStringify(a) === stableStringify(b);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isPlainObject(value) {
|
|
126
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rowName(row) {
|
|
130
|
+
// Sandbox row identity is the Data Model grid's capital-N `Name` column.
|
|
131
|
+
return String(row?.Name ?? "").trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function violation(code, path, message) {
|
|
135
|
+
return { code, path, message };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Try to parse an orchestration graph value (string or object) far enough
|
|
140
|
+
* to measure node configs. Returns null when not parseable — schema
|
|
141
|
+
* validation owns deep correctness; the policy only measures size.
|
|
142
|
+
*/
|
|
143
|
+
function parseGraphForMeasurement(value) {
|
|
144
|
+
if (isPlainObject(value)) return value;
|
|
145
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(value);
|
|
148
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function checkRowSizes(row, currentRow, path, violations) {
|
|
155
|
+
const serialized = stableStringify(row);
|
|
156
|
+
if (serialized.length > WORKSPACE_PATCH_LIMITS.maxRowBytes && !sameValue(row, currentRow)) {
|
|
157
|
+
violations.push(violation(
|
|
158
|
+
"oversized_row",
|
|
159
|
+
path,
|
|
160
|
+
`row serializes to ${serialized.length} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxRowBytes}); ` +
|
|
161
|
+
"move bulk payloads to source records or split the row"
|
|
162
|
+
));
|
|
163
|
+
}
|
|
164
|
+
for (const field of HISTORY_BLOB_ROW_FIELDS) {
|
|
165
|
+
const value = row[field];
|
|
166
|
+
if (Array.isArray(value) && value.length > 0 && !sameValue(value, currentRow?.[field])) {
|
|
167
|
+
violations.push(violation(
|
|
168
|
+
"history_smuggling",
|
|
169
|
+
`${path}.${field}`,
|
|
170
|
+
`${field} is a populated array — run/record history belongs in growthub.source-records.json ` +
|
|
171
|
+
"(written by sandbox-run / refresh-sources), never inside dataModel rows"
|
|
172
|
+
));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const graphField of ["orchestrationGraph", "orchestrationConfig", "orchestrationDraftGraph", "orchestrationDraftConfig"]) {
|
|
176
|
+
if (sameValue(row[graphField], currentRow?.[graphField])) continue;
|
|
177
|
+
const graph = parseGraphForMeasurement(row[graphField]);
|
|
178
|
+
if (!graph || !Array.isArray(graph.nodes)) continue;
|
|
179
|
+
graph.nodes.forEach((node, nodeIndex) => {
|
|
180
|
+
const config = node?.config;
|
|
181
|
+
if (!isPlainObject(config)) return;
|
|
182
|
+
const size = stableStringify(config).length;
|
|
183
|
+
if (size > WORKSPACE_PATCH_LIMITS.maxNodeConfigBytes) {
|
|
184
|
+
violations.push(violation(
|
|
185
|
+
"oversized_node_config",
|
|
186
|
+
`${path}.${graphField}.nodes[${nodeIndex}].config`,
|
|
187
|
+
`node config serializes to ${size} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxNodeConfigBytes}); ` +
|
|
188
|
+
"reference large payloads through source records or env refs instead of inlining them"
|
|
189
|
+
));
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function checkSandboxRow(row, currentRow, path, violations) {
|
|
196
|
+
for (const field of CREDENTIAL_ROW_FIELDS) {
|
|
197
|
+
if (row[field] !== undefined) {
|
|
198
|
+
violations.push(violation(
|
|
199
|
+
"credential_field",
|
|
200
|
+
`${path}.${field}`,
|
|
201
|
+
`${field} is not allowed on a sandbox row — auth secrets must stay in the local CLI's own store; ` +
|
|
202
|
+
"rows carry authRef / env-ref names only"
|
|
203
|
+
));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const isNewRow = !currentRow;
|
|
208
|
+
for (const field of LIVE_WORKFLOW_ROW_FIELDS) {
|
|
209
|
+
const incoming = row[field];
|
|
210
|
+
if (isNewRow) {
|
|
211
|
+
const populated = Array.isArray(incoming)
|
|
212
|
+
? incoming.length > 0
|
|
213
|
+
: incoming !== undefined && incoming !== null && String(incoming).trim() !== "";
|
|
214
|
+
if (populated) {
|
|
215
|
+
violations.push(violation(
|
|
216
|
+
"live_workflow_field",
|
|
217
|
+
`${path}.${field}`,
|
|
218
|
+
`${field} may not be created through direct PATCH — save it as a draft ` +
|
|
219
|
+
"(orchestrationDraft*) and promote it through POST /api/workspace/workflow/publish"
|
|
220
|
+
));
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!sameValue(incoming, currentRow[field])) {
|
|
225
|
+
violations.push(violation(
|
|
226
|
+
"live_workflow_field",
|
|
227
|
+
`${path}.${field}`,
|
|
228
|
+
`${field} is publish-owned — direct PATCH may only echo the persisted value; ` +
|
|
229
|
+
"use POST /api/workspace/workflow/publish to change the live workflow"
|
|
230
|
+
));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!isNewRow && row.version !== undefined && !sameValue(row.version, currentRow.version)) {
|
|
235
|
+
violations.push(violation(
|
|
236
|
+
"live_workflow_field",
|
|
237
|
+
`${path}.version`,
|
|
238
|
+
"version increments are publish-owned — direct PATCH may only echo the persisted version"
|
|
239
|
+
));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const incomingStatus = String(row.lifecycleStatus ?? "").trim().toLowerCase();
|
|
243
|
+
const currentStatus = String(currentRow?.lifecycleStatus ?? "").trim().toLowerCase();
|
|
244
|
+
if (incomingStatus === "live" && currentStatus !== "live") {
|
|
245
|
+
violations.push(violation(
|
|
246
|
+
"live_publish_via_patch",
|
|
247
|
+
`${path}.lifecycleStatus`,
|
|
248
|
+
'lifecycleStatus: "live" is publish-owned — POST /api/workspace/workflow/publish is the only ' +
|
|
249
|
+
"transition into live; direct PATCH may keep a live row live or move it back to draft"
|
|
250
|
+
));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function checkDataModel(dataModel, currentConfig, violations) {
|
|
255
|
+
if (dataModel === undefined) return;
|
|
256
|
+
if (!isPlainObject(dataModel) || (dataModel.objects !== undefined && !Array.isArray(dataModel.objects))) {
|
|
257
|
+
// Shape errors are the validator's domain; the policy stops here so the
|
|
258
|
+
// two layers never disagree about the same malformed input.
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const currentObjects = Array.isArray(currentConfig?.dataModel?.objects)
|
|
262
|
+
? currentConfig.dataModel.objects
|
|
263
|
+
: [];
|
|
264
|
+
const currentById = new Map(currentObjects.map((o) => [String(o?.id ?? ""), o]));
|
|
265
|
+
|
|
266
|
+
(dataModel.objects ?? []).forEach((object, objectIndex) => {
|
|
267
|
+
if (!isPlainObject(object)) return;
|
|
268
|
+
const path = `dataModel.objects[${objectIndex}]`;
|
|
269
|
+
const currentObject = currentById.get(String(object.id ?? "")) ?? null;
|
|
270
|
+
const rows = Array.isArray(object.rows) ? object.rows : [];
|
|
271
|
+
|
|
272
|
+
if (rows.length > WORKSPACE_PATCH_LIMITS.maxRowsPerObject) {
|
|
273
|
+
violations.push(violation(
|
|
274
|
+
"oversized_object",
|
|
275
|
+
`${path}.rows`,
|
|
276
|
+
`${rows.length} rows exceeds the ${WORKSPACE_PATCH_LIMITS.maxRowsPerObject}-row ceiling per object; ` +
|
|
277
|
+
"page bulk data through source records instead"
|
|
278
|
+
));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const objectType = String(object.objectType ?? currentObject?.objectType ?? "").trim();
|
|
282
|
+
const currentRows = Array.isArray(currentObject?.rows) ? currentObject.rows : [];
|
|
283
|
+
const currentRowsByName = new Map(
|
|
284
|
+
currentRows.filter((r) => rowName(r)).map((r) => [rowName(r), r])
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
rows.forEach((row, rowIndex) => {
|
|
288
|
+
if (!isPlainObject(row)) return;
|
|
289
|
+
const rowPath = `${path}.rows[${rowIndex}]`;
|
|
290
|
+
const currentRow = rowName(row) ? currentRowsByName.get(rowName(row)) ?? null : null;
|
|
291
|
+
checkRowSizes(row, currentRow, rowPath, violations);
|
|
292
|
+
if (objectType === "sandbox-environment") {
|
|
293
|
+
checkSandboxRow(row, currentRow, rowPath, violations);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Evaluate a PATCH body against the mutation policy.
|
|
301
|
+
*
|
|
302
|
+
* @param {object|null} currentConfig — currently persisted workspace config.
|
|
303
|
+
* @param {unknown} patch — the incoming PATCH body, exactly as received.
|
|
304
|
+
* @returns {{ ok: boolean, violations: Array<{code: string, path: string, message: string}> }}
|
|
305
|
+
*/
|
|
306
|
+
function evaluateWorkspacePatchPolicy(currentConfig, patch) {
|
|
307
|
+
const violations = [];
|
|
308
|
+
|
|
309
|
+
if (!isPlainObject(patch)) {
|
|
310
|
+
violations.push(violation("invalid_body", "", "patch must be a plain object"));
|
|
311
|
+
return { ok: false, violations };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const unknown = Object.keys(patch).filter((key) => !WORKSPACE_PATCH_ALLOWED_FIELDS.includes(key));
|
|
315
|
+
if (unknown.includes("workspaceSourceRecords")) {
|
|
316
|
+
violations.push(violation(
|
|
317
|
+
"source_records_through_patch",
|
|
318
|
+
"workspaceSourceRecords",
|
|
319
|
+
"workspaceSourceRecords is GET-only hydration — sidecar writes flow through " +
|
|
320
|
+
"POST /api/workspace/refresh-sources, never through PATCH"
|
|
321
|
+
));
|
|
322
|
+
}
|
|
323
|
+
const signatureHits = unknown.filter((key) => FULL_CONFIG_SIGNATURE_FIELDS.includes(key));
|
|
324
|
+
if (signatureHits.length >= 2) {
|
|
325
|
+
violations.push(violation(
|
|
326
|
+
"full_config_body",
|
|
327
|
+
"",
|
|
328
|
+
`body carries whole-config fields (${signatureHits.join(", ")}) — never PATCH the full ` +
|
|
329
|
+
"workspace config back; send only the changed allowlisted key(s)"
|
|
330
|
+
));
|
|
331
|
+
}
|
|
332
|
+
for (const key of unknown) {
|
|
333
|
+
if (key === "workspaceSourceRecords") continue;
|
|
334
|
+
violations.push(violation(
|
|
335
|
+
"unknown_field",
|
|
336
|
+
key,
|
|
337
|
+
`${key} is outside the permanent PATCH allowlist (${WORKSPACE_PATCH_ALLOWED_FIELDS.join(", ")})`
|
|
338
|
+
));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const serialized = stableStringify(patch);
|
|
342
|
+
if (serialized.length > WORKSPACE_PATCH_LIMITS.maxPatchBytes) {
|
|
343
|
+
violations.push(violation(
|
|
344
|
+
"oversized_patch",
|
|
345
|
+
"",
|
|
346
|
+
`patch serializes to ${serialized.length} bytes (limit ${WORKSPACE_PATCH_LIMITS.maxPatchBytes})`
|
|
347
|
+
));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
checkDataModel(patch.dataModel, currentConfig, violations);
|
|
351
|
+
|
|
352
|
+
return { ok: violations.length === 0, violations };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Repair guidance — one governed alternative per violation code, so a
|
|
357
|
+
* rejection teaches self-correction instead of provoking retry loops.
|
|
358
|
+
* Returned by patch/preflight (`repairPlan[]`) and the PATCH 422 envelope.
|
|
359
|
+
*/
|
|
360
|
+
const REPAIR_PLANS = Object.freeze({
|
|
361
|
+
invalid_body: "Send a single JSON object containing only changed allowlisted keys.",
|
|
362
|
+
unknown_field: `Remove keys outside the allowlist (${WORKSPACE_PATCH_ALLOWED_FIELDS.join(", ")}); other config fields are read-only through this API.`,
|
|
363
|
+
full_config_body: "Never PATCH the whole workspace config back — GET first, then send only the changed allowlisted key(s).",
|
|
364
|
+
source_records_through_patch: "Write source records through POST /api/workspace/refresh-sources (or let sandbox-run persist run records); PATCH never touches the sidecar.",
|
|
365
|
+
oversized_patch: "Split the change into smaller PATCHes per allowlisted key, and move bulk data into source records.",
|
|
366
|
+
oversized_object: "Page bulk rows through source records; keep dataModel objects under the row ceiling.",
|
|
367
|
+
oversized_row: "Move bulk payloads into source records and store only a sourceId/reference on the row.",
|
|
368
|
+
oversized_node_config: "Reference large payloads from node configs via source records or env refs instead of inlining them.",
|
|
369
|
+
history_smuggling: "Run/record history lives in growthub.source-records.json (written by sandbox-run / refresh-sources) — store only lastRunId/lastSourceId on the row.",
|
|
370
|
+
credential_field: "Store the secret in the local CLI's own store and reference it via authRef / envRefs names only.",
|
|
371
|
+
live_workflow_field: "Move the graph into orchestrationDraftConfig (or orchestrationDraftGraph), prove it with POST /api/workspace/sandbox-run {useDraft:true}, then promote it with POST /api/workspace/workflow/publish.",
|
|
372
|
+
live_publish_via_patch: "lifecycleStatus \"live\" is set only by POST /api/workspace/workflow/publish after a verified draft test."
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
/** Ordered, deduplicated repair steps for a violation list. */
|
|
376
|
+
function repairPlanForViolations(violations) {
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const plan = [];
|
|
379
|
+
for (const v of Array.isArray(violations) ? violations : []) {
|
|
380
|
+
const step = REPAIR_PLANS[v?.code];
|
|
381
|
+
if (step && !seen.has(step)) {
|
|
382
|
+
seen.add(step);
|
|
383
|
+
plan.push(step);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return plan;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export {
|
|
390
|
+
CREDENTIAL_ROW_FIELDS,
|
|
391
|
+
DRAFT_WORKFLOW_ROW_FIELDS,
|
|
392
|
+
FULL_CONFIG_SIGNATURE_FIELDS,
|
|
393
|
+
HISTORY_BLOB_ROW_FIELDS,
|
|
394
|
+
LIVE_WORKFLOW_ROW_FIELDS,
|
|
395
|
+
WORKSPACE_PATCH_ALLOWED_FIELDS,
|
|
396
|
+
WORKSPACE_PATCH_LIMITS,
|
|
397
|
+
evaluateWorkspacePatchPolicy,
|
|
398
|
+
repairPlanForViolations,
|
|
399
|
+
stableStringify
|
|
400
|
+
};
|
|
@@ -1044,6 +1044,12 @@ function validateSandboxEnvironmentRow(row, path, errors) {
|
|
|
1044
1044
|
if (row.allowList !== undefined && typeof row.allowList !== "string" && !Array.isArray(row.allowList)) {
|
|
1045
1045
|
errors.push(`${path}.allowList must be a comma-separated string or array of hostnames`);
|
|
1046
1046
|
}
|
|
1047
|
+
if (row.browserAccess !== undefined) {
|
|
1048
|
+
const value = String(row.browserAccess).trim().toLowerCase();
|
|
1049
|
+
if (!["", "true", "false", "0", "1", "on", "off"].includes(value)) {
|
|
1050
|
+
errors.push(`${path}.browserAccess must coerce to a boolean (true/false/on/off)`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1047
1053
|
if (row.instructions !== undefined && typeof row.instructions !== "string") {
|
|
1048
1054
|
errors.push(`${path}.instructions must be a string`);
|
|
1049
1055
|
}
|
|
@@ -69,6 +69,7 @@ const SWARM_EXECUTION_TARGET_FIELDS = [
|
|
|
69
69
|
"timeoutMs",
|
|
70
70
|
"networkAllow",
|
|
71
71
|
"allowList",
|
|
72
|
+
"browserAccess",
|
|
72
73
|
];
|
|
73
74
|
|
|
74
75
|
function clean(value) {
|
|
@@ -152,6 +153,7 @@ function resolveSwarmExecutionTarget(workspaceConfig, payload = {}) {
|
|
|
152
153
|
timeoutMs: String(clampPositiveInt(payload?.timeoutMs || helperRow?.timeoutMs, SWARM_DEFAULT_TIMEOUT_MS)),
|
|
153
154
|
networkAllow: clean(payload?.networkAllow || helperRow?.networkAllow),
|
|
154
155
|
allowList: clean(payload?.allowList || helperRow?.allowList),
|
|
156
|
+
browserAccess: clean(payload?.browserAccess || helperRow?.browserAccess),
|
|
155
157
|
inheritedFromObjectId: helperRow ? WORKSPACE_HELPER_SANDBOX_OBJECT_ID : "",
|
|
156
158
|
inheritedFromName: helperRow ? clean(helperRow.Name || WORKSPACE_HELPER_ROW_NAME) : "",
|
|
157
159
|
};
|
|
@@ -406,6 +408,7 @@ function buildSandboxRowFromSwarmProposal(workspaceConfig, proposal) {
|
|
|
406
408
|
envRefs: "",
|
|
407
409
|
networkAllow: executionTarget.networkAllow,
|
|
408
410
|
allowList: executionTarget.allowList,
|
|
411
|
+
browserAccess: executionTarget.browserAccess,
|
|
409
412
|
instructions: clean(payload.objective),
|
|
410
413
|
command: "",
|
|
411
414
|
timeoutMs: executionTarget.timeoutMs,
|