log-llm-config 1.2.8 → 1.3.2
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/dist/apply_deferred_vscdb.js +8 -0
- package/dist/cli/bash_script_generator.js +23 -11
- package/dist/compliance_check_runner.js +31 -0
- package/dist/compliance_prompt_gate.js +64 -8
- package/dist/log_config_files/readers/vscdb_config_builder.js +6 -2
- package/dist/log_config_files/readers/vscdb_reader.js +102 -1
- package/dist/log_config_files/runtime/compliance_check.js +147 -59
- package/dist/log_config_files/runtime/hardware_uuid.js +9 -0
- package/dist/log_config_files/runtime/hook_logger.js +38 -5
- package/dist/log_config_files/runtime/main_runner.js +2 -0
- package/dist/log_config_files/runtime/management_storage.js +31 -0
- package/dist/log_config_files/runtime/remediation_sync.js +709 -19
- package/package.json +7 -5
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs after Cursor SIGKILL: applies queued state.vscdb patches from
|
|
3
|
+
* ~/opt-ai-sec/management/optimus_deferred_vscdb_apply.json (see remediation_sync).
|
|
4
|
+
*/
|
|
5
|
+
import { applyDeferredVscdbFromDisk } from './log_config_files/runtime/remediation_sync.js';
|
|
6
|
+
applyDeferredVscdbFromDisk()
|
|
7
|
+
.then((ok) => process.exit(ok ? 0 : 1))
|
|
8
|
+
.catch(() => process.exit(1));
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { readFileCollectionVscdbContract } from '../log_config_files/runtime/management_storage.js';
|
|
2
|
+
/** Reactive ItemTable key from backend-derived cache (written on log-config); same path as remediations. */
|
|
3
|
+
function readReactiveStorageItemKeyFromDisk() {
|
|
4
|
+
const k = readFileCollectionVscdbContract()?.reactive_storage_item_key;
|
|
5
|
+
return typeof k === 'string' && k.trim() !== '' ? k.trim() : null;
|
|
6
|
+
}
|
|
1
7
|
const fileCategories = [
|
|
2
8
|
{ label: 'Cursor: mcp.json', targets: ['./.cursor/mcp.json', '$HOME/.cursor/mcp.json'] },
|
|
3
9
|
{ label: 'Claude: .mcp.json', targets: ['./mcp.json', './.mcp.json', '/Library/Application Support/ClaudeCode/managed-mcp.json'] },
|
|
@@ -5,15 +11,20 @@ const fileCategories = [
|
|
|
5
11
|
{ label: 'Cursor: hooks.json', targets: ['./.cursor/hooks.json', '$HOME/.cursor/hooks.json', '/Library/Application Support/Cursor/hooks.json'] },
|
|
6
12
|
{ label: 'Cursor: User/settings.json (user-level)', targets: ['$HOME/Library/Application Support/Cursor/User/settings.json'] },
|
|
7
13
|
];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
function buildSqliteCategories() {
|
|
15
|
+
const key = readReactiveStorageItemKeyFromDisk();
|
|
16
|
+
if (!key)
|
|
17
|
+
return [];
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
label: 'Cursor: state.vscdb (reactive blob / composerState)',
|
|
21
|
+
dbPath: '$HOME/Library/Application Support/Cursor/User/globalStorage/state.vscdb',
|
|
22
|
+
table: 'ItemTable',
|
|
23
|
+
key,
|
|
24
|
+
jsonPaths: [['composerState'], []],
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
}
|
|
17
28
|
function buildFileCategoryLines(category) {
|
|
18
29
|
return [
|
|
19
30
|
`echo "===== ${category.label} ====="`,
|
|
@@ -38,12 +49,13 @@ function buildFileCategoryLines(category) {
|
|
|
38
49
|
}
|
|
39
50
|
function buildSqliteCategoryLines(category) {
|
|
40
51
|
const jsonPathsLiteral = JSON.stringify(category.jsonPaths);
|
|
52
|
+
const safeSqlKey = category.key.replace(/'/g, "''");
|
|
41
53
|
return [
|
|
42
54
|
`echo "===== ${category.label} ====="`, `db_path="${category.dbPath}"`,
|
|
43
55
|
'expanded=$(eval echo "$db_path")',
|
|
44
56
|
'if [ -f "$expanded" ]; then', ' if command -v sqlite3 >/dev/null 2>&1; then',
|
|
45
57
|
' echo "===== $expanded ====="',
|
|
46
|
-
` query_result=$(sqlite3 "$expanded" "SELECT value FROM ${category.table} WHERE key='${
|
|
58
|
+
` query_result=$(sqlite3 "$expanded" "SELECT value FROM ${category.table} WHERE key='${safeSqlKey}'")`,
|
|
47
59
|
' if [ -n "$query_result" ]; then',
|
|
48
60
|
` echo "===== key: ${category.key} ====="`,
|
|
49
61
|
` LOG_CONFIG_JSON_VALUE="$query_result" python3 - <<'PY'`,
|
|
@@ -76,7 +88,7 @@ function renderBashScript() {
|
|
|
76
88
|
' local suffix="${path#"$repo_root"}"', ' if [ -z "$suffix" ]; then', ' echo "<project_root>"',
|
|
77
89
|
' else', ' echo "<project_root>${suffix}"', ' fi', ' else', ' echo "$path"', ' fi', '}', '',
|
|
78
90
|
...fileCategories.flatMap(buildFileCategoryLines),
|
|
79
|
-
...
|
|
91
|
+
...buildSqliteCategories().flatMap(buildSqliteCategoryLines),
|
|
80
92
|
];
|
|
81
93
|
return scriptLines.join('\n');
|
|
82
94
|
}
|
|
@@ -1,9 +1,40 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { OPT_AI_SEC_MANAGEMENT_REL } from './bootstrap_constants.js';
|
|
5
|
+
import { hookLogSessionBanner } from './log_config_files/runtime/hook_logger.js';
|
|
1
6
|
import { runComplianceCheck } from './log_config_files/runtime/compliance_check.js';
|
|
7
|
+
/** Append-only log for compliance runner lifecycle; hook_log.txt is also append-only (session banners). */
|
|
8
|
+
function runnerFileLog(message) {
|
|
9
|
+
try {
|
|
10
|
+
const dir = join(homedir(), OPT_AI_SEC_MANAGEMENT_REL);
|
|
11
|
+
if (!existsSync(dir))
|
|
12
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
const path = join(dir, 'compliance_runner.log');
|
|
14
|
+
appendFileSync(path, `${new Date().toISOString()} ${message}\n`, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* ignore */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
(async () => {
|
|
21
|
+
hookLogSessionBanner('compliance_check_runner (background sync + check)');
|
|
22
|
+
runnerFileLog('compliance_check_runner: start');
|
|
3
23
|
try {
|
|
4
24
|
await runComplianceCheck();
|
|
25
|
+
runnerFileLog('compliance_check_runner: finished ok');
|
|
5
26
|
}
|
|
6
27
|
catch (err) {
|
|
28
|
+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
29
|
+
runnerFileLog('compliance_check_runner: uncaught error');
|
|
30
|
+
try {
|
|
31
|
+
const dir = join(homedir(), OPT_AI_SEC_MANAGEMENT_REL);
|
|
32
|
+
const path = join(dir, 'compliance_runner.log');
|
|
33
|
+
appendFileSync(path, `${detail}\n\n`, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
7
38
|
process.stderr.write(`compliance_check_runner error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
8
39
|
process.exit(1);
|
|
9
40
|
}
|
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { applyAutofixViolations, pruneSatisfiedOneTimeRemediations, runLocalRemediationComplianceCheck, } from './log_config_files/runtime/compliance_check.js';
|
|
6
6
|
import { existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
7
10
|
import { getRemediationInstructionsPath } from './log_config_files/runtime/management_storage.js';
|
|
11
|
+
import { hookLogSessionBanner, hookRunLog } from './log_config_files/runtime/hook_logger.js';
|
|
8
12
|
const MANIFEST_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Set by optimus-compliance-check.sh only for Claude Desktop (dumb TERM). Hook runs allowlisted restart after osascript. */
|
|
14
|
+
function claudeDesktopHookRunsRestart() {
|
|
15
|
+
const v = process.env.OPTIMUS_CLAUDE_DESKTOP_DEFER_GATE_RESTART?.trim().toLowerCase();
|
|
16
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
17
|
+
}
|
|
9
18
|
function parseIde() {
|
|
10
19
|
const eq = process.argv.find((a) => a.startsWith('--ide='));
|
|
11
20
|
if (eq) {
|
|
@@ -42,6 +51,13 @@ function blockPayload(ide, violationMessage) {
|
|
|
42
51
|
}
|
|
43
52
|
return JSON.stringify({ continue: false, user_message: text });
|
|
44
53
|
}
|
|
54
|
+
function fireRestartCommands(commands) {
|
|
55
|
+
for (const cmd of commands) {
|
|
56
|
+
hookRunLog(`restart: firing command="${cmd}"`);
|
|
57
|
+
const child = spawn('sh', ['-c', cmd], { detached: true, stdio: 'ignore' });
|
|
58
|
+
child.unref();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
45
61
|
function getManifestStalenessMs() {
|
|
46
62
|
try {
|
|
47
63
|
const path = getRemediationInstructionsPath();
|
|
@@ -54,8 +70,33 @@ function getManifestStalenessMs() {
|
|
|
54
70
|
return null;
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
function isRunAsCliModule() {
|
|
74
|
+
const entry = process.argv[1];
|
|
75
|
+
if (!entry)
|
|
76
|
+
return false;
|
|
77
|
+
try {
|
|
78
|
+
return import.meta.url === pathToFileURL(resolve(entry)).href;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Short line for success dialog: finding title/sentence from manifest, not per-check remediation technical text. */
|
|
85
|
+
function autofixDialogLine(v) {
|
|
86
|
+
const title = v.finding_title?.trim();
|
|
87
|
+
if (title)
|
|
88
|
+
return `• [${v.finding_formatted_id}] ${title}`;
|
|
89
|
+
const fd = v.finding_description?.trim();
|
|
90
|
+
if (fd)
|
|
91
|
+
return `• [${v.finding_formatted_id}] ${fd}`;
|
|
92
|
+
const d = v.description.trim();
|
|
93
|
+
const short = d.length > 160 ? `${d.slice(0, 157)}…` : d;
|
|
94
|
+
return `• [${v.finding_formatted_id}] ${short}`;
|
|
95
|
+
}
|
|
96
|
+
/** Exported for tests; CLI invokes this only when {@link isRunAsCliModule} is true. */
|
|
97
|
+
export async function runCompliancePromptGate() {
|
|
98
|
+
const ide = parseIde();
|
|
99
|
+
hookLogSessionBanner('compliance_prompt_gate (before submit)');
|
|
59
100
|
const status = runLocalRemediationComplianceCheck();
|
|
60
101
|
if (status.status === 'fail' && status.violations.length > 0) {
|
|
61
102
|
const staleMs = getManifestStalenessMs();
|
|
@@ -66,19 +107,32 @@ async function run() {
|
|
|
66
107
|
printAllowWithAdvisory(ide, advisory);
|
|
67
108
|
return;
|
|
68
109
|
}
|
|
69
|
-
const { fixed, restartCommands, failedViolations, reportPromises } = applyAutofixViolations(status.violations);
|
|
110
|
+
const { fixed, appliedViolations = [], restartCommands, failedViolations, reportPromises, deferredSqlitePending, } = applyAutofixViolations(status.violations);
|
|
70
111
|
if (fixed > 0) {
|
|
71
112
|
// Wait for all server reports before exiting so the POST lands.
|
|
72
113
|
await Promise.allSettled(reportPromises);
|
|
114
|
+
// Deferred SQLite can leave recheck failing until restart; that must not hide a separate
|
|
115
|
+
// failed autofix (e.g. JSON remediation failed while vscdb was only queued).
|
|
116
|
+
if (failedViolations.length > 0) {
|
|
117
|
+
const ids = failedViolations.map((v) => `[${v.finding_formatted_id}]`).join(', ');
|
|
118
|
+
const msg = `Auto-fix failed for ${ids} — please fix manually or contact your security team.\n\n${failedViolations[0]?.message ?? ''}`;
|
|
119
|
+
console.log(blockPayload(ide, msg));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
73
122
|
const recheck = runLocalRemediationComplianceCheck();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const autofixMessage = `Optimus Security auto-fixed ${fixed} policy violation(s):\n${lines.join('\n')}`;
|
|
123
|
+
const recheckOk = recheck.status === 'ok' || recheck.violations.length === 0;
|
|
124
|
+
if (deferredSqlitePending || recheckOk) {
|
|
125
|
+
const autofixMessage = `Optimus Security auto-fixed ${fixed} policy violation(s):\n${appliedViolations.map((v) => autofixDialogLine(v)).join('\n')}`;
|
|
78
126
|
const payload = { __optimus_autofix: true, autofix_message: autofixMessage };
|
|
79
127
|
if (restartCommands.length > 0)
|
|
80
128
|
payload.restart_commands = restartCommands;
|
|
81
129
|
console.log(JSON.stringify(payload));
|
|
130
|
+
// Cursor: .cursor/hooks runs restart after the osascript dialog — avoid double SIGKILL here.
|
|
131
|
+
// Claude Desktop: same — optimus-compliance-check.sh shows the dialog then evals allowlisted restarts.
|
|
132
|
+
// Claude Code (CLI): no shell restart path; gate must spawn here.
|
|
133
|
+
const fireInGate = ide !== 'cursor' && !(ide === 'claude' && claudeDesktopHookRunsRestart());
|
|
134
|
+
if (fireInGate)
|
|
135
|
+
fireRestartCommands(restartCommands);
|
|
82
136
|
return;
|
|
83
137
|
}
|
|
84
138
|
const msg = recheck.violations[0]?.message ?? 'A security policy violation has been detected.';
|
|
@@ -102,4 +156,6 @@ async function run() {
|
|
|
102
156
|
}
|
|
103
157
|
printAllow(ide);
|
|
104
158
|
}
|
|
105
|
-
|
|
159
|
+
if (isRunAsCliModule()) {
|
|
160
|
+
runCompliancePromptGate().catch(() => printAllow(parseIde())).finally(() => process.exit(0));
|
|
161
|
+
}
|
|
@@ -81,8 +81,12 @@ function buildVscdbRawContentFromSpec(state, spec) {
|
|
|
81
81
|
if (!Array.isArray(value) || value.length === 0)
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
|
-
if (spec.value_constraint === 'boolean'
|
|
85
|
-
|
|
84
|
+
if (spec.value_constraint === 'boolean') {
|
|
85
|
+
const okBool = typeof value === 'boolean';
|
|
86
|
+
const okSqliteInt = typeof value === 'number' && (value === 0 || value === 1);
|
|
87
|
+
if (!okBool && !okSqliteInt)
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
86
90
|
return buildRawContent(state, spec.state_key, value, spec.include_keys, new Date().toISOString());
|
|
87
91
|
}
|
|
88
92
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
|
+
import { readFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
|
|
3
4
|
function querySqlite(dbPath, key) {
|
|
4
5
|
const safe = key.replace(/'/g, "''");
|
|
5
6
|
return execSync(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='${safe}'"`, {
|
|
@@ -7,6 +8,98 @@ function querySqlite(dbPath, key) {
|
|
|
7
8
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8
9
|
}).trim();
|
|
9
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Fallback if the API omits vscdb_composer_contract (older server): derive from vscdb_read_queries.
|
|
13
|
+
*/
|
|
14
|
+
export function parseVscdbComposerContractFromReadQueries(queries) {
|
|
15
|
+
const empty = {
|
|
16
|
+
version: 1,
|
|
17
|
+
reactive_storage_item_key: undefined,
|
|
18
|
+
composer_shadow_keys: [],
|
|
19
|
+
};
|
|
20
|
+
if (!queries?.length)
|
|
21
|
+
return empty;
|
|
22
|
+
for (const step of queries) {
|
|
23
|
+
if (step.value_kind === 'composer_with_include' && step.state_key === 'composerState') {
|
|
24
|
+
const keys = step.item_table_keys ?? [];
|
|
25
|
+
return {
|
|
26
|
+
version: 1,
|
|
27
|
+
reactive_storage_item_key: keys[0],
|
|
28
|
+
composer_shadow_keys: [...(step.include_keys ?? [])],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return empty;
|
|
33
|
+
}
|
|
34
|
+
export function normalizeVscdbComposerContractFromPatternsResponse(resp) {
|
|
35
|
+
const c = resp.vscdb_composer_contract;
|
|
36
|
+
const hasKey = c != null &&
|
|
37
|
+
((typeof c.reactive_storage_item_key === 'string' && c.reactive_storage_item_key.trim() !== '') ||
|
|
38
|
+
(c.composer_shadow_keys?.length ?? 0) > 0);
|
|
39
|
+
if (hasKey && c) {
|
|
40
|
+
return {
|
|
41
|
+
version: 1,
|
|
42
|
+
reactive_storage_item_key: c.reactive_storage_item_key ?? undefined,
|
|
43
|
+
composer_shadow_keys: [...(c.composer_shadow_keys ?? [])],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return parseVscdbComposerContractFromReadQueries(resp.vscdb_read_queries);
|
|
47
|
+
}
|
|
48
|
+
/** Persist backend-derived contract next to remediation_instructions.json. */
|
|
49
|
+
export function persistVscdbComposerContractFromPatternsResponse(resp) {
|
|
50
|
+
const norm = normalizeVscdbComposerContractFromPatternsResponse(resp);
|
|
51
|
+
if (norm.reactive_storage_item_key || norm.composer_shadow_keys.length > 0) {
|
|
52
|
+
writeFileCollectionVscdbContract(norm);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read one ItemTable JSON blob (e.g. key `composerState`) and return `{ [itemKey]: object }` so
|
|
57
|
+
* dot-paths like `composerState.modes4.agent.autoRun` (or legacy numeric `modes4.0`) work in compliance checks. Empty / missing value
|
|
58
|
+
* yields `{ [itemKey]: {} }`. Returns null if the DB is missing, sqlite3 is unavailable, or JSON parse fails.
|
|
59
|
+
*
|
|
60
|
+
* When `file_collection_vscdb_contract.json` lists `reactive_storage_item_key`, a missing legacy `composerState` row
|
|
61
|
+
* falls back to nested `composerState` inside that reactive blob (paths from the backend API only).
|
|
62
|
+
*/
|
|
63
|
+
export function readVscdbItemTableJson(dbPath, itemKey) {
|
|
64
|
+
try {
|
|
65
|
+
if (!dbPath || !existsSync(dbPath))
|
|
66
|
+
return null;
|
|
67
|
+
execSync('which sqlite3', { stdio: 'ignore' });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const raw = querySqlite(dbPath, itemKey);
|
|
74
|
+
const contract = readFileCollectionVscdbContract();
|
|
75
|
+
const reactiveKey = typeof contract?.reactive_storage_item_key === 'string' ? contract.reactive_storage_item_key.trim() : '';
|
|
76
|
+
if (itemKey === 'composerState' && (!raw || raw === '{}') && reactiveKey) {
|
|
77
|
+
const reactive = querySqlite(dbPath, reactiveKey);
|
|
78
|
+
if (reactive) {
|
|
79
|
+
const root = JSON.parse(reactive);
|
|
80
|
+
const cs = root.composerState;
|
|
81
|
+
if (cs !== null && typeof cs === 'object' && !Array.isArray(cs)) {
|
|
82
|
+
return { composerState: cs };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return { [itemKey]: {} };
|
|
88
|
+
}
|
|
89
|
+
const parsed = JSON.parse(raw);
|
|
90
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
91
|
+
return { [itemKey]: parsed };
|
|
92
|
+
}
|
|
93
|
+
// Bare JSON primitives (e.g. cursor/thirdPartyExtensibilityEnabled stores `true`/`false`).
|
|
94
|
+
if (typeof parsed === 'boolean' || typeof parsed === 'number' || typeof parsed === 'string') {
|
|
95
|
+
return { [itemKey]: parsed };
|
|
96
|
+
}
|
|
97
|
+
return { [itemKey]: {} };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
10
103
|
function setNested(obj, dotPath, value) {
|
|
11
104
|
const parts = dotPath.split('.');
|
|
12
105
|
const safe = (k) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
|
|
@@ -42,9 +135,17 @@ function runOneStep(dbPath, stateData, step) {
|
|
|
42
135
|
stateData.composerState = composerState;
|
|
43
136
|
}
|
|
44
137
|
if (step.include_keys?.length) {
|
|
138
|
+
const nested = composerState && typeof composerState === 'object'
|
|
139
|
+
? composerState
|
|
140
|
+
: undefined;
|
|
45
141
|
for (const k of step.include_keys) {
|
|
46
|
-
|
|
142
|
+
// Prefer the value inside composerState when present; blob root can be stale vs the UI.
|
|
143
|
+
if (nested && k in nested && nested[k] !== undefined) {
|
|
144
|
+
stateData[k] = nested[k];
|
|
145
|
+
}
|
|
146
|
+
else if (k in obj && obj[k] !== undefined) {
|
|
47
147
|
stateData[k] = obj[k];
|
|
148
|
+
}
|
|
48
149
|
}
|
|
49
150
|
}
|
|
50
151
|
return;
|