@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.
@@ -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
+ }