@ducking-mind/proxy-app-cli 0.1.0 → 0.1.1
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 +127 -62
- package/agent-contract.mjs +128 -0
- package/agent-runtime/package-guide.mjs +193 -0
- package/agent-runtime/protocol.mjs +325 -0
- package/agent-runtime/runtime-status.mjs +70 -0
- package/artifact-security.mjs +45 -0
- package/local-dev-credentials.mjs +187 -0
- package/local-dev-relay.mjs +367 -82
- package/package.json +23 -6
- package/prepare-agent-runtime.mjs +43 -0
- package/proxy-app.mjs +871 -190
- package/release-runtime/release-config.mjs +154 -0
- package/release-runtime/release-target.mjs +48 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared protocol primitives for exposing Proxy Desktop applications to an
|
|
3
|
+
* external agent (for example, through MCP). This module has no Electron or
|
|
4
|
+
* transport dependency and is safe to use from the desktop host and tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { serializeAgentError } from './runtime-status.mjs'
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
export const AGENT_PROTOCOL = 'proxy.desktop.agent/v1'
|
|
11
|
+
export const AGENT_PROTOCOL_VERSION = 1
|
|
12
|
+
|
|
13
|
+
export const ACCESS = Object.freeze({ READ: 'read', WRITE: 'write' })
|
|
14
|
+
|
|
15
|
+
const LEGACY_APP_ID_RE = /^app-[a-z0-9][a-z0-9._-]{0,119}$/
|
|
16
|
+
const PLATFORM_APP_ID_RE = /^app_[a-z0-9][a-z0-9._-]{2,119}$/
|
|
17
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
|
18
|
+
const NAMESPACE_RE = /^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/
|
|
19
|
+
const CAPABILITY_RE = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/
|
|
20
|
+
const TOOL_RE = /^proxy_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/
|
|
21
|
+
const AGENT_TARGETS = Object.freeze(['web', 'desktop'])
|
|
22
|
+
const MAX_SCHEMA_BYTES = 64 * 1024
|
|
23
|
+
const RESERVED_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
|
|
24
|
+
|
|
25
|
+
export function isRecord(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assert(condition, message) {
|
|
30
|
+
if (!condition) throw new TypeError(message)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isPublicAppSlug(value) {
|
|
34
|
+
return typeof value === 'string' && value === value.trim() && value.length <= 120 && /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isAppManifestID(value) {
|
|
38
|
+
return isPublicAppSlug(value) || (typeof value === 'string' && value === value.trim() && PLATFORM_APP_ID_RE.test(value))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only metadata from the authenticated platform (or the trusted account
|
|
42
|
+
// broker) may approve a historical manifest identity. Never infer an alias by
|
|
43
|
+
// adding/removing a prefix, and never alias one stable platform ID to another.
|
|
44
|
+
export function appManifestIdentityMatches(value, metadata) {
|
|
45
|
+
if (!isAppManifestID(value) || !metadata) return false
|
|
46
|
+
const id = metadata.app_id || metadata.id
|
|
47
|
+
if (PLATFORM_APP_ID_RE.test(value)) return value === id
|
|
48
|
+
if (value === (metadata.app_slug || metadata.slug)) return true
|
|
49
|
+
return Array.isArray(metadata.app_aliases) && metadata.app_aliases.some(alias => isPublicAppSlug(alias) && alias === value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function assertAppId(value) {
|
|
53
|
+
assert(isAppManifestID(value) || (typeof value === 'string' && value === value.trim() && LEGACY_APP_ID_RE.test(value)), `invalid app id: ${value}`)
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assertVersion(value) {
|
|
58
|
+
assert(typeof value === 'string' && VERSION_RE.test(value), `invalid app version: ${value}`)
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function assertNamespace(value) {
|
|
63
|
+
assert(typeof value === 'string' && NAMESPACE_RE.test(value) && value.length <= 48,
|
|
64
|
+
`invalid agent namespace: ${value}`)
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assertCapabilityName(value) {
|
|
69
|
+
assert(typeof value === 'string' && CAPABILITY_RE.test(value) && value.length <= 64,
|
|
70
|
+
`invalid capability name: ${value}`)
|
|
71
|
+
return value
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeAgentTargets(value, field = 'targets', fallback = ['desktop']) {
|
|
75
|
+
const targets = value === undefined ? fallback : value
|
|
76
|
+
assert(Array.isArray(targets) && targets.length > 0 && targets.length <= AGENT_TARGETS.length, `${field} must list one or more targets`)
|
|
77
|
+
assert(new Set(targets).size === targets.length && targets.every(target => AGENT_TARGETS.includes(target)), `${field} must contain only web or desktop`)
|
|
78
|
+
return Object.freeze(AGENT_TARGETS.filter(target => targets.includes(target)))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeSchema(value, field = 'schema') {
|
|
82
|
+
const schema = value === undefined ? { type: 'object' } : value
|
|
83
|
+
assert(isRecord(schema), `${field} must be a JSON schema object`)
|
|
84
|
+
let bytes
|
|
85
|
+
try { bytes = Buffer.byteLength(JSON.stringify(schema)) } catch { bytes = Infinity }
|
|
86
|
+
assert(bytes <= MAX_SCHEMA_BYTES, `${field} exceeds ${MAX_SCHEMA_BYTES} bytes`)
|
|
87
|
+
validateSchema(schema, field)
|
|
88
|
+
return schema
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeCapabilityDeclaration(input, { defaultTargets = ['desktop'] } = {}) {
|
|
92
|
+
assert(isRecord(input), 'agent capability must be an object')
|
|
93
|
+
const name = assertCapabilityName(input.name)
|
|
94
|
+
const description = input.description === undefined ? '' : input.description
|
|
95
|
+
assert(typeof description === 'string' && description.length <= 2048, `invalid description for ${name}`)
|
|
96
|
+
const access = input.access === undefined ? ACCESS.READ : input.access
|
|
97
|
+
assert(access === ACCESS.READ || access === ACCESS.WRITE, `invalid access for ${name}`)
|
|
98
|
+
const idempotent = input.idempotent === undefined ? access === ACCESS.READ : input.idempotent
|
|
99
|
+
assert(typeof idempotent === 'boolean', `invalid idempotent value for ${name}`)
|
|
100
|
+
const title = input.title === undefined ? name : input.title
|
|
101
|
+
assert(typeof title === 'string' && title.length <= 200, `invalid title for ${name}`)
|
|
102
|
+
for (const field of ['destructive', 'open_world', 'billable']) if (input[field] !== undefined) assert(typeof input[field] === 'boolean', `invalid ${field} for ${name}`)
|
|
103
|
+
const completion = input.completion || 'completed'
|
|
104
|
+
assert(['completed', 'accepted'].includes(completion), `invalid completion for ${name}`)
|
|
105
|
+
const targets = normalizeAgentTargets(input.targets, `${name}.targets`, defaultTargets)
|
|
106
|
+
const inputSchema = normalizeSchema(input.input_schema ?? input.inputSchema, `${name}.input_schema`)
|
|
107
|
+
const outputSchema = normalizeSchema(input.output_schema ?? input.outputSchema, `${name}.output_schema`)
|
|
108
|
+
assert(inputSchema.type === 'object' && outputSchema.type === 'object', 'tool schemas must have object roots')
|
|
109
|
+
if (access === ACCESS.WRITE) assert(inputSchema.properties?.idempotency_key?.type === 'string' && inputSchema.required?.includes('idempotency_key'), 'write capabilities must require a string idempotency_key')
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
name,
|
|
112
|
+
title,
|
|
113
|
+
description,
|
|
114
|
+
destructive: input.destructive ?? access === ACCESS.WRITE,
|
|
115
|
+
open_world: input.open_world ?? false,
|
|
116
|
+
billable: input.billable ?? false,
|
|
117
|
+
completion,
|
|
118
|
+
targets,
|
|
119
|
+
access,
|
|
120
|
+
idempotent,
|
|
121
|
+
input_schema: inputSchema,
|
|
122
|
+
output_schema: outputSchema,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeManifestAgent(manifest) {
|
|
127
|
+
assert(isRecord(manifest), 'manifest must be an object')
|
|
128
|
+
const agent = manifest.agent
|
|
129
|
+
if (agent === undefined) return null
|
|
130
|
+
assert(isRecord(agent), 'manifest.agent must be an object')
|
|
131
|
+
if (agent.enabled === false) return null
|
|
132
|
+
assert(agent.enabled === true, 'manifest.agent.enabled must be true or false')
|
|
133
|
+
if (agent.contract_source !== undefined) assert(agent.contract_source === 'code', 'invalid agent contract_source')
|
|
134
|
+
assert(!(agent.contract_source === 'code' && agent.entry), 'generated code contract must not retain agent.entry')
|
|
135
|
+
assert(!(agent.entry && agent.capabilities !== undefined), 'agent.entry and agent.capabilities cannot both be present')
|
|
136
|
+
assertAppId(manifest.id)
|
|
137
|
+
assertVersion(manifest.version)
|
|
138
|
+
const namespace = assertNamespace(agent.namespace || manifest.id.replace(/^app\./, '').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase())
|
|
139
|
+
assert(!RESERVED_NAMESPACES.has(namespace), `reserved agent namespace: ${namespace}`)
|
|
140
|
+
const targets = normalizeAgentTargets(agent.targets, 'manifest.agent.targets')
|
|
141
|
+
// Source projects reference their capability module. Packaged code-first
|
|
142
|
+
// apps carry its generated declarations for discovery before launch.
|
|
143
|
+
const raw = agent.capabilities === undefined ? [] : agent.capabilities
|
|
144
|
+
assert(Array.isArray(raw) && raw.length <= 64, 'manifest.agent.capabilities must be an array with at most 64 entries')
|
|
145
|
+
const capabilities = raw.map(capability => normalizeCapabilityDeclaration(capability, { defaultTargets: targets }))
|
|
146
|
+
const seen = new Set()
|
|
147
|
+
for (const capability of capabilities) {
|
|
148
|
+
assert(capability.targets.every(target => targets.includes(target)), `${capability.name}.targets must be a subset of manifest.agent.targets`)
|
|
149
|
+
assert(!seen.has(capability.name), `duplicate agent capability: ${capability.name}`)
|
|
150
|
+
seen.add(capability.name)
|
|
151
|
+
}
|
|
152
|
+
const tools = capabilities.map(capability => toolNameFor(namespace, capability.name))
|
|
153
|
+
assert(new Set(tools).size === tools.length, 'duplicate generated tool name')
|
|
154
|
+
const contractVersion = assertVersion(agent.contract_version || manifest.version)
|
|
155
|
+
const summary = agent.summary || manifest.description || ''
|
|
156
|
+
assert(typeof summary === 'string' && summary.length <= 2048, 'invalid agent summary')
|
|
157
|
+
const keywords = agent.keywords || []
|
|
158
|
+
assert(Array.isArray(keywords) && keywords.length <= 24 && keywords.every(item => typeof item === 'string' && item.length > 0 && item.length <= 80), 'invalid agent keywords')
|
|
159
|
+
if (agent.guide !== undefined) {
|
|
160
|
+
assert(isRecord(agent.guide) && typeof agent.guide.entry === 'string', 'invalid agent guide')
|
|
161
|
+
assert(Array.isArray(agent.guide.topics || []) && (agent.guide.topics || []).length <= 16, 'invalid agent guide topics')
|
|
162
|
+
}
|
|
163
|
+
if (agent.examples !== undefined) assert(typeof agent.examples === 'string', 'invalid agent examples')
|
|
164
|
+
return Object.freeze({ enabled: true, namespace, targets, contractVersion, summary, keywords, guide: agent.guide, examples: agent.examples, capabilities })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
|
168
|
+
/** Same contract identity for market metadata and installed packages, independent of transport/runtime state. */
|
|
169
|
+
export function describeAgentContract(manifest) {
|
|
170
|
+
const agent = normalizeManifestAgent(manifest)
|
|
171
|
+
if (!agent) return null
|
|
172
|
+
const contract = { appId: manifest.id, namespace: agent.namespace, contractVersion: agent.contractVersion, targets: agent.targets,
|
|
173
|
+
capabilities: [...agent.capabilities].sort((a, b) => a.name.localeCompare(b.name)) }
|
|
174
|
+
return { enabled: true, namespace: agent.namespace, targets: agent.targets, contractVersion: agent.contractVersion,
|
|
175
|
+
contractHash: createHash('sha256').update(JSON.stringify(canonical(contract))).digest('hex'),
|
|
176
|
+
capabilityCount: agent.capabilities.length,
|
|
177
|
+
desktopCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('desktop')).length,
|
|
178
|
+
webCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('web')).length,
|
|
179
|
+
capabilities: agent.capabilities.map(({ name, title, description, access, targets, billable, completion }) => ({ name, title, description, access, targets, billable, completion })) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function normalizeRuntimeCapability(input) {
|
|
183
|
+
assert(isRecord(input), 'runtime capability must be an object')
|
|
184
|
+
const name = assertCapabilityName(input.name)
|
|
185
|
+
if (input.handlerVersion !== undefined) {
|
|
186
|
+
assert(Number.isInteger(input.handlerVersion) && input.handlerVersion > 0, `invalid handlerVersion for ${name}`)
|
|
187
|
+
}
|
|
188
|
+
if (input.available !== undefined) assert(typeof input.available === 'boolean', `invalid available for ${name}`)
|
|
189
|
+
return Object.freeze({ name, handlerVersion: input.handlerVersion || 1, available: input.available !== false })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function validateRuntimeRegistration(manifest, registration, { target = 'desktop' } = {}) {
|
|
193
|
+
assert(AGENT_TARGETS.includes(target), 'invalid runtime target')
|
|
194
|
+
assert(isRecord(registration), 'runtime registration must be an object')
|
|
195
|
+
const agent = normalizeManifestAgent(manifest)
|
|
196
|
+
assert(agent, 'agent capabilities are not enabled in manifest')
|
|
197
|
+
assertAppId(registration.appId)
|
|
198
|
+
assertVersion(registration.version)
|
|
199
|
+
assert(registration.appId === manifest.id, 'runtime app id does not match manifest')
|
|
200
|
+
assert(registration.version === manifest.version, 'runtime app version does not match manifest')
|
|
201
|
+
assert(Array.isArray(registration.capabilities) && registration.capabilities.length <= 64, 'runtime capabilities must be an array with at most 64 entries')
|
|
202
|
+
const codeFirst = manifest.agent.contract_source === 'code'
|
|
203
|
+
const dynamic = registration.dynamic === true && !codeFirst
|
|
204
|
+
const declarations = new Map(agent.capabilities.map((capability) => [capability.name, capability]))
|
|
205
|
+
const runtime = []
|
|
206
|
+
const registeredDeclarations = []
|
|
207
|
+
const seen = new Set()
|
|
208
|
+
for (const raw of registration.capabilities) {
|
|
209
|
+
const capability = normalizeRuntimeCapability(raw)
|
|
210
|
+
assert(!seen.has(capability.name), `duplicate runtime capability: ${capability.name}`)
|
|
211
|
+
seen.add(capability.name)
|
|
212
|
+
let declared = declarations.get(capability.name)
|
|
213
|
+
const hasContract = ['title', 'description', 'access', 'input_schema', 'inputSchema', 'output_schema', 'outputSchema', 'targets', 'completion', 'idempotent', 'destructive', 'open_world', 'billable'].some(field => Object.hasOwn(raw, field))
|
|
214
|
+
let supplied
|
|
215
|
+
if (codeFirst || dynamic || hasContract) {
|
|
216
|
+
if (codeFirst || dynamic) assert(raw.access !== undefined && (raw.input_schema ?? raw.inputSchema) !== undefined && (raw.output_schema ?? raw.outputSchema) !== undefined, `runtime capability requires a complete code declaration: ${capability.name}`)
|
|
217
|
+
supplied = normalizeCapabilityDeclaration(raw, { defaultTargets: agent.targets })
|
|
218
|
+
assert(supplied.targets.every(item => agent.targets.includes(item)), `${capability.name}.targets must be a subset of application targets`)
|
|
219
|
+
assert(supplied.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
220
|
+
}
|
|
221
|
+
// Code-first applications provide the complete contract at runtime. The
|
|
222
|
+
// names-only form remains a compatibility path for already packaged
|
|
223
|
+
// applications whose generated manifest contains the contract.
|
|
224
|
+
if (declared) {
|
|
225
|
+
assert(declared.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
226
|
+
if (codeFirst) {
|
|
227
|
+
assert(JSON.stringify(canonical(supplied)) === JSON.stringify(canonical(declared)), `runtime capability contract differs from packaged code contract: ${capability.name}`)
|
|
228
|
+
}
|
|
229
|
+
} else if (!dynamic) {
|
|
230
|
+
assert(false, `runtime capability is not declared: ${capability.name}`)
|
|
231
|
+
}
|
|
232
|
+
if (dynamic) {
|
|
233
|
+
declared = supplied
|
|
234
|
+
declarations.set(capability.name, declared)
|
|
235
|
+
}
|
|
236
|
+
registeredDeclarations.push(declared)
|
|
237
|
+
if (capability.available) runtime.push({ ...capability, declaration: declared })
|
|
238
|
+
}
|
|
239
|
+
return Object.freeze({
|
|
240
|
+
appId: registration.appId,
|
|
241
|
+
version: registration.version,
|
|
242
|
+
capabilities: runtime,
|
|
243
|
+
declarations: registeredDeclarations,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function toolNameFor(namespace, capability) {
|
|
248
|
+
assertNamespace(namespace)
|
|
249
|
+
assertCapabilityName(capability)
|
|
250
|
+
const name = `proxy_${namespace.replaceAll('-', '_').replaceAll('.', '_')}_${capability.replaceAll('-', '_')}`
|
|
251
|
+
assert(name.length <= 128 && TOOL_RE.test(name), `invalid generated tool name: ${name}`)
|
|
252
|
+
return name
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function toMcpTool({ namespace, appId, version, contractVersion = version, contractHash, capability, runtime }) {
|
|
256
|
+
const declaration = normalizeCapabilityDeclaration({ ...capability, name: capability.name })
|
|
257
|
+
const tool = {
|
|
258
|
+
name: toolNameFor(namespace, declaration.name),
|
|
259
|
+
title: declaration.title,
|
|
260
|
+
description: [declaration.description || `${appId} ${declaration.name}`, declaration.billable ? 'May incur model or service charges.' : '', declaration.completion === 'accepted' ? 'Returns acceptance only; use the application task query to check completion.' : ''].filter(Boolean).join(' '),
|
|
261
|
+
inputSchema: declaration.input_schema,
|
|
262
|
+
_meta: { proxy: { appId, version, contractVersion, ...(contractHash ? { contractHash } : {}), capability: declaration.name, billable: declaration.billable, completion: declaration.completion, targets: declaration.targets } },
|
|
263
|
+
}
|
|
264
|
+
if (declaration.output_schema) tool.outputSchema = declaration.output_schema
|
|
265
|
+
tool.annotations = { readOnlyHint: declaration.access === ACCESS.READ, destructiveHint: declaration.destructive, idempotentHint: declaration.idempotent, openWorldHint: declaration.open_world }
|
|
266
|
+
if (runtime) tool._meta.proxy.runtime = runtime
|
|
267
|
+
return Object.freeze(tool)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function createInvocation({ requestId, appId, appVersion, capability, args, deadlineAt, cancellationToken }) {
|
|
271
|
+
assert(typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128, 'invalid request id')
|
|
272
|
+
assertAppId(appId); assertVersion(appVersion); assertCapabilityName(capability)
|
|
273
|
+
assert(args === undefined || isRecord(args), 'agent arguments must be an object')
|
|
274
|
+
return Object.freeze({ requestId, appId, appVersion, capability, args: args || {}, deadlineAt, cancellationToken })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function serializeError(error) {
|
|
278
|
+
return serializeAgentError(error)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
// Deliberately bounded JSON Schema subset. Reject unsupported keywords instead
|
|
283
|
+
// of advertising validation that the host cannot actually enforce.
|
|
284
|
+
const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'description', 'title', 'default'])
|
|
285
|
+
export function validateSchema(schema, path = '$', depth = 0) {
|
|
286
|
+
assert(depth <= 16 && isRecord(schema), `${path}: invalid or too deeply nested schema`)
|
|
287
|
+
for (const key of Object.keys(schema)) assert(keywords.has(key), `${path}: unsupported schema keyword ${key}`)
|
|
288
|
+
const allowed = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
|
|
289
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
290
|
+
assert(types.every(type => allowed.includes(type)) && (types.length === 1 || types.length === 2 && new Set(types).size === 2 && types.includes('null')), `${path}: explicit type or a nullable type pair is required`)
|
|
291
|
+
const type = types.find(type => type !== 'null') || 'null'
|
|
292
|
+
if (schema.properties !== undefined) {
|
|
293
|
+
assert(type === 'object' && isRecord(schema.properties), `${path}: invalid properties`)
|
|
294
|
+
for (const [key, value] of Object.entries(schema.properties)) validateSchema(value, `${path}.${key}`, depth + 1)
|
|
295
|
+
}
|
|
296
|
+
if (schema.required !== undefined) assert(Array.isArray(schema.required) && schema.required.every(key => typeof key === 'string' && Object.hasOwn(schema.properties || {}, key)), `${path}: invalid required fields`)
|
|
297
|
+
if (schema.additionalProperties !== undefined) assert(typeof schema.additionalProperties === 'boolean', `${path}: additionalProperties must be boolean`)
|
|
298
|
+
if (type === 'array') validateSchema(schema.items, `${path}[]`, depth + 1)
|
|
299
|
+
if (schema.enum !== undefined) assert(Array.isArray(schema.enum) && schema.enum.length > 0, `${path}: invalid enum`)
|
|
300
|
+
for (const key of ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems']) if (schema[key] !== undefined) {
|
|
301
|
+
assert(Number.isFinite(schema[key]), `${path}: invalid ${key}`)
|
|
302
|
+
if (!['minimum', 'maximum'].includes(key)) assert(Number.isInteger(schema[key]) && schema[key] >= 0, `${path}: invalid ${key}`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export function validateValue(schema, value, path = '$') {
|
|
306
|
+
const fail = (message) => { throw Object.assign(new Error(`${path}: ${message}`), { code: 'AGENT_INVALID_ARGUMENT' }) }
|
|
307
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type]
|
|
308
|
+
const type = value === null && types.includes('null') ? 'null' : types.find(type => type !== 'null') || 'null'
|
|
309
|
+
const valid = type === 'object' ? isRecord(value) : type === 'array' ? Array.isArray(value) : type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : typeof value === type
|
|
310
|
+
if (!valid || (typeof value === 'number' && !Number.isFinite(value))) fail(`expected ${type}`)
|
|
311
|
+
if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) fail('value is not in enum')
|
|
312
|
+
if (type === 'object') {
|
|
313
|
+
for (const key of schema.required || []) if (!Object.hasOwn(value, key)) fail(`missing ${key}`)
|
|
314
|
+
for (const [key, item] of Object.entries(value)) {
|
|
315
|
+
if (Object.hasOwn(schema.properties || {}, key)) validateValue(schema.properties[key], item, `${path}.${key}`)
|
|
316
|
+
else if (schema.additionalProperties === false) fail(`unknown field ${key}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (type === 'array') {
|
|
320
|
+
if (schema.minItems !== undefined && value.length < schema.minItems || schema.maxItems !== undefined && value.length > schema.maxItems) fail('array length out of range')
|
|
321
|
+
value.forEach((item, i) => validateValue(schema.items, item, `${path}[${i}]`))
|
|
322
|
+
}
|
|
323
|
+
if (type === 'string' && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) fail('string length out of range')
|
|
324
|
+
if (typeof value === 'number' && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) fail('number out of range')
|
|
325
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Descriptive renderer state only; neither status nor recovery hints grant access. */
|
|
2
|
+
const states = new Set(['ready', 'auth_required', 'loading', 'no_document', 'busy', 'error'])
|
|
3
|
+
const actions = new Set(['wait', 'user_login', 'open_document', 'retry', 'inspect_state'])
|
|
4
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
+
const check = (condition, message) => { if (!condition) throw new TypeError(message) }
|
|
6
|
+
const keys = (value, allowed, field) => check(Object.keys(value).every(key => allowed.includes(key)), `Unexpected ${field} field`)
|
|
7
|
+
|
|
8
|
+
export function normalizeNextAction(value) {
|
|
9
|
+
check(record(value), 'nextAction must be an object')
|
|
10
|
+
keys(value, ['type', 'capability'], 'nextAction')
|
|
11
|
+
check(actions.has(value.type), 'Invalid nextAction type')
|
|
12
|
+
if (value.capability !== undefined) check(typeof value.capability === 'string' && /^[a-z][a-z0-9_-]{0,127}$/.test(value.capability), 'Invalid nextAction capability')
|
|
13
|
+
return { type: value.type, ...(value.capability === undefined ? {} : { capability: value.capability }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeRuntimeStatus(value) {
|
|
17
|
+
check(record(value), 'Runtime status must be an object')
|
|
18
|
+
keys(value, ['state', 'reason', 'retryable', 'nextAction', 'context'], 'runtime status')
|
|
19
|
+
check(states.has(value.state), 'Invalid runtime state')
|
|
20
|
+
const result = { state: value.state }
|
|
21
|
+
if (value.reason !== undefined) {
|
|
22
|
+
check(typeof value.reason === 'string' && value.reason.length <= 512, 'Invalid runtime reason')
|
|
23
|
+
result.reason = value.reason
|
|
24
|
+
}
|
|
25
|
+
if (value.retryable !== undefined) {
|
|
26
|
+
check(typeof value.retryable === 'boolean', 'Invalid runtime retryable value')
|
|
27
|
+
result.retryable = value.retryable
|
|
28
|
+
}
|
|
29
|
+
if (value.nextAction !== undefined) result.nextAction = normalizeNextAction(value.nextAction)
|
|
30
|
+
if (value.context !== undefined) {
|
|
31
|
+
check(record(value.context), 'Runtime context must be an object')
|
|
32
|
+
keys(value.context, ['activeDocumentId'], 'runtime context')
|
|
33
|
+
if (value.context.activeDocumentId !== undefined) check(typeof value.context.activeDocumentId === 'string' && value.context.activeDocumentId.length <= 128, 'Invalid activeDocumentId')
|
|
34
|
+
result.context = value.context.activeDocumentId === undefined ? {} : { activeDocumentId: value.context.activeDocumentId }
|
|
35
|
+
}
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keep recovery metadata across process boundaries without forwarding arbitrary fields. */
|
|
40
|
+
export function serializeAgentError(error) {
|
|
41
|
+
const result = {
|
|
42
|
+
code: typeof error?.code === 'string' ? error.code.slice(0, 128) : 'AGENT_HANDLER_ERROR',
|
|
43
|
+
message: typeof error?.message === 'string' ? error.message.slice(0, 2048) : 'Application handler failed',
|
|
44
|
+
}
|
|
45
|
+
// Preserve public platform diagnostics through the renderer/child/MCP
|
|
46
|
+
// boundaries, without forwarding credentials or arbitrary error fields.
|
|
47
|
+
if (Number.isInteger(error?.status) && error.status >= 100 && error.status <= 599) result.status = error.status
|
|
48
|
+
if (typeof error?.requestId === 'string') result.requestId = error.requestId.slice(0, 256)
|
|
49
|
+
if (typeof error?.authReason === 'string') result.authReason = error.authReason.slice(0, 128)
|
|
50
|
+
if (typeof error?.reason === 'string') result.reason = error.reason.slice(0, 512)
|
|
51
|
+
if (typeof error?.retryable === 'boolean') result.retryable = error.retryable
|
|
52
|
+
if (typeof error?.executionStarted === 'boolean') result.executionStarted = error.executionStarted
|
|
53
|
+
if (error?.nextAction !== undefined) {
|
|
54
|
+
try { result.nextAction = normalizeNextAction(error.nextAction) } catch { /* Invalid hints cannot obscure the actual error. */ }
|
|
55
|
+
}
|
|
56
|
+
const recovery = {
|
|
57
|
+
AGENT_OUTCOME_UNKNOWN: { reason: 'execution_outcome_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
58
|
+
AGENT_WRITE_BUSY: { reason: 'write_pending_or_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
59
|
+
AGENT_IDEMPOTENCY_CONFLICT: { reason: 'idempotency_conflict', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
60
|
+
AGENT_APP_UNAVAILABLE: { reason: 'app_unavailable', retryable: true, nextAction: { type: 'wait' } },
|
|
61
|
+
AGENT_APP_NOT_READY: { reason: 'app_not_ready', retryable: true, nextAction: { type: 'wait' } },
|
|
62
|
+
}[result.code]
|
|
63
|
+
if (recovery) for (const [key, value] of Object.entries(recovery)) if (result[key] === undefined) result[key] = value
|
|
64
|
+
// An unknown execution outcome must never acquire an automatic retry hint.
|
|
65
|
+
if (result.code === 'AGENT_OUTCOME_UNKNOWN') {
|
|
66
|
+
result.retryable = false
|
|
67
|
+
result.nextAction = { type: 'inspect_state' }
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { basename, extname, join } from 'node:path'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
// Keep these release rules aligned with appmarket/artifact_security.go. This is
|
|
5
|
+
// a last-line leak check, not a guarantee that arbitrary secrets can be found.
|
|
6
|
+
const credentialRules = [
|
|
7
|
+
['embedded-api-key', /(?:^|[^A-Za-z0-9_-])(sk-(?:(?:proj|svcacct|ant-api\d+)-)?[A-Za-z0-9_-]{32,})/g],
|
|
8
|
+
['embedded-github-token', /(?:^|[^A-Za-z0-9_])((?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,}))/g],
|
|
9
|
+
['embedded-slack-token', /(?:^|[^A-Za-z0-9_-])(xox[baprs]-[A-Za-z0-9-]{32,})/g],
|
|
10
|
+
['embedded-session-jwt', /(?:^|[^A-Za-z0-9_-])((?:ast_)?eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{32,})/g],
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
function isPlaceholder(token) {
|
|
14
|
+
const body = token.replace(/^(?:sk-(?:(?:proj|svcacct|ant-api\d+)-)?|gh[pousr]_|github_pat_|xox[baprs]-)/, '')
|
|
15
|
+
return /^(?:x+|X+|0+|your[-_].*|replace[-_].*|example[-_].*|placeholder[-_].*)$/.test(body)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertSafeArtifactFile(filePath, data, { allowSourceMaps = false } = {}) {
|
|
19
|
+
const name = basename(filePath.replaceAll('\\', '/')).toLowerCase()
|
|
20
|
+
const reject = rule => { throw new Error(`unsafe app artifact [${rule}]: ${filePath}`) }
|
|
21
|
+
if (name === '.npmrc' || name === '.proxy-app.json' || name === '.env' || name.startsWith('.env.')) {
|
|
22
|
+
throw new Error(`sensitive file cannot be included in app bundle: ${filePath}`)
|
|
23
|
+
}
|
|
24
|
+
if (!allowSourceMaps && name.endsWith('.map')) reject('source-map-file')
|
|
25
|
+
if (['.key', '.p12', '.pfx', '.jks', '.keystore', '.ppk'].includes(extname(name)) || /^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)$/.test(name)) reject('private-key-file')
|
|
26
|
+
const text = typeof data === 'string' ? data : new TextDecoder().decode(data)
|
|
27
|
+
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text) || /PuTTY-User-Key-File-[23]:/.test(text)) reject('private-key-content')
|
|
28
|
+
if (!allowSourceMaps && /(?:\/\/[@#]|\/\*[@#])\s*sourceMappingURL\s*=\s*\S/.test(text)) reject('source-map-reference')
|
|
29
|
+
for (const [rule, pattern] of credentialRules) {
|
|
30
|
+
pattern.lastIndex = 0
|
|
31
|
+
for (const match of text.matchAll(pattern)) {
|
|
32
|
+
if (!isPlaceholder(match[1])) reject(rule)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function assertSafeArtifactDirectory(directory, prefix = '') {
|
|
38
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
39
|
+
const filePath = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
40
|
+
if (entry.isSymbolicLink()) throw new Error(`symbolic links are not allowed in app bundles: ${filePath}`)
|
|
41
|
+
const absolute = join(directory, entry.name)
|
|
42
|
+
if (entry.isDirectory()) await assertSafeArtifactDirectory(absolute, filePath)
|
|
43
|
+
else assertSafeArtifactFile(filePath, await readFile(absolute))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { realpathSync } from 'node:fs'
|
|
4
|
+
import { lstat, mkdir, unlink, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const SERVICE = 'cn.proxy-app.local-development'
|
|
9
|
+
const VALUE_PREFIX = 'proxy-app-token-v1:'
|
|
10
|
+
const TIMEOUT_MS = 5000
|
|
11
|
+
const MAX_OUTPUT_BYTES = 128 * 1024
|
|
12
|
+
// security -i reads a bounded line buffer. A longer line can be split into
|
|
13
|
+
// several commands, so it must never be submitted even though it is base64.
|
|
14
|
+
const MAX_INTERACTIVE_COMMAND_BYTES = 4000
|
|
15
|
+
const MEMORY_REASON = '系统安全凭证存储不可用,当前授权仅保存在开发进程内存中。'
|
|
16
|
+
const INVALIDATION_REASON = '无法更新本地授权失效记录,当前授权仅保存在开发进程内存中。'
|
|
17
|
+
|
|
18
|
+
function normalizeURL(value) {
|
|
19
|
+
let url
|
|
20
|
+
try { url = new URL(value) } catch { throw new Error('开发授权缓存需要有效的平台地址。') }
|
|
21
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
22
|
+
throw new Error('开发授权缓存的平台地址不能包含凭证、查询参数或片段。')
|
|
23
|
+
}
|
|
24
|
+
return url.origin + url.pathname.replace(/\/+$/, '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function credentialAccount({ baseURL, portalURL, appID, projectPath, purpose }) {
|
|
28
|
+
if (!baseURL || !appID?.trim() || !projectPath) throw new Error('开发授权缓存配置不完整。')
|
|
29
|
+
let project
|
|
30
|
+
try { project = realpathSync(projectPath) } catch { throw new Error('开发授权缓存需要已存在的项目路径。') }
|
|
31
|
+
const identity = [
|
|
32
|
+
normalizeURL(baseURL), normalizeURL(portalURL || baseURL), appID.trim(), project,
|
|
33
|
+
]
|
|
34
|
+
// Keep existing runtime cache identities intact. Developer authorization is
|
|
35
|
+
// a separate capability and must survive an app user's logout/account switch.
|
|
36
|
+
if (purpose === 'development') identity.push('development')
|
|
37
|
+
return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// No shell is involved. Only a fixed command, a hash and base64 data reach the
|
|
41
|
+
// interactive parser; quotes, newlines and command syntax in a token stay data.
|
|
42
|
+
function encodeToken(token) {
|
|
43
|
+
return VALUE_PREFIX + Buffer.from(token, 'utf8').toString('base64')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function decodeToken(value) {
|
|
47
|
+
const stored = value.replace(/\r?\n$/, '')
|
|
48
|
+
if (!stored.startsWith(VALUE_PREFIX)) return null
|
|
49
|
+
const encoded = stored.slice(VALUE_PREFIX.length)
|
|
50
|
+
if (!encoded || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null
|
|
51
|
+
const token = Buffer.from(encoded, 'base64').toString('utf8')
|
|
52
|
+
return token && encodeToken(token) === stored ? token : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runSecurityCommand(file, args, { input = '', timeoutMs }) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let child
|
|
58
|
+
let timer
|
|
59
|
+
let finished = false
|
|
60
|
+
let stdout = ''
|
|
61
|
+
let bytes = 0
|
|
62
|
+
const finish = (exitCode) => {
|
|
63
|
+
if (finished) return
|
|
64
|
+
finished = true
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
resolve({ exitCode, stdout: exitCode === 0 ? stdout : '' })
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
child = spawn(file, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: false, windowsHide: true })
|
|
70
|
+
timer = setTimeout(() => { child.kill('SIGKILL'); finish(null) }, timeoutMs)
|
|
71
|
+
child.on('error', () => finish(null))
|
|
72
|
+
child.on('close', (code) => finish(code))
|
|
73
|
+
child.stdout.on('data', (chunk) => {
|
|
74
|
+
bytes += chunk.length
|
|
75
|
+
if (bytes > MAX_OUTPUT_BYTES) { child.kill('SIGKILL'); finish(null) }
|
|
76
|
+
else stdout += chunk.toString('utf8')
|
|
77
|
+
})
|
|
78
|
+
// security may echo input in errors; never forward or retain stderr.
|
|
79
|
+
child.stderr.resume()
|
|
80
|
+
child.stdin.on('error', () => finish(null))
|
|
81
|
+
child.stdin.end(input)
|
|
82
|
+
} catch { finish(null) }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cache a Portal login for one platform, app and real project directory.
|
|
88
|
+
* App versions and local server ports intentionally do not affect its identity.
|
|
89
|
+
* runCommand is injectable and returns { exitCode, stdout }; it receives
|
|
90
|
+
* (executable, args, { input, timeoutMs }). Tokens never appear in args.
|
|
91
|
+
* stateDirectory contains only non-secret invalidation markers, never tokens.
|
|
92
|
+
*/
|
|
93
|
+
export function createLocalCredentialStore({
|
|
94
|
+
baseURL, portalURL = '', appID, projectPath,
|
|
95
|
+
purpose = 'runtime',
|
|
96
|
+
platform = process.platform,
|
|
97
|
+
runCommand = runSecurityCommand,
|
|
98
|
+
timeoutMs = TIMEOUT_MS,
|
|
99
|
+
stateDirectory = join(homedir(), 'Library', 'Caches', 'proxy-app', 'credentials'),
|
|
100
|
+
}) {
|
|
101
|
+
if (!['runtime', 'development'].includes(purpose)) throw new Error('未知的授权缓存用途。')
|
|
102
|
+
const account = credentialAccount({ baseURL, portalURL, appID, projectPath, purpose })
|
|
103
|
+
const markerPath = join(stateDirectory, account + '.disabled')
|
|
104
|
+
const boundedTimeout = Number.isFinite(timeoutMs) ? Math.max(1, Math.min(timeoutMs, TIMEOUT_MS)) : TIMEOUT_MS
|
|
105
|
+
let kind = platform === 'darwin' ? 'keychain' : 'memory'
|
|
106
|
+
let reason = kind === 'keychain' ? '' : MEMORY_REASON
|
|
107
|
+
let memoryToken = null
|
|
108
|
+
let queue = Promise.resolve()
|
|
109
|
+
|
|
110
|
+
const fallback = (message = MEMORY_REASON) => { kind = 'memory'; reason = message }
|
|
111
|
+
const serial = (operation) => {
|
|
112
|
+
const pending = queue.then(operation)
|
|
113
|
+
queue = pending.catch(() => undefined)
|
|
114
|
+
return pending
|
|
115
|
+
}
|
|
116
|
+
const security = async (args, input = '') => {
|
|
117
|
+
let timer
|
|
118
|
+
try {
|
|
119
|
+
const result = await Promise.race([
|
|
120
|
+
Promise.resolve().then(() => runCommand('/usr/bin/security', args, { input, timeoutMs: boundedTimeout })),
|
|
121
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(null), boundedTimeout) }),
|
|
122
|
+
])
|
|
123
|
+
if (!result || typeof result.stdout !== 'string' || Buffer.byteLength(result.stdout) > MAX_OUTPUT_BYTES) return null
|
|
124
|
+
return { exitCode: result.exitCode, stdout: result.stdout }
|
|
125
|
+
} catch { return null } finally { clearTimeout(timer) }
|
|
126
|
+
}
|
|
127
|
+
const invalidated = async () => {
|
|
128
|
+
try { await lstat(markerPath); return true } catch (error) {
|
|
129
|
+
if (error?.code === 'ENOENT') return false
|
|
130
|
+
fallback(INVALIDATION_REASON)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const invalidate = async () => {
|
|
135
|
+
try {
|
|
136
|
+
await mkdir(stateDirectory, { recursive: true, mode: 0o700 })
|
|
137
|
+
await writeFile(markerPath, 'Automatic restore is disabled until a new authorization is saved.\n', { flag: 'wx', mode: 0o600 })
|
|
138
|
+
return true
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === 'EEXIST') return true
|
|
141
|
+
fallback(INVALIDATION_REASON)
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
get kind() { return kind },
|
|
148
|
+
get reason() { return reason },
|
|
149
|
+
load: () => serial(async () => {
|
|
150
|
+
if (memoryToken || kind !== 'keychain' || await invalidated()) return memoryToken
|
|
151
|
+
const result = await security(['find-generic-password', '-s', SERVICE, '-a', account, '-w'])
|
|
152
|
+
if (result?.exitCode === 44) return null // errSecItemNotFound
|
|
153
|
+
if (result?.exitCode !== 0) { fallback(); return null }
|
|
154
|
+
const token = decodeToken(result.stdout)
|
|
155
|
+
if (!token) { fallback(); return null }
|
|
156
|
+
memoryToken = token
|
|
157
|
+
return token
|
|
158
|
+
}),
|
|
159
|
+
save: (token) => serial(async () => {
|
|
160
|
+
if (typeof token !== 'string' || !token || Buffer.byteLength(token) > 64 * 1024) {
|
|
161
|
+
throw new Error('开发授权凭证为空或格式无效。')
|
|
162
|
+
}
|
|
163
|
+
memoryToken = token
|
|
164
|
+
if (platform !== 'darwin' || !await invalidate() || kind !== 'keychain') return
|
|
165
|
+
const encoded = encodeToken(token)
|
|
166
|
+
const command = 'add-generic-password -U -s ' + SERVICE + ' -a ' + account + ' -w ' + encoded + '\n'
|
|
167
|
+
if (Buffer.byteLength(command) > MAX_INTERACTIVE_COMMAND_BYTES) { fallback(); return }
|
|
168
|
+
const result = await security(['-i'], command)
|
|
169
|
+
if (result?.exitCode !== 0) { fallback(); return }
|
|
170
|
+
// An interactive command's process status alone does not prove the item
|
|
171
|
+
// was updated. Keep restore disabled until the saved token is verified.
|
|
172
|
+
const saved = await security(['find-generic-password', '-s', SERVICE, '-a', account, '-w'])
|
|
173
|
+
if (saved?.exitCode !== 0 || saved.stdout.replace(/\r?\n$/, '') !== encoded) { fallback(); return }
|
|
174
|
+
try { await unlink(markerPath) } catch (error) {
|
|
175
|
+
if (error?.code !== 'ENOENT') fallback(INVALIDATION_REASON)
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
178
|
+
clear: () => serial(async () => {
|
|
179
|
+
memoryToken = null
|
|
180
|
+
if (platform !== 'darwin' || !await invalidate()) return
|
|
181
|
+
const result = await security(['delete-generic-password', '-s', SERVICE, '-a', account])
|
|
182
|
+
if (result?.exitCode !== 0 && result?.exitCode !== 44) fallback()
|
|
183
|
+
// Keep the marker even if deletion succeeds. Only a successfully saved
|
|
184
|
+
// new login may enable automatic restore after an explicit logout.
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
}
|