@cocorograph/hub-agent 0.8.64 → 0.8.66
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/README.md +17 -0
- package/package.json +1 -1
- package/scripts/install.sh +354 -1
- package/src/claude-display-mask.mjs +89 -0
- package/src/claude-stream-bridge.mjs +36 -11
- package/src/codex-result-mask.mjs +187 -0
- package/src/main.mjs +5 -2
- package/src/permission-display-mask.mjs +142 -0
- package/src/tool-result-mask.mjs +201 -130
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MASK_ENABLED,
|
|
3
|
+
redactSecrets,
|
|
4
|
+
redactSecretsValue,
|
|
5
|
+
toolResultPlaceholder,
|
|
6
|
+
} from "./tool-result-mask.mjs"
|
|
7
|
+
|
|
8
|
+
// On masking failures, keep safe protocol identity/status values so approvals
|
|
9
|
+
// and completion events remain routable. Names alone never exempt secret values.
|
|
10
|
+
const CONTROL_KEYS = new Set([
|
|
11
|
+
"id", "type", "method", "stream_id", "thread_id", "threadId", "turnId",
|
|
12
|
+
"itemId", "request_id", "callId", "input_id", "delegationId", "delegation_id",
|
|
13
|
+
"agentThreadId", "agent_thread_id", "parentThreadId", "ownerTurnId",
|
|
14
|
+
"status", "phase", "kind", "runtime", "success", "exitCode", "code",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const FAILURE_TEXT = "[Codex の表示内容を安全にマスクできなかったため非表示にしました]"
|
|
18
|
+
const DISPLAY_KEYS = new Set([
|
|
19
|
+
"arguments", "input", "result", "error", "contentItems", "structuredContent",
|
|
20
|
+
"content", "summary", "text", "prompt", "command", "aggregatedOutput", "changes",
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
function toolInput(value) {
|
|
24
|
+
if (typeof value !== "string") return value
|
|
25
|
+
try { return JSON.parse(value) } catch { return value }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function placeholderForItem(item) {
|
|
29
|
+
if (item.type === "mcpToolCall" || item.type === "dynamicToolCall") {
|
|
30
|
+
return toolResultPlaceholder(item.tool, toolInput(item.arguments))
|
|
31
|
+
}
|
|
32
|
+
if (item.type === "commandExecution") {
|
|
33
|
+
return toolResultPlaceholder("exec_command", { command: item.command })
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SCHEMA_CHILDREN = new Set(["items", "oneOf", "anyOf", "allOf", "not", "additionalProperties"])
|
|
39
|
+
const SCHEMA_RESPONSE_VALUES = new Set(["enum", "const", "required", "id"])
|
|
40
|
+
|
|
41
|
+
/** Field names describe input slots, not supplied credentials. Preserve schema
|
|
42
|
+
* structure while masking labels and removing hidden defaults (never submit a
|
|
43
|
+
* redaction placeholder as an answer). Response keys/choice IDs cannot be renamed
|
|
44
|
+
* without a reverse mapping, so secret-bearing identities disable the form. */
|
|
45
|
+
function maskElicitationSchema(schema, fieldName = "") {
|
|
46
|
+
if (Array.isArray(schema)) return schema.map((child) => maskElicitationSchema(child, fieldName))
|
|
47
|
+
if (!schema || typeof schema !== "object") return redactSecretsValue(schema)
|
|
48
|
+
const entries = []
|
|
49
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
50
|
+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
51
|
+
entries.push([key, Object.fromEntries(Object.entries(value).map(([name, child]) => {
|
|
52
|
+
if (redactSecrets(name) !== name) throw new Error("secret_schema_identity")
|
|
53
|
+
return [name, maskElicitationSchema(child, name)]
|
|
54
|
+
}))])
|
|
55
|
+
} else if (SCHEMA_CHILDREN.has(key)) {
|
|
56
|
+
entries.push([key, maskElicitationSchema(value, fieldName)])
|
|
57
|
+
} else if (SCHEMA_RESPONSE_VALUES.has(key)) {
|
|
58
|
+
const contextual = fieldName && key !== "required"
|
|
59
|
+
? redactSecretsValue({ [fieldName]: value })[fieldName] : value
|
|
60
|
+
if (contextual !== value || redactSecretsValue(value) !== value) throw new Error("secret_schema_identity")
|
|
61
|
+
entries.push([key, value])
|
|
62
|
+
} else if (key === "default" || key === "examples") {
|
|
63
|
+
const contextKey = fieldName || "default"
|
|
64
|
+
const safe = redactSecretsValue({ [contextKey]: value })
|
|
65
|
+
if (schema.writeOnly !== true && schema.format !== "password" && safe[contextKey] === value) {
|
|
66
|
+
entries.push([key, value])
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// Apply the shared key AND value policy to all other schema metadata.
|
|
70
|
+
entries.push(...Object.entries(redactSecretsValue({ [key]: value })))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return Object.fromEntries(entries)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function preserveElicitationSchema(original, masked) {
|
|
77
|
+
if (original.type !== "codex.permission.request" || original.method !== "mcpServer/elicitation/request"
|
|
78
|
+
|| !original.params?.requestedSchema) return masked
|
|
79
|
+
try {
|
|
80
|
+
return { ...masked, params: { ...masked.params, requestedSchema: maskElicitationSchema(original.params.requestedSchema) } }
|
|
81
|
+
} catch {
|
|
82
|
+
// The existing frontend rejects accept for an unsupported mode, but still
|
|
83
|
+
// offers decline/cancel, addressing the unchanged pending request ID.
|
|
84
|
+
return { ...masked, params: { ...masked.params,
|
|
85
|
+
mode: "masked",
|
|
86
|
+
message: "秘密を含む回答キー・選択値があるため、このフォームは表示できません。拒否またはキャンセルしてください。",
|
|
87
|
+
requestedSchema: {},
|
|
88
|
+
} }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function failedDisplayCopy(value, protocol = true, seen = new WeakSet()) {
|
|
93
|
+
if (!value || typeof value !== "object") return typeof value === "string" ? FAILURE_TEXT : value
|
|
94
|
+
if (seen.has(value)) return FAILURE_TEXT
|
|
95
|
+
seen.add(value)
|
|
96
|
+
const next = Array.isArray(value) ? [] : Object.create(null)
|
|
97
|
+
const keys = new Set()
|
|
98
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
99
|
+
if (!descriptor.enumerable) continue
|
|
100
|
+
const safeKey = Array.isArray(value) ? key : redactSecrets(key)
|
|
101
|
+
if (keys.has(safeKey)) return FAILURE_TEXT
|
|
102
|
+
keys.add(safeKey)
|
|
103
|
+
const raw = descriptor.value
|
|
104
|
+
next[safeKey] = !Object.hasOwn(descriptor, "value") ? FAILURE_TEXT
|
|
105
|
+
: protocol && CONTROL_KEYS.has(key) && (raw === null || typeof raw !== "object")
|
|
106
|
+
? redactSecrets(raw)
|
|
107
|
+
: failedDisplayCopy(raw, protocol && !DISPLAY_KEYS.has(key), seen)
|
|
108
|
+
}
|
|
109
|
+
seen.delete(value)
|
|
110
|
+
return next
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Apply item policy using original arguments, after shared recursive redaction. */
|
|
114
|
+
function maskItems(original, redacted, protocol = true) {
|
|
115
|
+
// Display subtrees have already been sanitized, including object keys and
|
|
116
|
+
// key-collision placeholders. Never rebuild them from the original keys.
|
|
117
|
+
if (!protocol) return redacted
|
|
118
|
+
if (!original || typeof original !== "object") return redacted
|
|
119
|
+
if (!redacted || typeof redacted !== "object") return redacted
|
|
120
|
+
if (Array.isArray(original)) return original.map((value, index) => maskItems(value, redacted[index], protocol))
|
|
121
|
+
const next = Object.fromEntries(Object.entries(redacted).map(([key, masked]) => {
|
|
122
|
+
// A renamed key cannot be a known protocol field; retain its sanitized
|
|
123
|
+
// value directly rather than recovering the old (potentially secret) key.
|
|
124
|
+
if (!Object.hasOwn(original, key)) return [key, masked]
|
|
125
|
+
const value = original[key]
|
|
126
|
+
return [key, maskItems(value, masked, !DISPLAY_KEYS.has(key))]
|
|
127
|
+
}))
|
|
128
|
+
const placeholder = protocol ? placeholderForItem(original) : null
|
|
129
|
+
if (placeholder) {
|
|
130
|
+
// Discard the entire result/error envelope, including provider extensions.
|
|
131
|
+
if (original.result != null) next.result = { content: [{ type: "text", text: placeholder }] }
|
|
132
|
+
if (original.error != null) next.error = { message: placeholder }
|
|
133
|
+
if (original.contentItems != null) next.contentItems = [{ type: "inputText", text: placeholder }]
|
|
134
|
+
if (original.aggregatedOutput != null) next.aggregatedOutput = placeholder
|
|
135
|
+
}
|
|
136
|
+
return next
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Outgoing display copies only. Raw bridge history and model inputs stay untouched. */
|
|
140
|
+
export function maskCodexDisplayPayload(payload) {
|
|
141
|
+
if (!MASK_ENABLED) return payload
|
|
142
|
+
try {
|
|
143
|
+
if (typeof payload?.type !== "string" || !payload.type.startsWith("codex.")) return payload
|
|
144
|
+
const method = payload.event?.method
|
|
145
|
+
// A secret may span arbitrarily many chunks. Completed items are authoritative;
|
|
146
|
+
// do not try regex masking isolated fragments (including future *Delta methods).
|
|
147
|
+
if (typeof method === "string" && /delta$/i.test(method)) return null
|
|
148
|
+
return preserveElicitationSchema(payload, maskItems(payload, redactSecretsValue(payload)))
|
|
149
|
+
} catch {
|
|
150
|
+
// Keep transport routing, but never retry a failed mask with the original body.
|
|
151
|
+
// Failure to inspect a malformed payload also falls back to dropping it.
|
|
152
|
+
try {
|
|
153
|
+
return failedDisplayCopy(payload)
|
|
154
|
+
} catch { return null }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Install once, before any bridge listeners/dispatch sends are registered. */
|
|
159
|
+
export function installCodexDisplayMask(client) {
|
|
160
|
+
const send = client.send.bind(client)
|
|
161
|
+
client.send = (payload, ...args) => {
|
|
162
|
+
const masked = maskCodexDisplayPayload(payload)
|
|
163
|
+
if (!masked) return false
|
|
164
|
+
const event = masked.event
|
|
165
|
+
const item = event?.params?.item
|
|
166
|
+
// Root reasoning is rendered only from deltas by the existing browser. Send
|
|
167
|
+
// one already-masked completed body in that shape; child completion has its
|
|
168
|
+
// own renderer and must not consume the parent's thinking buffer.
|
|
169
|
+
if (MASK_ENABLED && masked.type === "codex.event" && event?.method === "item/completed"
|
|
170
|
+
&& item?.type === "reasoning"
|
|
171
|
+
&& !(item._hubSubagent?.runtime === "codex"
|
|
172
|
+
&& typeof item._hubSubagent.delegationId === "string"
|
|
173
|
+
&& item._hubSubagent.delegationId.trim())) {
|
|
174
|
+
const pick = (value) => Array.isArray(value) ? value.filter((part) => typeof part === "string") : []
|
|
175
|
+
const text = [...pick(item.content), ...pick(item.summary)].join("\n").trim()
|
|
176
|
+
if (text) send({
|
|
177
|
+
...masked,
|
|
178
|
+
event: {
|
|
179
|
+
method: "item/reasoning/summaryTextDelta",
|
|
180
|
+
params: { ...event.params, itemId: item.id, delta: text },
|
|
181
|
+
},
|
|
182
|
+
}, ...args)
|
|
183
|
+
}
|
|
184
|
+
return send(masked, ...args)
|
|
185
|
+
}
|
|
186
|
+
return client
|
|
187
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -48,8 +48,10 @@ import { WsClient } from "./ws-client.mjs"
|
|
|
48
48
|
import { DispatchScheduler } from "./dispatch-scheduler.mjs"
|
|
49
49
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
50
50
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
51
|
+
import { installClaudeDisplayMask } from "./claude-display-mask.mjs"
|
|
51
52
|
import { CodexMcpControl } from "./codex-mcp-control.mjs"
|
|
52
53
|
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
54
|
+
import { installCodexDisplayMask } from "./codex-result-mask.mjs"
|
|
53
55
|
import {
|
|
54
56
|
quotePosixShellArg,
|
|
55
57
|
resolveCodexBinary,
|
|
@@ -999,7 +1001,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
999
1001
|
)
|
|
1000
1002
|
}
|
|
1001
1003
|
|
|
1002
|
-
const client = new WsClient(config, {
|
|
1004
|
+
const client = installCodexDisplayMask(new WsClient(config, {
|
|
1003
1005
|
logger,
|
|
1004
1006
|
version,
|
|
1005
1007
|
hostKey,
|
|
@@ -1009,7 +1011,8 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
1009
1011
|
bundleVersionProvider: readBundleVersionSync,
|
|
1010
1012
|
bundleStatusProvider,
|
|
1011
1013
|
bundleManifestPath: BUNDLE_MANIFEST_PATH,
|
|
1012
|
-
})
|
|
1014
|
+
}))
|
|
1015
|
+
installClaudeDisplayMask(client)
|
|
1013
1016
|
|
|
1014
1017
|
// EventEmitter の 'error' は listener が無いと process が落ちる。
|
|
1015
1018
|
// ws-client は close 側で reconnect を予約しているので、ここでは log だけ。
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util"
|
|
2
|
+
import { redactSecrets, redactSecretsValue, SECRET_PLACEHOLDER } from "./tool-result-mask.mjs"
|
|
3
|
+
|
|
4
|
+
export const PERMISSION_MASK_DENIAL =
|
|
5
|
+
"伏せ字を含む引数を復元できません。該当項目を再入力して、もう一度実行してください。"
|
|
6
|
+
|
|
7
|
+
/** Permission payloads must be JSON values, without cycles or accessors. */
|
|
8
|
+
export function copyPermissionInput(value, ancestors = new Set()) {
|
|
9
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
|
10
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
11
|
+
if (!value || typeof value !== "object" || ancestors.has(value)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
12
|
+
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error(PERMISSION_MASK_DENIAL)
|
|
13
|
+
ancestors.add(value)
|
|
14
|
+
try {
|
|
15
|
+
if (Object.getOwnPropertySymbols(value).length) throw new Error(PERMISSION_MASK_DENIAL)
|
|
16
|
+
const entries = Object.entries(Object.getOwnPropertyDescriptors(value))
|
|
17
|
+
.filter(([key]) => !(Array.isArray(value) && key === "length"))
|
|
18
|
+
.map(([key, descriptor]) => {
|
|
19
|
+
if (!descriptor.enumerable || !Object.hasOwn(descriptor, "value")) throw new Error(PERMISSION_MASK_DENIAL)
|
|
20
|
+
return [key, copyPermissionInput(descriptor.value, ancestors)]
|
|
21
|
+
})
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
if (entries.length !== value.length || entries.some(([key], index) => key !== String(index))) throw new Error(PERMISSION_MASK_DENIAL)
|
|
24
|
+
return entries.map(([, item]) => item)
|
|
25
|
+
}
|
|
26
|
+
return Object.fromEntries(entries)
|
|
27
|
+
} finally {
|
|
28
|
+
ancestors.delete(value)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function questionKeyMap(originalInput, displayInput) {
|
|
33
|
+
const keys = new Map()
|
|
34
|
+
if (!Array.isArray(originalInput.questions) || !Array.isArray(displayInput.questions)) return keys
|
|
35
|
+
originalInput.questions.forEach((question, index) => {
|
|
36
|
+
const raw = question?.question
|
|
37
|
+
const shown = displayInput.questions[index]?.question
|
|
38
|
+
if (typeof raw !== "string" || typeof shown !== "string") return
|
|
39
|
+
if (keys.has(shown) && keys.get(shown) !== raw) throw new Error(PERMISSION_MASK_DENIAL)
|
|
40
|
+
keys.set(shown, raw)
|
|
41
|
+
})
|
|
42
|
+
return keys
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function questionLabelMaps(originalInput, displayInput) {
|
|
46
|
+
const questions = new Map()
|
|
47
|
+
if (!Array.isArray(originalInput.questions) || !Array.isArray(displayInput.questions)) return questions
|
|
48
|
+
originalInput.questions.forEach((question, index) => {
|
|
49
|
+
if (typeof question?.question !== "string" || !Array.isArray(question.options)) return
|
|
50
|
+
const labels = questions.get(question.question) ?? new Map()
|
|
51
|
+
const shownOptions = displayInput.questions[index]?.options
|
|
52
|
+
question.options.forEach((option, optionIndex) => {
|
|
53
|
+
const raw = option?.label
|
|
54
|
+
const shown = shownOptions?.[optionIndex]?.label
|
|
55
|
+
if (typeof raw !== "string" || typeof shown !== "string") return
|
|
56
|
+
if (labels.has(shown) && labels.get(shown) !== raw) throw new Error(PERMISSION_MASK_DENIAL)
|
|
57
|
+
labels.set(shown, raw)
|
|
58
|
+
})
|
|
59
|
+
questions.set(question.question, labels)
|
|
60
|
+
})
|
|
61
|
+
return questions
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function preparePermissionInput(input, { toolName } = {}) {
|
|
65
|
+
const originalInput = copyPermissionInput(input)
|
|
66
|
+
if (!originalInput || Array.isArray(originalInput) || typeof originalInput !== "object") throw new Error(PERMISSION_MASK_DENIAL)
|
|
67
|
+
const displayInput = copyPermissionInput(redactSecretsValue(originalInput))
|
|
68
|
+
if (!displayInput || typeof displayInput !== "object" || Array.isArray(displayInput)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
69
|
+
// AskUserQuestion's UI indexes answers by the displayed question text.
|
|
70
|
+
// Reject ambiguous display labels before the user is asked to choose.
|
|
71
|
+
if (toolName === "AskUserQuestion") {
|
|
72
|
+
questionKeyMap(originalInput, displayInput)
|
|
73
|
+
questionLabelMaps(originalInput, displayInput)
|
|
74
|
+
}
|
|
75
|
+
return { originalInput, displayInput }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Restore only unchanged masked values at the same path. Never interpolate secrets. */
|
|
79
|
+
export function restorePermissionInput(originalInput, displayInput, updatedInput, { toolName } = {}) {
|
|
80
|
+
const updated = copyPermissionInput(updatedInput)
|
|
81
|
+
if (!updated || Array.isArray(updated) || typeof updated !== "object") throw new Error(PERMISSION_MASK_DENIAL)
|
|
82
|
+
function containsMarker(value) {
|
|
83
|
+
if (typeof value === "string") return value.includes(SECRET_PLACEHOLDER)
|
|
84
|
+
return value && typeof value === "object" && Object.entries(value).some(([key, item]) => key.includes(SECRET_PLACEHOLDER) || containsMarker(item))
|
|
85
|
+
}
|
|
86
|
+
function restore(original, display, next, path = []) {
|
|
87
|
+
const wasMasked = !isDeepStrictEqual(original, display)
|
|
88
|
+
if (wasMasked && isDeepStrictEqual(next, display)) return copyPermissionInput(original)
|
|
89
|
+
if (wasMasked && (typeof next === "string" || typeof display === "string") && containsMarker(next)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
90
|
+
if (!next || typeof next !== "object") return next
|
|
91
|
+
// A wholly hidden object has no editable key correspondence. A complete
|
|
92
|
+
// replacement without markers is ordinary new input, not a restoration.
|
|
93
|
+
if (wasMasked && typeof display === "string") return next
|
|
94
|
+
const keyMap = new Map()
|
|
95
|
+
if (original && typeof original === "object" && display && typeof display === "object") {
|
|
96
|
+
for (const key of Object.keys(original)) {
|
|
97
|
+
const shown = Array.isArray(original) ? key : redactSecrets(key)
|
|
98
|
+
if (keyMap.has(shown)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
99
|
+
keyMap.set(shown, key)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hasMaskedKeys = [...keyMap].some(([shown, raw]) => shown !== raw)
|
|
103
|
+
const destinations = new Set()
|
|
104
|
+
const entries = Object.entries(next).map(([key, value]) => {
|
|
105
|
+
const originalKey = keyMap.get(key) ?? key
|
|
106
|
+
const questionAnswerKey = toolName === "AskUserQuestion" && path.length === 1 && path[0] === "answers"
|
|
107
|
+
if (hasMaskedKeys && key.includes(SECRET_PLACEHOLDER) && !keyMap.has(key) && !questionAnswerKey) throw new Error(PERMISSION_MASK_DENIAL)
|
|
108
|
+
if (destinations.has(originalKey)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
109
|
+
destinations.add(originalKey)
|
|
110
|
+
return [originalKey, restore(
|
|
111
|
+
original && typeof original === "object" && Object.hasOwn(original, originalKey) ? original[originalKey] : undefined,
|
|
112
|
+
display && typeof display === "object" && Object.hasOwn(display, key) ? display[key] : undefined,
|
|
113
|
+
value, [...path, originalKey],
|
|
114
|
+
)]
|
|
115
|
+
})
|
|
116
|
+
return Array.isArray(next) ? entries.map(([, value]) => value) : Object.fromEntries(entries)
|
|
117
|
+
}
|
|
118
|
+
const restored = restore(originalInput, displayInput, updated)
|
|
119
|
+
if (toolName === "AskUserQuestion" && Object.hasOwn(restored, "answers")) {
|
|
120
|
+
if (!restored.answers || typeof restored.answers !== "object" || Array.isArray(restored.answers)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
121
|
+
const keys = questionKeyMap(originalInput, displayInput)
|
|
122
|
+
const questionLabels = questionLabelMaps(originalInput, displayInput)
|
|
123
|
+
const answers = new Map()
|
|
124
|
+
for (const [key, value] of Object.entries(restored.answers)) {
|
|
125
|
+
const originalKey = keys.get(key) ?? key
|
|
126
|
+
if (key.includes(SECRET_PLACEHOLDER) && !keys.has(key) && !Object.values(originalInput.questions ?? []).some(q => q?.question === key)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
127
|
+
if (answers.has(originalKey)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
128
|
+
const labels = questionLabels.get(originalKey)
|
|
129
|
+
const restoreLabel = (answer) => {
|
|
130
|
+
if (typeof answer !== "string") throw new Error(PERMISSION_MASK_DENIAL)
|
|
131
|
+
// The UI returns either a selected label or free input in this field.
|
|
132
|
+
// Only a complete, unique label match is interpretable as that option.
|
|
133
|
+
if (labels?.has(answer)) return labels.get(answer)
|
|
134
|
+
if (answer.includes(SECRET_PLACEHOLDER)) throw new Error(PERMISSION_MASK_DENIAL)
|
|
135
|
+
return answer
|
|
136
|
+
}
|
|
137
|
+
answers.set(originalKey, Array.isArray(value) ? value.map(restoreLabel) : restoreLabel(value))
|
|
138
|
+
}
|
|
139
|
+
restored.answers = Object.fromEntries(answers)
|
|
140
|
+
}
|
|
141
|
+
return restored
|
|
142
|
+
}
|