@ducking-mind/proxy-app-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Public compatibility primitives for an app Agent contract (for example,
3
+ * the `proxy.desktop.agent/v1` data format). This module validates application
4
+ * declarations and schemas; runtime registration, transport, authorization,
5
+ * and host lifecycle remain in the private desktop implementation.
6
+ */
7
+
8
+ export const AGENT_PROTOCOL = 'proxy.desktop.agent/v1'
9
+ export const AGENT_PROTOCOL_VERSION = 1
10
+
11
+ export const ACCESS = Object.freeze({ READ: 'read', WRITE: 'write' })
12
+
13
+ const LEGACY_APP_ID_RE = /^app-[a-z0-9][a-z0-9._-]{0,119}$/
14
+ const PLATFORM_APP_ID_RE = /^app_[a-z0-9][a-z0-9._-]{2,119}$/
15
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
16
+ const NAMESPACE_RE = /^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/
17
+ const CAPABILITY_RE = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/
18
+ const TOOL_RE = /^proxy_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/
19
+ const AGENT_TARGETS = Object.freeze(['web', 'desktop'])
20
+ const MAX_SCHEMA_BYTES = 64 * 1024
21
+ const RESERVED_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
22
+
23
+ export function isRecord(value) {
24
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
25
+ }
26
+
27
+ export function assert(condition, message) {
28
+ if (!condition) throw new TypeError(message)
29
+ }
30
+
31
+ export function isPublicAppSlug(value) {
32
+ return typeof value === 'string' && value === value.trim() && value.length <= 120 && /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(value)
33
+ }
34
+
35
+ export function isAppManifestID(value) {
36
+ return isPublicAppSlug(value) || (typeof value === 'string' && value === value.trim() && PLATFORM_APP_ID_RE.test(value))
37
+ }
38
+
39
+ export function assertAppId(value) {
40
+ assert(isAppManifestID(value) || (typeof value === 'string' && value === value.trim() && LEGACY_APP_ID_RE.test(value)), `invalid app id: ${value}`)
41
+ return value
42
+ }
43
+
44
+ export function assertVersion(value) {
45
+ assert(typeof value === 'string' && VERSION_RE.test(value), `invalid app version: ${value}`)
46
+ return value
47
+ }
48
+
49
+ export function assertNamespace(value) {
50
+ assert(typeof value === 'string' && NAMESPACE_RE.test(value) && value.length <= 48,
51
+ `invalid agent namespace: ${value}`)
52
+ return value
53
+ }
54
+
55
+ export function assertCapabilityName(value) {
56
+ assert(typeof value === 'string' && CAPABILITY_RE.test(value) && value.length <= 64,
57
+ `invalid capability name: ${value}`)
58
+ return value
59
+ }
60
+
61
+ export function normalizeAgentTargets(value, field = 'targets', fallback = ['desktop']) {
62
+ const targets = value === undefined ? fallback : value
63
+ assert(Array.isArray(targets) && targets.length > 0 && targets.length <= AGENT_TARGETS.length, `${field} must list one or more targets`)
64
+ assert(new Set(targets).size === targets.length && targets.every(target => AGENT_TARGETS.includes(target)), `${field} must contain only web or desktop`)
65
+ return Object.freeze(AGENT_TARGETS.filter(target => targets.includes(target)))
66
+ }
67
+
68
+ export function normalizeSchema(value, field = 'schema') {
69
+ const schema = value === undefined ? { type: 'object' } : value
70
+ assert(isRecord(schema), `${field} must be a JSON schema object`)
71
+ let bytes
72
+ try { bytes = Buffer.byteLength(JSON.stringify(schema)) } catch { bytes = Infinity }
73
+ assert(bytes <= MAX_SCHEMA_BYTES, `${field} exceeds ${MAX_SCHEMA_BYTES} bytes`)
74
+ validateSchema(schema, field)
75
+ return schema
76
+ }
77
+
78
+ export function normalizeCapabilityDeclaration(input, { defaultTargets = ['desktop'] } = {}) {
79
+ assert(isRecord(input), 'agent capability must be an object')
80
+ const name = assertCapabilityName(input.name)
81
+ const description = input.description === undefined ? '' : input.description
82
+ assert(typeof description === 'string' && description.length <= 2048, `invalid description for ${name}`)
83
+ const access = input.access === undefined ? ACCESS.READ : input.access
84
+ assert(access === ACCESS.READ || access === ACCESS.WRITE, `invalid access for ${name}`)
85
+ const idempotent = input.idempotent === undefined ? access === ACCESS.READ : input.idempotent
86
+ assert(typeof idempotent === 'boolean', `invalid idempotent value for ${name}`)
87
+ const title = input.title === undefined ? name : input.title
88
+ assert(typeof title === 'string' && title.length <= 200, `invalid title for ${name}`)
89
+ for (const field of ['destructive', 'open_world', 'billable']) if (input[field] !== undefined) assert(typeof input[field] === 'boolean', `invalid ${field} for ${name}`)
90
+ const completion = input.completion || 'completed'
91
+ assert(['completed', 'accepted'].includes(completion), `invalid completion for ${name}`)
92
+ const targets = normalizeAgentTargets(input.targets, `${name}.targets`, defaultTargets)
93
+ const inputSchema = normalizeSchema(input.input_schema ?? input.inputSchema, `${name}.input_schema`)
94
+ const outputSchema = normalizeSchema(input.output_schema ?? input.outputSchema, `${name}.output_schema`)
95
+ assert(inputSchema.type === 'object' && outputSchema.type === 'object', 'tool schemas must have object roots')
96
+ if (access === ACCESS.WRITE) assert(inputSchema.properties?.idempotency_key?.type === 'string' && inputSchema.required?.includes('idempotency_key'), 'write capabilities must require a string idempotency_key')
97
+ return Object.freeze({
98
+ name,
99
+ title,
100
+ description,
101
+ destructive: input.destructive ?? access === ACCESS.WRITE,
102
+ open_world: input.open_world ?? false,
103
+ billable: input.billable ?? false,
104
+ completion,
105
+ targets,
106
+ access,
107
+ idempotent,
108
+ input_schema: inputSchema,
109
+ output_schema: outputSchema,
110
+ })
111
+ }
112
+
113
+ export function normalizeManifestAgent(manifest) {
114
+ assert(isRecord(manifest), 'manifest must be an object')
115
+ const agent = manifest.agent
116
+ if (agent === undefined) return null
117
+ assert(isRecord(agent), 'manifest.agent must be an object')
118
+ if (agent.enabled === false) return null
119
+ assert(agent.enabled === true, 'manifest.agent.enabled must be true or false')
120
+ if (agent.contract_source !== undefined) assert(agent.contract_source === 'code', 'invalid agent contract_source')
121
+ assert(!(agent.contract_source === 'code' && agent.entry), 'generated code contract must not retain agent.entry')
122
+ assert(!(agent.entry && agent.capabilities !== undefined), 'agent.entry and agent.capabilities cannot both be present')
123
+ assertAppId(manifest.id)
124
+ assertVersion(manifest.version)
125
+ const namespace = assertNamespace(agent.namespace || manifest.id.replace(/^app\./, '').replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase())
126
+ assert(!RESERVED_NAMESPACES.has(namespace), `reserved agent namespace: ${namespace}`)
127
+ const targets = normalizeAgentTargets(agent.targets, 'manifest.agent.targets')
128
+ // Source projects reference their capability module. Packaged code-first
129
+ // apps carry its generated declarations for discovery before launch.
130
+ const raw = agent.capabilities === undefined ? [] : agent.capabilities
131
+ assert(Array.isArray(raw) && raw.length <= 64, 'manifest.agent.capabilities must be an array with at most 64 entries')
132
+ const capabilities = raw.map(capability => normalizeCapabilityDeclaration(capability, { defaultTargets: targets }))
133
+ const seen = new Set()
134
+ for (const capability of capabilities) {
135
+ assert(capability.targets.every(target => targets.includes(target)), `${capability.name}.targets must be a subset of manifest.agent.targets`)
136
+ assert(!seen.has(capability.name), `duplicate agent capability: ${capability.name}`)
137
+ seen.add(capability.name)
138
+ }
139
+ const tools = capabilities.map(capability => toolNameFor(namespace, capability.name))
140
+ assert(new Set(tools).size === tools.length, 'duplicate generated tool name')
141
+ const contractVersion = assertVersion(agent.contract_version || manifest.version)
142
+ const summary = agent.summary || manifest.description || ''
143
+ assert(typeof summary === 'string' && summary.length <= 2048, 'invalid agent summary')
144
+ const keywords = agent.keywords || []
145
+ assert(Array.isArray(keywords) && keywords.length <= 24 && keywords.every(item => typeof item === 'string' && item.length > 0 && item.length <= 80), 'invalid agent keywords')
146
+ if (agent.guide !== undefined) {
147
+ assert(isRecord(agent.guide) && typeof agent.guide.entry === 'string', 'invalid agent guide')
148
+ assert(Array.isArray(agent.guide.topics || []) && (agent.guide.topics || []).length <= 16, 'invalid agent guide topics')
149
+ }
150
+ if (agent.examples !== undefined) assert(typeof agent.examples === 'string', 'invalid agent examples')
151
+ return Object.freeze({ enabled: true, namespace, targets, contractVersion, summary, keywords, guide: agent.guide, examples: agent.examples, capabilities })
152
+ }
153
+
154
+ export function toolNameFor(namespace, capability) {
155
+ assertNamespace(namespace)
156
+ assertCapabilityName(capability)
157
+ const name = `proxy_${namespace.replaceAll('-', '_').replaceAll('.', '_')}_${capability.replaceAll('-', '_')}`
158
+ assert(name.length <= 128 && TOOL_RE.test(name), `invalid generated tool name: ${name}`)
159
+ return name
160
+ }
161
+
162
+ // Deliberately bounded JSON Schema subset. Reject unsupported keywords instead
163
+ // of advertising validation that the host cannot actually enforce.
164
+ const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'description', 'title', 'default'])
165
+ export function validateSchema(schema, path = '$', depth = 0) {
166
+ assert(depth <= 16 && isRecord(schema), `${path}: invalid or too deeply nested schema`)
167
+ for (const key of Object.keys(schema)) assert(keywords.has(key), `${path}: unsupported schema keyword ${key}`)
168
+ const allowed = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
169
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
170
+ 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`)
171
+ const type = types.find(type => type !== 'null') || 'null'
172
+ if (schema.properties !== undefined) {
173
+ assert(type === 'object' && isRecord(schema.properties), `${path}: invalid properties`)
174
+ for (const [key, value] of Object.entries(schema.properties)) validateSchema(value, `${path}.${key}`, depth + 1)
175
+ }
176
+ 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`)
177
+ if (schema.additionalProperties !== undefined) assert(typeof schema.additionalProperties === 'boolean', `${path}: additionalProperties must be boolean`)
178
+ if (type === 'array') validateSchema(schema.items, `${path}[]`, depth + 1)
179
+ if (schema.enum !== undefined) assert(Array.isArray(schema.enum) && schema.enum.length > 0, `${path}: invalid enum`)
180
+ for (const key of ['minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems']) if (schema[key] !== undefined) {
181
+ assert(Number.isFinite(schema[key]), `${path}: invalid ${key}`)
182
+ if (!['minimum', 'maximum'].includes(key)) assert(Number.isInteger(schema[key]) && schema[key] >= 0, `${path}: invalid ${key}`)
183
+ }
184
+ }
185
+ export function validateValue(schema, value, path = '$') {
186
+ const fail = (message) => { throw Object.assign(new Error(`${path}: ${message}`), { code: 'AGENT_INVALID_ARGUMENT' }) }
187
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type]
188
+ const type = value === null && types.includes('null') ? 'null' : types.find(type => type !== 'null') || 'null'
189
+ const valid = type === 'object' ? isRecord(value) : type === 'array' ? Array.isArray(value) : type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : typeof value === type
190
+ if (!valid || (typeof value === 'number' && !Number.isFinite(value))) fail(`expected ${type}`)
191
+ if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) fail('value is not in enum')
192
+ if (type === 'object') {
193
+ for (const key of schema.required || []) if (!Object.hasOwn(value, key)) fail(`missing ${key}`)
194
+ for (const [key, item] of Object.entries(value)) {
195
+ if (Object.hasOwn(schema.properties || {}, key)) validateValue(schema.properties[key], item, `${path}.${key}`)
196
+ else if (schema.additionalProperties === false) fail(`unknown field ${key}`)
197
+ }
198
+ }
199
+ if (type === 'array') {
200
+ if (schema.minItems !== undefined && value.length < schema.minItems || schema.maxItems !== undefined && value.length > schema.maxItems) fail('array length out of range')
201
+ value.forEach((item, i) => validateValue(schema.items, item, `${path}[${i}]`))
202
+ }
203
+ if (type === 'string' && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength)) fail('string length out of range')
204
+ if (typeof value === 'number' && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) fail('number out of range')
205
+ }
@@ -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
+ }