@walkhi/code-relax 0.1.0-beta.59 → 0.1.0-beta.65

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 CHANGED
@@ -18,9 +18,9 @@ npm i -g @walkhi/code-relax@latest --allow-scripts=@walkhi/code-relax --foregrou
18
18
 
19
19
  ## Linux 源码测试
20
20
 
21
- 公开 beta.30 尚不含 Linux,不能用上面的 registry 命令取得本轮修改。Linux 源码测试需要官方 ChatGPT 桌面版、Node >=22.12、`secret-tool` 和已解锁的 Secret Service;Ubuntu/Debian 的工具包为 `libsecret-tools`。以普通用户在图形桌面会话中安装,不使用 sudo 运行 npm/relax。
21
+ 公开 beta.30 尚不含 Linux,不能用上面的 registry 命令取得本轮修改。Linux 源码测试需要官方 ChatGPT 桌面版、Node >=22.12;工作站身份使用当前用户状态目录的普通 JSON,不再依赖 Secret Service。以普通用户在图形桌面会话中安装,不使用 sudo 运行 npm/relax。
22
22
 
23
- 数据与服务位于 XDG Data 下的 `CodexRemote`,状态位于 XDG State 下;登录启动使用 `~/.config/autostart/cn.walkhi.code-relax.desktop`(遵循 XDG_CONFIG_HOME)。只为启动的 Desktop 实例设置托管地址;普通应用菜单启动不会自动继承,需使用 `relax desktop-recover --confirm` 接入。目录、自定义 CODEX_HOME、运行时更新与验收边界见 [Linux 实现](../docs/workstation-platforms.md#linux-工作站2026-10-15)。Mac 的钥匙串/LaunchAgent 说明也在该文档。
23
+ 数据与服务位于 XDG Data 下的 `CodexRemote`,状态位于 XDG State 下;登录启动使用 `~/.config/autostart/cn.walkhi.code-relax.desktop`(遵循 XDG_CONFIG_HOME)。只为启动的 Desktop 实例设置托管地址;普通应用菜单启动不会自动继承,需使用 `relax desktop-recover --confirm` 接入。目录、自定义 CODEX_HOME、运行时更新与验收边界见 [Linux 实现](../docs/workstation-platforms.md#linux-工作站2026-10-15)。Mac 的普通配置/LaunchAgent 说明也在该文档;旧安装身份转存与失败不停服规则见[配置存储](../docs/relay-config-storage.md)。
24
24
 
25
25
  ## Codex 内核
26
26
 
@@ -10,6 +10,7 @@ import { doctor, formatDoctorOutput } from '../src/service-doctor.mjs';
10
10
  import { launchDesktop } from '../src/desktop-launcher.mjs';
11
11
  import { desktopHost, removeDesktopShortcut, residentStartup, resolveStateRoot } from '../src/platform/desktop-host.mjs';
12
12
  import { selfRelay } from '../src/self-relay/lifecycle.mjs';
13
+ import { importRelayConfig } from '../src/relay-config-import.mjs';
13
14
  import { fileURLToPath } from 'node:url';
14
15
  import path from 'node:path';
15
16
  import { executableKey } from '../src/platform/path-identity.mjs';
@@ -265,6 +266,7 @@ async function main() {
265
266
  }
266
267
  const hadInstalledService = fs.existsSync(path.join(serviceRoot, 'installation.json'));
267
268
  const previousInstallation = readRecord(path.join(serviceRoot, 'installation.json'));
269
+ await importRelayConfig(serviceRoot, physicalStateRoot, previousInstallation?.nodeExecutable);
268
270
  if (previousInstallation?.nodeExecutable) await removeDesktopShortcut({ serviceRoot, nodeExecutable: previousInstallation.nodeExecutable });
269
271
  const desktopBeforeSetup = desktopHost.desktopIntegration === false ? { running: false, desktop: null } : await desktopHost.inspect();
270
272
  const runtime = await resolveCodexRuntime(physicalStateRoot, { refresh: true });
@@ -281,10 +283,20 @@ async function main() {
281
283
  let errors = '';
282
284
  child.stderr.on('data', chunk => { errors += chunk; });
283
285
  const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', resolve); });
284
- if (code) {
286
+ try {
287
+ if (code !== 0) throw new Error(errors.trim().replace(/^(?:Code Relax:)+/u, '') || '桌面服务启动失败');
288
+ if (previousRelay.deviceId) {
289
+ const restoredRelay = await selfRelay('relay-status', stateRoot);
290
+ if (restoredRelay.deviceId !== previousRelay.deviceId ||
291
+ (previousRelay.relayConnected && !restoredRelay.relayConnected) ||
292
+ (previousRelay.paired && !restoredRelay.paired)) {
293
+ throw new Error('更新后未确认原工作站身份和中继授权恢复,安装未完成。');
294
+ }
295
+ }
296
+ } catch (error) {
285
297
  try { await selfRelay('relay-stop', stateRoot); } catch {}
286
298
  try { await service.stop(); } catch {}
287
- throw new Error(errors.trim().replace(/^(?:Code Relax:)+/u, '') || '桌面服务启动失败');
299
+ throw error;
288
300
  }
289
301
  };
290
302
  let stoppedForUpdate = false;
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.59"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.65"}
@@ -250,10 +250,43 @@
250
250
  }
251
251
  const permissionPresets = {
252
252
  ask: { permissions: ':workspace', approvalPolicy: 'on-request', approvalsReviewer: 'user' },
253
- auto: { permissions: ':workspace', approvalPolicy: 'on-request', approvalsReviewer: 'guardian_subagent' },
253
+ auto: { permissions: ':workspace', approvalPolicy: 'on-request', approvalsReviewer: 'auto_review' },
254
254
  full: { permissions: ':danger-full-access', approvalPolicy: 'never', approvalsReviewer: 'user' },
255
255
  };
256
- function isAutoReviewer(value) { return value === 'auto_review' || value === 'guardian_subagent'; }
256
+ function isAutoReviewer(value) { return value === 'auto_review'; }
257
+ const globalPermissionKey = 'code-relax-approval-mode';
258
+ async function readGlobalPermissions(request, context = {}, optionsCache = new PermissionOptionsCache(), threadId = '') {
259
+ const { config } = await request('config/read', { includeLayers: false });
260
+ const preferred = config.desktop?.[globalPermissionKey] ?? context.desktopPermissionMode;
261
+ await optionsCache.read(request, threadId, context);
262
+ const configured = permissionState({ approvalPolicy: config.approval_policy,
263
+ approvalsReviewer: config.approvals_reviewer ?? 'user',
264
+ activePermissionProfile: config.default_permissions ? { id: config.default_permissions } : null,
265
+ sandboxPolicy: config.sandbox_mode === 'danger-full-access' ? { type: 'dangerFullAccess' }
266
+ : config.sandbox_mode === 'workspace-write' ? { type: 'workspaceWrite', networkAccess: config.sandbox_workspace_write?.network_access === true } : null });
267
+ return { ...optionsCache.context(threadId, context),
268
+ permissionMode: Object.hasOwn(permissionPresets, preferred) ? preferred : context.permissionMode ?? configured.permissionMode,
269
+ globalPermissionMode: Object.hasOwn(permissionPresets, preferred) ? preferred : null };
270
+ }
271
+ async function writeGlobalPermissions(request, mode, context = {}, optionsCache, threadId = '') {
272
+ if (!Object.hasOwn(permissionPresets, mode)) throw new Error('不支持的审批方式');
273
+ const current = await readGlobalPermissions(request, context, optionsCache, threadId);
274
+ if (!current.permissionModes.includes(mode)) throw new Error('此主机或项目配置不支持该审批方式');
275
+ const result = await request('config/batchWrite', { edits: [{ keyPath: `desktop.${globalPermissionKey}`,
276
+ value: mode, mergeStrategy: 'upsert' }], filePath: null, expectedVersion: null });
277
+ if (result.status !== 'ok') throw new Error('全局审批方式被其他配置覆盖,请核对配置');
278
+ const confirmed = await readGlobalPermissions(request, context, optionsCache, threadId);
279
+ if (confirmed.globalPermissionMode !== mode) throw new Error('全局审批方式未确认,请以当前显示为准');
280
+ return confirmed;
281
+ }
282
+ async function globalPermissionOverrides(request, context = {}, threadId = '') {
283
+ const { config } = await request('config/read', { includeLayers: false });
284
+ const mode = config.desktop?.[globalPermissionKey] ?? context.desktopPermissionMode;
285
+ if (!Object.hasOwn(permissionPresets, mode)) return {};
286
+ const options = await readPermissionOptions(request, threadId, context.cwd);
287
+ if (!availablePermissionModes(options, context).includes(mode)) throw new Error('全局审批方式不符合当前项目或模型的权限限制,请先调整审批方式');
288
+ return { ...permissionPresets[mode] };
289
+ }
257
290
  function permissionState(settings) {
258
291
  const sandboxPolicy = settings.sandboxPolicy ?? settings.sandbox;
259
292
  const { approvalPolicy, approvalsReviewer, activePermissionProfile } = settings;
@@ -281,7 +314,7 @@
281
314
  };
282
315
  const [profiles, features, requirements] = await Promise.all([
283
316
  list('permissionProfile/list', cwd ? { cwd } : {}),
284
- list('experimentalFeature/list', { threadId }),
317
+ list('experimentalFeature/list', threadId ? { threadId } : {}),
285
318
  request('configRequirements/read', {}),
286
319
  ]);
287
320
  return { profiles, guardianEnabled: features.some(feature => feature.name === 'guardian_approval' && feature.enabled), requirements: requirements.requirements };
@@ -298,7 +331,7 @@
298
331
  if (requirements?.allowedSandboxModes && !requirements.allowedSandboxModes.includes(mode === 'full' ? 'danger-full-access' : 'workspace-write')) return false;
299
332
  if (requirements?.allowedPermissionProfiles && requirements.allowedPermissionProfiles[preset.permissions] !== true) return false;
300
333
  const reviewers = requirements?.allowedApprovalsReviewers;
301
- return !reviewers || reviewers.includes(preset.approvalsReviewer) || (mode === 'auto' && reviewers.some(isAutoReviewer));
334
+ return !reviewers || reviewers.includes(preset.approvalsReviewer);
302
335
  }).map(([mode]) => mode);
303
336
  }
304
337
  class PermissionOptionsCache {
@@ -322,20 +355,59 @@
322
355
  await entry.promise;
323
356
  }
324
357
  }
358
+ class SettingsNotifications extends Map {
359
+ constructor() { super(); this.permissionWaiters = new Map(); }
360
+ set(threadId, settings) {
361
+ super.set(threadId, settings);
362
+ const waiter = this.permissionWaiters.get(threadId);
363
+ if (waiter && permissionState(settings).permissionMode === waiter.mode) waiter.finish(settings);
364
+ return this;
365
+ }
366
+ waitForPermission(threadId, mode, timeoutMs = 10_000) {
367
+ if (this.permissionWaiters.has(threadId)) throw new Error('该对话的权限变更正在等待内核确认');
368
+ let finish, settled = false;
369
+ const promise = new Promise(resolve => {
370
+ const timer = setTimeout(() => finish(new Error('权限变更结果尚未确认;请核对当前权限,不会自动重发。')), timeoutMs);
371
+ finish = result => {
372
+ if (settled) return;
373
+ settled = true;
374
+ clearTimeout(timer);
375
+ this.permissionWaiters.delete(threadId);
376
+ resolve(result);
377
+ };
378
+ });
379
+ this.permissionWaiters.set(threadId, { mode, finish });
380
+ return { promise, cancel: () => finish(null) };
381
+ }
382
+ clear() {
383
+ for (const waiter of this.permissionWaiters.values()) waiter.finish(new Error('连接已断开,权限变更结果尚未确认;不会自动重发。'));
384
+ super.clear();
385
+ }
386
+ }
325
387
  async function updatePermissions(request, contexts, notifications, optionsCache, threadId, mode) {
326
388
  if (!Object.hasOwn(permissionPresets, mode)) throw new Error('不支持的权限模式');
389
+ if (notifications.permissionWaiters.has(threadId)) throw new Error('该对话的权限变更正在等待内核确认');
327
390
  const context = contexts.get(threadId);
328
391
  if (!context) throw new Error('请先打开对话');
329
392
  await optionsCache.read(request, threadId, context);
393
+ if (!contexts.has(threadId)) throw new Error('连接已变化,请重新打开对话后设置权限');
330
394
  if (!optionsCache.context(threadId, contexts.get(threadId)).permissionModes.includes(mode)) throw new Error('此主机或项目配置不支持该权限模式');
331
- const previous = notifications.get(threadId), preset = permissionPresets[mode];
332
- await request('thread/settings/update', { threadId, ...preset });
333
- if (notifications.get(threadId) === previous) {
334
- contexts.set(threadId, { ...contexts.get(threadId), ...preset, activePermissionProfile: { id: preset.permissions, extends: null }, permissionMode: mode });
335
- }
395
+ if (contexts.get(threadId).permissionMode === mode) return optionsCache.context(threadId, contexts.get(threadId));
396
+ const waiting = notifications.waitForPermission(threadId, mode);
397
+ try {
398
+ await request('thread/settings/update', { threadId, ...permissionPresets[mode] });
399
+ // An empty RPC response only acknowledges queuing. Never invent applied settings.
400
+ const result = await waiting.promise;
401
+ if (result instanceof Error) throw result;
402
+ if (!contexts.has(threadId)) throw new Error('连接已变化,权限变更结果尚未确认');
403
+ } finally { waiting.cancel(); }
336
404
  return optionsCache.context(threadId, contexts.get(threadId));
337
405
  }
338
406
  async function createThread(request, target, input, settings, additionalContext, onThreadCreated = () => {}) {
407
+ let permissions;
408
+ try { permissions = await globalPermissionOverrides(request, { cwd: target.cwd, model: settings.model, desktopPermissionMode: settings.desktopPermissionMode }); }
409
+ catch (error) { error.deliveryUnknown = false; throw error; }
410
+ target = { ...target, ...permissions };
339
411
  let response;
340
412
  try { response = await request('thread/start', target); }
341
413
  catch (error) {
@@ -349,6 +421,7 @@
349
421
  const result = started => ({ threadId:thread.id, turnId:started?.turn?.id || null });
350
422
  try {
351
423
  const started = await request('turn/start', { threadId:thread.id, input,
424
+ ...permissions,
352
425
  ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
353
426
  ...(additionalContext ? { additionalContext: { ...additionalContext,
354
427
  ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
@@ -390,6 +463,11 @@
390
463
  createdAt: item.createdAt ?? '', error: '', native: true })) };
391
464
  }
392
465
  async function addQueue(request, threadId, input, clientUserMessageId, containsVoiceInput = false) {
466
+ try {
467
+ const { thread } = await request('thread/read', { threadId, includeTurns: false });
468
+ const permissions = await globalPermissionOverrides(request, thread, threadId);
469
+ if (Object.keys(permissions).length) await request('thread/settings/update', { threadId, ...permissions });
470
+ } catch (error) { error.deliveryUnknown = false; throw error; }
393
471
  input = [...input, { type: 'text', text: containsVoiceInput === true ? voiceInputNotice : mobileInputNotice }];
394
472
  await request('thread/queue/add', { threadId, input, clientUserMessageId });
395
473
  return readQueue(request, threadId);
@@ -428,5 +506,6 @@
428
506
  };
429
507
  }
430
508
  return { isMobileInputNotice, parseCodexDelegation, toolDelegation, projectThreadTarget, createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, beginSnapshot, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, catalogThreadProjectId, projectlessThreadTarget, updateSettings,
431
- permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions, mobileTurnSource };
509
+ permissionState, availablePermissionModes, PermissionOptionsCache, SettingsNotifications, updatePermissions,
510
+ readGlobalPermissions, writeGlobalPermissions, globalPermissionOverrides, mobileTurnSource };
432
511
  });
@@ -165,8 +165,10 @@ export class DotsChatServer extends HostedHistoryAppServer {
165
165
  // The room remains observed while the phone is away. Its epoch/sequence
166
166
  // identifies the body; the expiring token only owns snapshot-to-stream handoff.
167
167
  const same = !force && (this.matchesUpdatedAt(record.thread, knownSync?.updatedAt)
168
+ && typeof knownSync?.chatRevision === 'string' && knownSync.chatRevision === record.thread.chatRevision
168
169
  || Boolean(retained && record === observed && knownSync?.epoch === this.epoch && Number.isSafeInteger(knownSync.seq)
169
- && record.sequence === knownSync.seq));
170
+ && record.sequence === knownSync.seq
171
+ && (knownSync.chatRevision == null || knownSync.chatRevision === record.thread.chatRevision)));
170
172
  this.traceSync('window-ready', threadId, { connectMs: connectedAt - started,
171
173
  readMs: Date.now() - connectedAt, retained: Boolean(retained), notModified: same,
172
174
  sequence: record.sequence, knownSequence: knownSync?.seq ?? null, turns: record.thread.turns.length });
@@ -291,7 +293,11 @@ export class DotsChatServer extends HostedHistoryAppServer {
291
293
  do {
292
294
  const dirty = record.dirty; record.dirty = 0;
293
295
  const updates = new Set(record.updates); record.updates.clear();
294
- if (dirty) {
296
+ // An update can be the first notification for a new message (or arrive
297
+ // after an add whose HTTP read did not include it yet). Establish its
298
+ // position through the room's after cursor before treating it as old.
299
+ const unknown = [...updates].some(id => !record.thread.turns.some(turn => turn.id === id));
300
+ if (dirty || unknown) {
295
301
  let after = record.thread.turns.filter(turn => turn.roomMessage).at(-1)?.id;
296
302
  do {
297
303
  const page = await this.rooms.messages(room.id, { after }); check();
@@ -307,7 +313,16 @@ export class DotsChatServer extends HostedHistoryAppServer {
307
313
  if (!message) throw new Error('点点消息更新尚未确认。');
308
314
  const turns = dotsRoomTurns(room, [message]);
309
315
  if (record.thread.turns.some(turn => turn.id === id)) this.publish(threadId, record, mergeTurns(record.thread.turns, turns));
310
- else this.publishChanges(threadId, record, turns.map(turn => ({ ...turn, historyOnly: true })));
316
+ else {
317
+ const first = record.thread.turns.find(turn => turn.roomMessage);
318
+ // Only messages created before the retained window can safely be
319
+ // sent as edits of older history. A recent/ambiguous omission is a
320
+ // real gap: retain the body and let the existing recovery handle it.
321
+ if (!(Date.parse(message.created_at) < Date.parse(first?.createdAt))) {
322
+ throw new Error('点点新消息尚未完整接续,请重新同步。');
323
+ }
324
+ this.publishChanges(threadId, record, turns.map(turn => ({ ...turn, historyOnly: true })));
325
+ }
311
326
  }
312
327
  } while (record.dirty || record.updates.size);
313
328
  this.trimWindow(record);
@@ -49,7 +49,8 @@ export class DotsRoomActivity extends EventEmitter {
49
49
  refresh(watch) {
50
50
  const activity = watch.typing ? { kind: 'replying', label: '正在回复…', spinning: true }
51
51
  : watch.liveActivity || (watch.handoffTimer ? { kind: 'replying', label: '正在回复…', spinning: true }
52
- : watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false } : null);
52
+ : watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false }
53
+ : watch.idle ? { kind: 'idle', label: '空闲', spinning: false } : null);
53
54
  this.publish(watch, activity);
54
55
  }
55
56
  clearReply(watch) {
@@ -105,15 +106,20 @@ export class DotsRoomActivity extends EventEmitter {
105
106
  }
106
107
  if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
107
108
  const current = value.threads[0];
109
+ watch.idle = value.threads.length === 0;
108
110
  watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
109
111
  if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
110
112
  if (current.turn_status === 'failed') {
111
113
  this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
112
114
  this.refresh(watch); return;
113
115
  }
114
- if (current.turn_status !== 'in_progress') { this.refresh(watch); return; }
115
116
  const status = typeof current.activity_status?.status_type === 'string' ? current.activity_status.status_type.toLowerCase() : '';
117
+ if (current.turn_status !== 'in_progress') {
118
+ watch.idle = ['completed', 'interrupted'].includes(current.turn_status) || status === 'ready';
119
+ this.refresh(watch); return;
120
+ }
116
121
  const label = current.activity_status?.status_label;
122
+ watch.idle = status === 'ready';
117
123
  if (status !== 'ready') {
118
124
  const waiting = waitingStates.has(status);
119
125
  const fallback = Object.hasOwn(statusLabels, status) ? statusLabels[status] : null;
@@ -161,7 +167,7 @@ export class DotsRoomActivity extends EventEmitter {
161
167
  stop(watch, keepReply = false) {
162
168
  watch.generation++; clearTimeout(watch.timer); watch.timer = null;
163
169
  watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
164
- watch.subscriptionId = null; watch.revision = -1;
170
+ watch.subscriptionId = null; watch.revision = -1; watch.idle = false;
165
171
  if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
166
172
  if (!keepReply) this.publish(watch, null);
167
173
  // The SSE cancellation ends the server watch. The shared topic remains
@@ -268,7 +268,7 @@ export class DotsAppServer extends HostedHistoryAppServer {
268
268
  turnArgs.input = [{ type: 'text', text: taskFilePrompt(text, references) }, ...turnArgs.input.filter(item => item.type !== 'text')];
269
269
  }
270
270
  } catch (error) { error.deliveryUnknown = false; throw error; }
271
- return super.send(turnArgs, { assertCurrent });
271
+ return super.send(turnArgs, { assertCurrent, useGlobalPermissions: false });
272
272
  }
273
273
 
274
274
  async interrupt(threadId) {
@@ -1,12 +1,7 @@
1
- # 被控主机平台适配
2
-
3
- 当前只实现 Windows Codex:
4
-
5
- - `windows/runtime-paths.mjs`:固定服务、用户状态、Codex 数据、附件和长期凭据路径。
6
- - `windows/service-host.mjs`:网卡、Bridge 进程环境和旧状态迁移。
7
- - `windows/desktop-host.mjs`、PowerShell 脚本:普通/共享 Desktop、快捷方式和隐藏控制台启动。
8
- - `windows/credential-store.mjs/.ps1`:私有中继电脑凭据的 DPAPI 与 ACL。
9
-
10
- Windows 专有脚本兼容系统自带 PowerShell 5.1。通用生命周期使用 Node 接口;系统检测和凭据保护不扩散到业务代码。
11
-
12
- macOS、Linux 目录仅保留规划说明,尚无路径、IPC、进程或安装实现。接入排在控制端扩展之后,并需分别验证 Codex Desktop/app-server 的可用连接方式;只有独立 app-server 时不能宣称接管 Desktop 当前任务。
1
+ # 被控主机平台适配
2
+
3
+ Windows/macOS/Linux 通过公共入口选择系统路径、进程、Desktop 和后台启动适配。通用生命周期使用 Node 接口,Windows 专有脚本兼容系统自带 PowerShell 5.1。
4
+
5
+ `credential-store.mjs` 已统一为当前用户状态目录内的普通 JSON 身份配置,不再选择 DPAPI、Keychain 或 Secret Service 适配器。一次性旧身份转存只由安装前的 `relay-config-import.mjs` 执行,失败不停止旧服务。
6
+
7
+ 范围、平台验收及账号登录保护边界见[工作站跨平台](../../../docs/workstation-platforms.md)和[配置存储](../../../docs/relay-config-storage.md)。
@@ -1,3 +1,19 @@
1
- import { platformModule } from './select.mjs';
2
- const adapter = await import(platformModule('credential-store'));
3
- export const { credentialStore } = adapter;
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runtimePaths } from './runtime-paths.mjs';
4
+ import { readRecord, writeRecord } from '../service-lifecycle.mjs';
5
+ import { assertPlainPath } from '../skill-install.mjs';
6
+
7
+ // Workstation identity is ordinary per-user configuration, outside the service
8
+ // installation. No platform vault or interactive permission is needed on load.
9
+ export function credentialStore(action, record, root = runtimePaths().stateRoot) {
10
+ if (!['load', 'save'].includes(action)) throw new Error('无效的工作站配置操作。');
11
+ const file = path.join(root, 'self-relay-identity.json');
12
+ assertPlainPath(file);
13
+ if (action === 'save') {
14
+ if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('无效的工作站身份配置。');
15
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
16
+ writeRecord(file, record);
17
+ }
18
+ return { path: file, record: readRecord(file) };
19
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runtimePaths } from './runtime-paths.mjs';
4
+
5
+ // Desktop 26.1013.4894.0: LCo / agent-mode-by-host-id, local execution host.
6
+ // Read the preference only; Desktop remains the owner of its state file.
7
+ export async function desktopPermissionMode(codexDataRoot = runtimePaths().codexDataRoot) {
8
+ let state;
9
+ try {
10
+ state = JSON.parse(await fs.readFile(path.join(codexDataRoot, '.codex-global-state.json'), 'utf8'));
11
+ } catch (error) {
12
+ if (error.code === 'ENOENT') return null;
13
+ throw error;
14
+ }
15
+ const mode = state?.['electron-persisted-atom-state']?.['agent-mode-by-host-id']?.local;
16
+ return ({ auto: 'ask', 'guardian-approvals': 'auto', 'full-access': 'full' })[mode] ?? null;
17
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { credentialStore } from './platform/credential-store.mjs';
6
+ import { readRecord } from './service-lifecycle.mjs';
7
+
8
+ const exec = promisify(execFile);
9
+
10
+ // One-time installation transfer. Execute the previously installed reader at
11
+ // its original location before replacing it; the new runtime never uses a vault.
12
+ export async function importRelayConfig(serviceRoot, stateRoot, nodeExecutable = process.execPath) {
13
+ const configuredId = readRecord(path.join(stateRoot, 'self-relay-config.json'))?.deviceId;
14
+ const validate = record => {
15
+ if (record?.version !== 2 || !record.devices || typeof record.devices !== 'object' || Array.isArray(record.devices)
16
+ || Object.entries(record.devices).some(([id, item]) => !/^[\w-]{16,128}$/.test(id) || item?.id !== id
17
+ || !/^[A-Za-z0-9_-]{43}$/.test(item.secret || '') || typeof item.registered !== 'boolean')
18
+ || (configuredId && configuredId !== 'new' && !record.devices[configuredId])) {
19
+ throw new Error('工作站身份配置不完整或与当前工作站不一致,未停止旧服务。');
20
+ }
21
+ };
22
+ const current = credentialStore('load', undefined, stateRoot).record;
23
+ if (current) { validate(current); return { status: 'already-configured' }; }
24
+ const legacy = path.join(serviceRoot, 'dist/src/platform', process.platform === 'win32' ? 'windows'
25
+ : process.platform === 'darwin' ? 'macos' : 'linux', 'credential-store.mjs');
26
+ if (!fs.existsSync(legacy)) {
27
+ if (configuredId && configuredId !== 'new') throw new Error('缺少当前工作站身份配置,未停止旧服务;请先完成原配对转存。');
28
+ return { status: 'not-needed' };
29
+ }
30
+ let record;
31
+ try {
32
+ const script = 'import { pathToFileURL } from "node:url"; const {credentialStore} = await import(pathToFileURL(process.argv[1])); '
33
+ + 'process.stdout.write(JSON.stringify((await credentialStore("load")).record));';
34
+ const { stdout } = await exec(nodeExecutable, ['--input-type=module', '-e', script, legacy],
35
+ { windowsHide: true, timeout: 65000, maxBuffer: 1024 * 1024 });
36
+ record = JSON.parse(stdout.replace(/^\uFEFF/, ''));
37
+ if (record === null && !configuredId) return { status: 'not-found' };
38
+ validate(record);
39
+ } catch {
40
+ // Never log execFile's error: it can contain stdout with the old identity.
41
+ throw new Error('原设备配对配置转存失败,未停止旧服务。无人值守更新已中止,未更换工作站身份;请先完成原配对转存。');
42
+ }
43
+ credentialStore('save', record, stateRoot);
44
+ return { status: 'imported' };
45
+ }
@@ -50,12 +50,15 @@ async function createRelayRuntime(stateRoot) {
50
50
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
51
51
  let health = await serviceHealth(session());
52
52
  if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
53
- const stored = await credentialStore('load');
53
+ const stored = await credentialStore('load', undefined, stateRoot);
54
54
  const identities = stored.record || { version: 2, devices: {} };
55
55
  const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
56
56
  validSecret(item.secret) && typeof item.registered === 'boolean';
57
57
  if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
58
58
  Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
59
+ if (!stored.record && config.deviceId && config.deviceId !== 'new') {
60
+ throw new Error('缺少已配对工作站的身份配置,请先完成原配对转存;未自动更换工作站身份。');
61
+ }
59
62
  if (!config.deviceId) {
60
63
  const known = Object.values(identities.devices);
61
64
  if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
@@ -69,7 +72,7 @@ async function createRelayRuntime(stateRoot) {
69
72
  if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
70
73
  identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
71
74
  identities.devices[identity.id] = identity;
72
- await credentialStore('save', identities);
75
+ await credentialStore('save', identities, stateRoot);
73
76
  }
74
77
  const persistedConfig = readRecord(configPath) || {};
75
78
  if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
@@ -172,7 +175,7 @@ async function createRelayRuntime(stateRoot) {
172
175
  if (!identity.registered) {
173
176
  const registeredIdentity = { ...identity, registered: true };
174
177
  identities.devices[identity.id] = registeredIdentity;
175
- await credentialStore('save', identities); identity = registeredIdentity;
178
+ await credentialStore('save', identities, stateRoot); identity = registeredIdentity;
176
179
  }
177
180
  if (closing) return;
178
181
  current.assertConnected();
@@ -451,6 +451,16 @@ async function handleRequest(request, response) {
451
451
  && await dotsServer.source(decodeURIComponent(taskPath[1])) === 'aeon';
452
452
  const execution = rootChat ? dotsChat : hostId === 'durable' ? dotsServer : sharedServer;
453
453
 
454
+ if (url.pathname === '/api/permissions' && ['GET', 'POST'].includes(request.method)) {
455
+ if (hostId !== 'local') throw new RequestError(405, '当前来源不提供工作站全局审批方式。');
456
+ const body = request.method === 'POST' ? await readJsonBody(request) : {};
457
+ if (request.method === 'POST' && typeof body.mode !== 'string') throw new RequestError(400, '缺少审批方式。');
458
+ sendJson(response, 200, await sharedServer.globalPermissions(body.mode, {
459
+ threadId: url.searchParams.get('threadId') || '', cwd: url.searchParams.get('cwd') || '',
460
+ }));
461
+ return;
462
+ }
463
+
454
464
  const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
455
465
  if (request.method === 'GET' && dotsActivityMatch) {
456
466
  if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
@@ -768,10 +778,12 @@ async function handleRequest(request, response) {
768
778
  target = appServerEvents.projectThreadTarget(nativeProject);
769
779
  }
770
780
  const { input } = appServerTurnArguments('', body);
781
+ const preference = await execution.globalPermissions(undefined, { cwd: target.cwd });
771
782
  messageSubmissionStarted = true;
772
783
  const createRpc = (method, params) => method === 'thread/start'
773
784
  ? execution.startThread(params) : rpc(method, params);
774
- const created = await appServerEvents.createThread(createRpc, target, input, body, undefined, bindCreation);
785
+ const created = await appServerEvents.createThread(createRpc, target, input,
786
+ { ...body, desktopPermissionMode: preference.globalPermissionMode }, undefined, bindCreation);
775
787
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
776
788
  void generateAndSetThreadTitle({
777
789
  request: rpc,
@@ -802,7 +814,7 @@ async function handleRequest(request, response) {
802
814
  const knownSeq = url.searchParams.get('knownSeq');
803
815
  const knownSync = { id: url.searchParams.get('knownSyncId'),
804
816
  epoch: url.searchParams.get('knownEpoch'), seq: knownSeq === null ? undefined : Number(knownSeq),
805
- updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
817
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')), chatRevision: url.searchParams.get('knownChatRevision') };
806
818
  const pageOptions = sharedHistoryPageOptions();
807
819
  pageOptions.reconcileObserved = url.searchParams.get('refresh') === '1';
808
820
  if (request.socketRequest) {
@@ -840,7 +852,7 @@ async function handleRequest(request, response) {
840
852
  const after = Number(url.searchParams.get('afterSeq'));
841
853
  const requestedSync = { id: url.searchParams.get('syncId'), epoch: url.searchParams.get('syncEpoch'),
842
854
  after: Number.isSafeInteger(after) && after >= 0 ? after : undefined,
843
- updatedAt: Number(url.searchParams.get('knownUpdatedAt')) };
855
+ updatedAt: Number(url.searchParams.get('knownUpdatedAt')), chatRevision: url.searchParams.get('knownChatRevision') };
844
856
  await streamSharedThread(response, threadId, execution,
845
857
  prepareSharedTimeline, requestedSync);
846
858
  return;
@@ -859,7 +871,7 @@ async function handleRequest(request, response) {
859
871
  const current = hostId === 'durable' ? { available: false } : readContextUsage(threadId);
860
872
  const native = await execution.readContext(threadId, url.searchParams.get('refreshSettings') === 'true');
861
873
  sendJson(response, 200, { ...current, ...(native.model ? { model: native.model, thinking: native.thinking, serviceTier: native.serviceTier,
862
- permissionMode: native.permissionMode, permissionModes: native.permissionModes } : {}), ...(native.available ? native : {}) });
874
+ permissionMode: native.permissionMode, permissionModes: native.permissionModes, globalPermissionMode: native.globalPermissionMode } : {}), ...(native.available ? native : {}) });
863
875
  return;
864
876
  }
865
877
 
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import events from '../shared/app-server-events.cjs';
5
5
  import messageDelivery from '../shared/message-delivery.cjs';
6
6
  import serverRequests from '../shared/server-requests.cjs';
7
+ import { desktopPermissionMode } from './platform/desktop-permission-mode.mjs';
7
8
 
8
9
  const syncTokenTtlMs = 30_000;
9
10
  const syncBufferMaxBytes = 8 * 1024 * 1024;
@@ -45,7 +46,7 @@ export class SharedAppServer extends EventEmitter {
45
46
  });
46
47
  this.threads = new Map();
47
48
  this.contexts = new Map();
48
- this.settingsNotifications = new Map();
49
+ this.settingsNotifications = new events.SettingsNotifications();
49
50
  this.permissionOptions = new events.PermissionOptionsCache();
50
51
  this.subscriptions = new Map();
51
52
  this.usageCache = new events.UsageCache(value => this.emit('usage', value));
@@ -190,7 +191,7 @@ export class SharedAppServer extends EventEmitter {
190
191
  });
191
192
  }
192
193
 
193
- async send(args, { assertCurrent = () => {} } = {}) {
194
+ async send(args, { assertCurrent = () => {}, useGlobalPermissions = true } = {}) {
194
195
  let submitted = false;
195
196
  try {
196
197
  await this.connect();
@@ -211,8 +212,11 @@ export class SharedAppServer extends EventEmitter {
211
212
  return await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) },
212
213
  result => ({ result, usage: { kind: 'steer', turnId: active.id } }));
213
214
  }
215
+ const permissions = useGlobalPermissions ? await events.globalPermissionOverrides((method, params) => this.request(method, params),
216
+ { ...this.contexts.get(args.threadId), desktopPermissionMode: await desktopPermissionMode(), model: args.model || this.contexts.get(args.threadId)?.model }, args.threadId) : {};
217
+ assertCurrent();
214
218
  submitted = true;
215
- return await this.request('turn/start', args,
219
+ return await this.request('turn/start', { ...args, ...permissions },
216
220
  result => ({ result, usage: { kind: 'start', turnId: result?.turn?.id || null } }));
217
221
  } catch (error) {
218
222
  if (!submitted || messageDelivery.rejectedBeforeSubmission(error)) error.deliveryUnknown = false;
@@ -484,7 +488,7 @@ export class SharedAppServer extends EventEmitter {
484
488
  let snapshot = false, catchup, confirmed = false;
485
489
  if (!record || !token || record.needsReload
486
490
  || ((record.historyTrimmed || record.metadataOnly) && requested.after < record.recoverAfter)) {
487
- const fresh = await this.timeline(threadId, {}, { updatedAt: requested?.updatedAt });
491
+ const fresh = await this.timeline(threadId, {}, { updatedAt: requested?.updatedAt, chatRevision: requested?.chatRevision });
488
492
  record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
489
493
  requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
490
494
  snapshot = fresh.notModified !== true;
@@ -662,9 +666,18 @@ export class SharedAppServer extends EventEmitter {
662
666
  }
663
667
 
664
668
  async readContext(threadId) {
665
- const context = this.contexts.get(threadId);
666
- if (context) await this.permissionOptions.read((method, params) => this.request(method, params), threadId, context);
667
- return this.context(threadId);
669
+ const context = { ...this.contexts.get(threadId), desktopPermissionMode: await desktopPermissionMode() };
670
+ const permissions = await events.readGlobalPermissions((method, params) => this.request(method, params), context || {}, this.permissionOptions, threadId);
671
+ return { ...this.context(threadId), ...permissions };
672
+ }
673
+
674
+ async globalPermissions(mode, { threadId = '', cwd = '' } = {}) {
675
+ await this.connect();
676
+ if (threadId && !this.contexts.has(threadId)) throw new Error('对话设置尚未加载');
677
+ const context = { ...(threadId ? this.contexts.get(threadId) : { cwd }), desktopPermissionMode: await desktopPermissionMode() };
678
+ const request = (method, params) => this.request(method, params);
679
+ return mode === undefined ? events.readGlobalPermissions(request, context, this.permissionOptions, threadId)
680
+ : events.writeGlobalPermissions(request, mode, context, this.permissionOptions, threadId);
668
681
  }
669
682
 
670
683
  context(threadId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.59",
3
+ "version": "0.1.0-beta.65",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.59",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.59",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.59"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.65",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.65",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.65"
47
47
  }
48
48
  }
@@ -1,28 +0,0 @@
1
- import path from 'node:path';
2
- import { createHash } from 'node:crypto';
3
- import { execFile } from 'node:child_process';
4
- import { runtimePaths } from '../runtime-paths.mjs';
5
-
6
- export async function credentialStore(action, record, root = runtimePaths().credentialsRoot) {
7
- if (!['load', 'save'].includes(action)) throw new Error('无效的凭据操作。');
8
- if (action === 'save' && (!record || typeof record !== 'object' || Array.isArray(record))) throw new Error('无效的凭据记录。');
9
- const account = createHash('sha256').update(path.resolve(root)).digest('hex');
10
- const args = action === 'load' ? ['lookup'] : ['store', '--label=Code Relax'];
11
- args.push('service', 'cn.walkhi.code-relax', 'account', account);
12
- return new Promise((resolve, reject) => {
13
- const child = execFile('secret-tool', args, { encoding: 'utf8', timeout: 60000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
14
- if (error) {
15
- if (action === 'load' && error.code === 1 && !stderr.trim()) return resolve({ record: null });
16
- return reject(new Error(error.code === 'ENOENT' ? '请安装 libsecret 的 secret-tool,并在已解锁钥匙环的 Linux 桌面会话中运行。'
17
- : `Linux Secret Service 操作失败:${stderr.trim() || error.code}`));
18
- }
19
- try {
20
- const value = action === 'load' ? JSON.parse(stdout) : record;
21
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Linux 钥匙环中的凭据记录无效。');
22
- resolve({ record: value });
23
- } catch (error) { reject(error); }
24
- });
25
- child.stdin.on('error', error => { if (error.code !== 'EPIPE') reject(error); });
26
- child.stdin.end(action === 'save' ? JSON.stringify(record) : undefined);
27
- });
28
- }
@@ -1,10 +0,0 @@
1
- import path from 'node:path';
2
- import { createHash } from 'node:crypto';
3
- import { runtimePaths } from '../runtime-paths.mjs';
4
- import { systemRequest } from './system.mjs';
5
-
6
- export function credentialStore(action, record, root = runtimePaths().credentialsRoot) {
7
- if (!['load', 'save'].includes(action)) throw new Error('无效的钥匙串操作。');
8
- const account = createHash('sha256').update(path.resolve(root)).digest('hex');
9
- return systemRequest({ operation: 'credentials', action, account, record });
10
- }
@@ -1,27 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { runtimePaths } from './runtime-paths.mjs';
5
-
6
- // Secrets travel over stdin/stdout only; never PowerShell arguments or errors.
7
- export function credentialStore(action, record, root = runtimePaths().credentialsRoot) {
8
- if (process.platform !== 'win32') throw new Error('Credential platform adapter unavailable');
9
- return new Promise((resolve, reject) => {
10
- const child = spawn(path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe'),
11
- ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
12
- fileURLToPath(new URL('./credential-store.ps1', import.meta.url))],
13
- { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
14
- let output = '', diagnostic = '';
15
- const timer = setTimeout(() => child.kill(), 15000);
16
- child.stdout.setEncoding('utf8'); child.stdout.on('data', data => { output += data; });
17
- child.stderr.setEncoding('utf8'); child.stderr.on('data', data => { diagnostic += data; }); child.stdin.on('error', () => {});
18
- child.once('error', () => { clearTimeout(timer); reject(new Error('无法启动 Windows 凭据保护组件')); });
19
- child.once('close', code => {
20
- clearTimeout(timer);
21
- if (code !== 0) return reject(new Error('Windows 凭据保护失败,未使用明文替代;请检查目录权限或 Windows 用户是否一致;' + diagnostic.trim()));
22
- try { resolve(JSON.parse(output.replace(/^\uFEFF/, ''))); }
23
- catch { reject(new Error('Windows 凭据保护返回无效结果')); }
24
- });
25
- child.stdin.end(JSON.stringify({ action, root, record }));
26
- });
27
- }
@@ -1,49 +0,0 @@
1
- $ErrorActionPreference = 'Stop'
2
- [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
3
- [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
- try {
5
- Add-Type -AssemblyName System.Security
6
- $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
7
- $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
8
- $directory = Join-Path ([System.IO.Path]::GetFullPath($request.root)) $sid.Value
9
- if (!(Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null }
10
- if ((Get-Item -LiteralPath $directory).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential directory' }
11
- $acl = New-Object System.Security.AccessControl.DirectorySecurity
12
- $acl.SetAccessRuleProtection($true, $false)
13
- foreach ($principal in @($sid.Value, 'S-1-5-18', 'S-1-5-32-544')) {
14
- $identity = New-Object System.Security.Principal.SecurityIdentifier($principal)
15
- $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
16
- $acl.AddAccessRule($rule)
17
- }
18
- [System.IO.Directory]::SetAccessControl($directory, $acl)
19
- $file = Join-Path $directory 'self-relay.json'
20
- if (Test-Path -LiteralPath $file) {
21
- if ((Get-Item -LiteralPath $file).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw 'Invalid credential file' }
22
- }
23
- if ($request.action -eq 'save') {
24
- $plain = [System.Text.Encoding]::UTF8.GetBytes(($request.record | ConvertTo-Json -Depth 12 -Compress))
25
- $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($plain, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
26
- $json = @{ version = 1; protection = 'DPAPI-CurrentUser'; data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
27
- $temporary = Join-Path $directory ([Guid]::NewGuid().ToString() + '.tmp')
28
- $backup = $temporary + '.bak'
29
- try {
30
- $stream = [System.IO.File]::Open($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)
31
- try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
32
- if (Test-Path -LiteralPath $file) { [System.IO.File]::Replace($temporary, $file, $backup) } else { [System.IO.File]::Move($temporary, $file) }
33
- } finally {
34
- if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force }
35
- if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Force }
36
- }
37
- } elseif ($request.action -ne 'load') { throw 'Invalid operation' }
38
- $record = $null
39
- if (Test-Path -LiteralPath $file) {
40
- $envelope = [System.IO.File]::ReadAllText($file, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
41
- if ($envelope.version -ne 1 -or $envelope.protection -ne 'DPAPI-CurrentUser') { throw 'Invalid credential envelope' }
42
- $plain = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($envelope.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
43
- $record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
44
- }
45
- [Console]::Write((@{ path = $file; record = $record } | ConvertTo-Json -Depth 12 -Compress))
46
- } catch {
47
- [Console]::Error.WriteLine('Credential protection failed: ' + $_.Exception.GetType().Name + ' at line ' + $_.InvocationInfo.ScriptLineNumber)
48
- exit 1
49
- }