@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 +2 -2
- package/dist/bin/codex-remote.mjs +14 -2
- package/dist/build-platform.json +1 -1
- package/dist/shared/app-server-events.cjs +89 -10
- package/dist/src/dots-chat-server.mjs +18 -3
- package/dist/src/dots-room-activity.mjs +9 -3
- package/dist/src/dots-task-catalog.mjs +1 -1
- package/dist/src/platform/README.md +7 -12
- package/dist/src/platform/credential-store.mjs +19 -3
- package/dist/src/platform/desktop-permission-mode.mjs +17 -0
- package/dist/src/relay-config-import.mjs +45 -0
- package/dist/src/self-relay/lifecycle.mjs +6 -3
- package/dist/src/server.mjs +16 -4
- package/dist/src/shared-app-server.mjs +20 -7
- package/package.json +4 -4
- package/dist/src/platform/linux/credential-store.mjs +0 -28
- package/dist/src/platform/macos/credential-store.mjs +0 -10
- package/dist/src/platform/windows/credential-store.mjs +0 -27
- package/dist/src/platform/windows/credential-store.ps1 +0 -49
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
|
|
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
|
|
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
|
-
|
|
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
|
|
299
|
+
throw error;
|
|
288
300
|
}
|
|
289
301
|
};
|
|
290
302
|
let stoppedForUpdate = false;
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
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: '
|
|
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'
|
|
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)
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 }
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
- `
|
|
6
|
-
|
|
7
|
-
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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();
|
package/dist/src/server.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
45
|
-
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.
|
|
46
|
-
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.
|
|
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
|
-
}
|