@dsh-enhanced/lark-channel 0.1.3 → 0.1.4

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.
Files changed (52) hide show
  1. package/README.md +69 -279
  2. package/bin/dsh-lark-setup.js +2 -2
  3. package/cordis.patch.yml +1 -0
  4. package/docs/operations.md +118 -0
  5. package/docs/progress-security.md +123 -0
  6. package/docs/setup.md +281 -0
  7. package/docs/supervised-growth.md +56 -0
  8. package/lib/adapter.d.ts +10 -18
  9. package/lib/adapter.d.ts.map +1 -1
  10. package/lib/adapter.js +152 -15
  11. package/lib/adapter.js.map +1 -1
  12. package/lib/config.d.ts +1 -0
  13. package/lib/config.d.ts.map +1 -1
  14. package/lib/config.js +2 -0
  15. package/lib/config.js.map +1 -1
  16. package/lib/index.d.ts +2 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +1 -1
  19. package/lib/index.js.map +1 -1
  20. package/lib/progress.d.ts +3 -1
  21. package/lib/progress.d.ts.map +1 -1
  22. package/lib/progress.js +107 -20
  23. package/lib/progress.js.map +1 -1
  24. package/lib/sdk.d.ts +9 -0
  25. package/lib/sdk.d.ts.map +1 -1
  26. package/lib/sdk.js +103 -0
  27. package/lib/sdk.js.map +1 -1
  28. package/lib/service.d.ts.map +1 -1
  29. package/lib/service.js +2 -0
  30. package/lib/service.js.map +1 -1
  31. package/lib/setup-profile.d.ts +1 -1
  32. package/lib/setup-profile.d.ts.map +1 -1
  33. package/lib/setup-profile.js +8 -4
  34. package/lib/setup-profile.js.map +1 -1
  35. package/lib/setup.d.ts +78 -1
  36. package/lib/setup.d.ts.map +1 -1
  37. package/lib/setup.js +963 -86
  38. package/lib/setup.js.map +1 -1
  39. package/lib/systemd.d.ts +25 -4
  40. package/lib/systemd.d.ts.map +1 -1
  41. package/lib/systemd.js +168 -19
  42. package/lib/systemd.js.map +1 -1
  43. package/lib/types.d.ts +18 -0
  44. package/lib/types.d.ts.map +1 -1
  45. package/lib/types.js.map +1 -1
  46. package/lib/version.d.ts +1 -1
  47. package/lib/version.js +1 -1
  48. package/lib/windows-task.d.ts +3 -0
  49. package/lib/windows-task.d.ts.map +1 -1
  50. package/lib/windows-task.js +27 -1
  51. package/lib/windows-task.js.map +1 -1
  52. package/package.json +6 -5
package/lib/setup.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
4
- import { chmod, mkdir, open, readFile, readdir, rename, rm, unlink } from 'node:fs/promises';
3
+ import { accessSync, constants, realpathSync } from 'node:fs';
4
+ import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { basename, dirname, isAbsolute, join, win32 } from 'node:path';
6
+ import { basename, dirname, isAbsolute, join, normalize, win32 } from 'node:path';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createInterface } from 'node:readline/promises';
@@ -13,6 +13,7 @@ import * as QRCode from 'qrcode';
13
13
  import { isMap, isSeq, parseDocument } from 'yaml';
14
14
  import { createOfficialLarkTransport } from './sdk.js';
15
15
  import { installDshResidentService } from './resident.js';
16
+ import { prepareDshSystemdUserService } from './systemd.js';
16
17
  import { configureLarkProfilePatch, refreshLarkAgentPolicyPatch } from './setup-profile.js';
17
18
  import { assertRawManagedProfileIntegrity, materializeManagedProfileOverride, } from './setup-materialization.js';
18
19
  const setupKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
@@ -36,6 +37,7 @@ export function parseLarkSetupArgs(argv) {
36
37
  installServiceOnly: false,
37
38
  refreshAgentPolicy: false,
38
39
  manageService: true,
40
+ linuxCredentialProvider: 'auto',
39
41
  agentTools: 'preserve',
40
42
  help: false,
41
43
  };
@@ -67,6 +69,9 @@ export function parseLarkSetupArgs(argv) {
67
69
  result.refreshAgentPolicy = true;
68
70
  else if (option === '--no-service')
69
71
  result.manageService = false;
72
+ else if (option === '--linux-credential-provider') {
73
+ result.linuxCredentialProvider = argumentValue(argv, index++, option);
74
+ }
70
75
  else if (option === '--allow-agent-tools' || option === '--disable-agent-tools') {
71
76
  if (result.agentTools !== 'preserve') {
72
77
  throw new Error('lark-channel setup: --allow-agent-tools and --disable-agent-tools are mutually exclusive');
@@ -92,7 +97,7 @@ export function parseLarkSetupArgs(argv) {
92
97
  if (!result.help && result.refreshAgentPolicy) {
93
98
  const incompatible = [
94
99
  '--domain', '--tenant', '--app-id', '--create-app', '--install-service',
95
- '--no-service', '--app-name', '--timeout-ms',
100
+ '--no-service', '--linux-credential-provider', '--app-name', '--timeout-ms',
96
101
  ].find(option => argv.includes(option));
97
102
  if (incompatible !== undefined) {
98
103
  throw new Error(`lark-channel setup: --refresh-agent-policy cannot be combined with ${incompatible}`);
@@ -107,6 +112,9 @@ export function parseLarkSetupArgs(argv) {
107
112
  if (result.installServiceOnly && (result.createApp || result.appId !== undefined)) {
108
113
  throw new Error('lark-channel setup: --install-service cannot be combined with application setup options');
109
114
  }
115
+ if (result.installServiceOnly && argv.includes('--linux-credential-provider')) {
116
+ throw new Error('lark-channel setup: --install-service cannot be combined with --linux-credential-provider');
117
+ }
110
118
  if (result.installServiceOnly && result.agentTools !== 'preserve') {
111
119
  throw new Error('lark-channel setup: --install-service cannot be combined with agent-tools options');
112
120
  }
@@ -117,6 +125,9 @@ export function parseLarkSetupArgs(argv) {
117
125
  }
118
126
  if (result.domain !== 'feishu' && result.domain !== 'lark')
119
127
  throw new Error('lark-channel setup: domain must be feishu or lark');
128
+ if (!['auto', 'protected-file', 'secret-service'].includes(result.linuxCredentialProvider)) {
129
+ throw new Error('lark-channel setup: linux-credential-provider must be auto, protected-file, or secret-service');
130
+ }
120
131
  if (!Number.isSafeInteger(result.timeoutMs) || result.timeoutMs < 30_000 || result.timeoutMs > 900_000) {
121
132
  throw new Error('lark-channel setup: timeout-ms must be an integer from 30000 to 900000');
122
133
  }
@@ -168,6 +179,12 @@ export function createVersionedCredentialLocator(input) {
168
179
  if (!credentialVersionPattern.test(version)) {
169
180
  throw new Error('lark-channel setup: invalid credential version');
170
181
  }
182
+ if (input.provider === 'linux-protected-file') {
183
+ return {
184
+ provider: input.provider,
185
+ path: join(input.dshHome, 'credentials-keychain', `lark-${input.profile}-${input.account}-${version}.secret`),
186
+ };
187
+ }
171
188
  if (input.provider === 'windows-dpapi') {
172
189
  return {
173
190
  provider: input.provider,
@@ -264,6 +281,8 @@ Options:
264
281
  --install-service Only install/restart the profile's resident service
265
282
  --refresh-agent-policy Only refresh policy from the existing channel binding
266
283
  --no-service Configure Lark without installing a resident service
284
+ --linux-credential-provider <mode>
285
+ auto, protected-file, or secret-service (default: auto)
267
286
  --allow-agent-tools Allow mounted foreground/external Agent capabilities
268
287
  --disable-agent-tools Remove setup-managed Agent capability rules
269
288
  --timeout-ms <ms> Owner DM wait, 30000..900000 (default: 300000)
@@ -271,7 +290,8 @@ Options:
271
290
 
272
291
  Without --create-app or --app-id, press Enter at the App ID prompt to select or create an app.
273
292
  App Secret is intentionally not accepted as an argument. It is stored in macOS Keychain,
274
- Linux Secret Service, or a per-user Windows DPAPI file.`;
293
+ Linux Secret Service (with an automatic private-file fallback for headless Linux),
294
+ or a per-user Windows DPAPI file.`;
275
295
  }
276
296
  function runSecurity(args, stdio) {
277
297
  const result = spawnSync('/usr/bin/security', [...args], {
@@ -284,45 +304,556 @@ function runSecurity(args, stdio) {
284
304
  throw new Error('lark-channel setup: macOS Keychain operation failed');
285
305
  return typeof result.stdout === 'string' ? result.stdout.trimEnd() : '';
286
306
  }
287
- function linuxCredentialEnv() {
307
+ function linuxCredentialEnv(environment = process.env) {
288
308
  return {
289
309
  PATH: '/usr/bin:/bin',
290
- ...(process.env.DBUS_SESSION_BUS_ADDRESS === undefined
291
- ? {} : { DBUS_SESSION_BUS_ADDRESS: process.env.DBUS_SESSION_BUS_ADDRESS }),
292
- ...(process.env.XDG_RUNTIME_DIR === undefined ? {} : { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR }),
310
+ ...(environment.DBUS_SESSION_BUS_ADDRESS === undefined
311
+ ? {} : { DBUS_SESSION_BUS_ADDRESS: environment.DBUS_SESSION_BUS_ADDRESS }),
312
+ ...(environment.XDG_RUNTIME_DIR === undefined ? {} : { XDG_RUNTIME_DIR: environment.XDG_RUNTIME_DIR }),
313
+ };
314
+ }
315
+ const linuxSecretTool = '/usr/bin/secret-tool';
316
+ /** Bound every Secret Service client call, including transaction recovery. */
317
+ const linuxSecretServiceTimeoutMs = 30_000;
318
+ /** A deliberately static diagnostic: never include a provider's stderr or a secret value. */
319
+ class LarkSetupDiagnosticError extends Error {
320
+ diagnostic;
321
+ constructor(diagnostic) {
322
+ super(`lark-channel setup: ${diagnostic}`);
323
+ this.diagnostic = diagnostic;
324
+ this.name = 'LarkSetupDiagnosticError';
325
+ }
326
+ }
327
+ function commandErrorCode(result) {
328
+ const error = result?.error;
329
+ if (error === null || typeof error !== 'object' || !('code' in error))
330
+ return undefined;
331
+ const code = error.code;
332
+ return typeof code === 'string' ? code : undefined;
333
+ }
334
+ function hasLinuxSecretServiceSession(environment) {
335
+ const dbus = environment.DBUS_SESSION_BUS_ADDRESS?.trim() ?? '';
336
+ const runtimeDirectory = environment.XDG_RUNTIME_DIR?.trim() ?? '';
337
+ return dbus !== '' || runtimeDirectory !== '';
338
+ }
339
+ function linuxSecretServiceDiagnostic(operation, result, environment) {
340
+ const common = 'Run setup as the target logged-in user (not via sudo or a detached SSH session). '
341
+ + 'For a headless server or container, use the default auto fallback or rerun with '
342
+ + '--linux-credential-provider protected-file.';
343
+ if (commandErrorCode(result) === 'ENOENT') {
344
+ return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} cannot find ${linuxSecretTool}. Install libsecret-tools and a Secret Service provider, then retry. ${common}`);
345
+ }
346
+ if (!hasLinuxSecretServiceSession(environment)) {
347
+ return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} has no user D-Bus session. Start from the target user's desktop/login session with DBUS_SESSION_BUS_ADDRESS or XDG_RUNTIME_DIR set. ${common}`);
348
+ }
349
+ const wasSignalled = result?.signal !== undefined && result.signal !== null;
350
+ if (commandErrorCode(result) === 'ETIMEDOUT' || wasSignalled) {
351
+ return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} did not respond. Unlock or start a Secret Service provider such as GNOME Keyring, KWallet, or KeePassXC, then retry. ${common}`);
352
+ }
353
+ return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} failed. Ensure ${linuxSecretTool} is installed and an unlocked org.freedesktop.secrets provider is available in this user's session, then retry. ${common}`);
354
+ }
355
+ function defaultLinuxSecretToolAvailable(path) {
356
+ try {
357
+ accessSync(path, constants.X_OK);
358
+ return true;
359
+ }
360
+ catch {
361
+ return false;
362
+ }
363
+ }
364
+ function defaultLinuxSecretServiceCommand(input) {
365
+ const result = spawnSync(input.executable, [...input.args], {
366
+ encoding: 'utf8',
367
+ env: input.environment,
368
+ timeout: linuxSecretServiceTimeoutMs,
369
+ maxBuffer: 128 * 1024,
370
+ ...(input.input === undefined ? {} : { input: input.input }),
371
+ stdio: [input.input === undefined ? 'ignore' : 'pipe', input.captureStdout ? 'pipe' : 'ignore', 'pipe'],
372
+ });
373
+ return {
374
+ status: result.status,
375
+ signal: result.signal,
376
+ ...(typeof result.stdout === 'string' ? { stdout: result.stdout } : {}),
377
+ ...(typeof result.stderr === 'string' ? { stderr: result.stderr } : {}),
378
+ ...(result.error === undefined ? {} : { error: result.error }),
379
+ };
380
+ }
381
+ function captureLinuxSecretServiceCommand(run, input) {
382
+ try {
383
+ return run(input);
384
+ }
385
+ catch (error) {
386
+ // Test seams and alternate runners may throw rather than return spawnSync's
387
+ // structured error. Preserve only the error code for static diagnostics;
388
+ // provider messages can contain secret material and must never be rendered.
389
+ return { status: null, signal: null, error };
390
+ }
391
+ }
392
+ /** libsecret reports a clear with no matching item as status 1 and no diagnostics. */
393
+ export function isLinuxSecretServiceClearAbsent(result) {
394
+ return result.status === 1
395
+ && result.signal === null
396
+ && result.error === undefined
397
+ && typeof result.stderr === 'string'
398
+ && result.stderr.trim() === '';
399
+ }
400
+ function verifyLinuxSecretServiceWriteCanary(input) {
401
+ const service = `dsh/lark/setup-preflight/${input.random(16).toString('hex')}`;
402
+ const account = 'setup-probe';
403
+ const secret = input.random(32).toString('hex');
404
+ const write = createSecretServiceWriteRequest(service, account, secret);
405
+ let primaryError;
406
+ try {
407
+ const stored = captureLinuxSecretServiceCommand(input.run, {
408
+ executable: linuxSecretTool,
409
+ args: write.args,
410
+ environment: input.environment,
411
+ input: write.input,
412
+ });
413
+ if (stored.status !== 0) {
414
+ throw linuxSecretServiceDiagnostic('credential store', stored, input.environment);
415
+ }
416
+ const read = captureLinuxSecretServiceCommand(input.run, {
417
+ executable: linuxSecretTool,
418
+ args: ['lookup', 'service', service, 'account', account],
419
+ environment: input.environment,
420
+ captureStdout: true,
421
+ });
422
+ if (read.status !== 0) {
423
+ throw linuxSecretServiceDiagnostic('credential lookup', read, input.environment);
424
+ }
425
+ if ((read.stdout ?? '').trimEnd() !== secret) {
426
+ throw new LarkSetupDiagnosticError('Linux Secret Service preflight could not read back its generated test credential. '
427
+ + 'Unlock or repair the Secret Service provider, then retry.');
428
+ }
429
+ }
430
+ catch (error) {
431
+ primaryError = error;
432
+ }
433
+ finally {
434
+ write.input.fill(0);
435
+ }
436
+ const cleared = captureLinuxSecretServiceCommand(input.run, {
437
+ executable: linuxSecretTool,
438
+ args: ['clear', 'service', service, 'account', account],
439
+ environment: input.environment,
440
+ });
441
+ if (cleared.status !== 0 && !isLinuxSecretServiceClearAbsent(cleared)) {
442
+ const cleanupError = linuxSecretServiceDiagnostic('credential cleanup', cleared, input.environment);
443
+ if (primaryError !== undefined) {
444
+ throw new AggregateError([primaryError, cleanupError], 'lark-channel setup: Linux Secret Service preflight failed and canary cleanup also failed');
445
+ }
446
+ throw cleanupError;
447
+ }
448
+ if (primaryError !== undefined)
449
+ throw primaryError;
450
+ }
451
+ /**
452
+ * Checks the Linux credential prerequisites before an OAuth flow changes a
453
+ * cloud app. It stores, reads, and clears a random non-production canary, and
454
+ * never reads, writes, or prints an application credential.
455
+ */
456
+ export function assertLinuxSecretServiceAvailable(options = {}) {
457
+ const environment = linuxCredentialEnv(options.environment ?? process.env);
458
+ const available = options.executableAvailable ?? defaultLinuxSecretToolAvailable;
459
+ if (!available(linuxSecretTool)) {
460
+ throw linuxSecretServiceDiagnostic('preflight', {
461
+ status: null, signal: null, error: { code: 'ENOENT' },
462
+ }, environment);
463
+ }
464
+ if (!hasLinuxSecretServiceSession(environment)) {
465
+ throw linuxSecretServiceDiagnostic('preflight', undefined, environment);
466
+ }
467
+ verifyLinuxSecretServiceWriteCanary({
468
+ environment,
469
+ random: options.randomBytes ?? randomBytes,
470
+ run: options.run ?? defaultLinuxSecretServiceCommand,
471
+ });
472
+ }
473
+ export function preflightLarkCredentialProvider(provider) {
474
+ if (provider === 'linux-secret-service')
475
+ assertLinuxSecretServiceAvailable();
476
+ }
477
+ async function assertLinuxProtectedFileRuntimeSupported() {
478
+ let providers;
479
+ try {
480
+ const keychain = await import('@dsh-enhanced/credentials-keychain/capabilities');
481
+ providers = keychain.supportedCredentialProviders;
482
+ }
483
+ catch (error) {
484
+ throw new Error('lark-channel setup: cannot load a compatible credentials-keychain package; reinstall matching plugin versions', { cause: error });
485
+ }
486
+ if (!Array.isArray(providers) || !providers.includes('linux-protected-file')) {
487
+ throw new Error('lark-channel setup: installed credentials-keychain does not support headless Linux; reinstall matching plugin versions');
488
+ }
489
+ }
490
+ /** Verifies that the headless backend can survive the same write/read/delete lifecycle as onboarding. */
491
+ export async function assertLinuxProtectedFileAvailable(input) {
492
+ if (!isAbsolute(input.dshHome))
493
+ throw new Error('lark-channel setup: DSH_HOME must be absolute');
494
+ const random = input.randomBytes ?? randomBytes;
495
+ const path = join(input.dshHome, 'credentials-keychain', `.lark-setup-preflight-${random(16).toString('hex')}.secret`);
496
+ const secret = random(32).toString('hex');
497
+ let stored = false;
498
+ let primaryError;
499
+ try {
500
+ await storeLinuxProtectedCredential(path, secret);
501
+ stored = true;
502
+ await input.afterStore?.(path);
503
+ if (await readLinuxProtectedCredential(path) !== secret) {
504
+ throw new Error('lark-channel setup: Linux protected credential preflight readback did not match');
505
+ }
506
+ }
507
+ catch (error) {
508
+ primaryError = error;
509
+ }
510
+ if (stored) {
511
+ try {
512
+ await removeCredential({ provider: 'linux-protected-file', path });
513
+ }
514
+ catch (cleanupError) {
515
+ if (primaryError !== undefined) {
516
+ throw new AggregateError([primaryError, cleanupError], 'lark-channel setup: Linux protected credential preflight and cleanup both failed');
517
+ }
518
+ throw cleanupError;
519
+ }
520
+ }
521
+ if (primaryError !== undefined)
522
+ throw primaryError;
523
+ }
524
+ /**
525
+ * Prefers the desktop keyring but lets a headless Linux login continue without
526
+ * weakening the profile to an ambient environment variable. Unexpected setup
527
+ * errors remain fail-closed; only our static Secret Service diagnostics select
528
+ * the protected-file backend.
529
+ */
530
+ export async function selectLarkCredentialProvider(preferred, options) {
531
+ const mode = options.mode ?? 'auto';
532
+ if (preferred !== 'linux-secret-service') {
533
+ if (mode !== 'auto') {
534
+ throw new Error('lark-channel setup: --linux-credential-provider is only valid on Linux');
535
+ }
536
+ return { provider: preferred };
537
+ }
538
+ if (mode === 'protected-file') {
539
+ await assertLinuxProtectedFileRuntimeSupported();
540
+ await assertLinuxProtectedFileAvailable(options);
541
+ return { provider: 'linux-protected-file' };
542
+ }
543
+ try {
544
+ assertLinuxSecretServiceAvailable(options);
545
+ return { provider: preferred };
546
+ }
547
+ catch (error) {
548
+ if (mode === 'secret-service' || safeDiagnosticMessages(error).length === 0)
549
+ throw error;
550
+ await assertLinuxProtectedFileRuntimeSupported();
551
+ await assertLinuxProtectedFileAvailable(options);
552
+ return { provider: 'linux-protected-file', fallbackReason: error };
553
+ }
554
+ }
555
+ function safeDiagnosticMessages(error) {
556
+ const messages = new Set();
557
+ const visited = new Set();
558
+ const walk = (current) => {
559
+ if (current === null || typeof current !== 'object' || visited.has(current))
560
+ return;
561
+ visited.add(current);
562
+ if (current instanceof LarkSetupDiagnosticError) {
563
+ messages.add(current.diagnostic);
564
+ return;
565
+ }
566
+ if (current instanceof AggregateError) {
567
+ for (const nested of current.errors)
568
+ walk(nested);
569
+ return;
570
+ }
571
+ if (current instanceof Error)
572
+ walk(current.cause);
293
573
  };
574
+ walk(error);
575
+ return [...messages];
576
+ }
577
+ /** Adds only static, credential-safe diagnostics to AggregateError CLI output. */
578
+ export function formatLarkSetupError(error) {
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ if (!(error instanceof AggregateError))
581
+ return message;
582
+ const diagnostics = safeDiagnosticMessages(error);
583
+ return diagnostics.length === 0 ? message : `${message}\n${diagnostics.map(value => `Hint: ${value}`).join('\n')}`;
584
+ }
585
+ const protectedCredentialMaxBytes = 65_536;
586
+ function currentLinuxCredentialUid() {
587
+ const uid = process.getuid?.();
588
+ const effectiveUid = process.geteuid?.();
589
+ if (uid === undefined || effectiveUid === undefined || uid !== effectiveUid) {
590
+ throw new Error('lark-channel setup: Linux protected credentials require matching real and effective user IDs');
591
+ }
592
+ return uid;
593
+ }
594
+ function linuxProtectedCredentialDirectory(path) {
595
+ if (!isAbsolute(path) || normalize(path) !== path || path.length > 1_024
596
+ || path.includes('\0') || !path.endsWith('.secret')) {
597
+ throw new Error('lark-channel setup: invalid Linux protected credential path');
598
+ }
599
+ return dirname(path);
600
+ }
601
+ async function ensureLinuxProtectedCredentialDirectory(path) {
602
+ const directory = linuxProtectedCredentialDirectory(path);
603
+ await mkdir(directory, { recursive: true, mode: 0o700 });
604
+ let metadata = await lstat(directory);
605
+ const uid = currentLinuxCredentialUid();
606
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()
607
+ || metadata.uid !== uid) {
608
+ throw new Error('lark-channel setup: Linux protected credential directory is not private to the current user');
609
+ }
610
+ if ((metadata.mode & 0o7777) !== 0o700) {
611
+ await chmod(directory, 0o700);
612
+ metadata = await lstat(directory);
613
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()
614
+ || metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o700) {
615
+ throw new Error('lark-channel setup: Linux protected credential directory could not be secured');
616
+ }
617
+ }
618
+ }
619
+ async function assertLinuxProtectedCredentialDirectory(path) {
620
+ const directory = linuxProtectedCredentialDirectory(path);
621
+ const metadata = await lstat(directory);
622
+ const uid = currentLinuxCredentialUid();
623
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()
624
+ || metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o700) {
625
+ throw new Error('lark-channel setup: Linux protected credential directory failed its security check');
626
+ }
627
+ }
628
+ async function storeLinuxProtectedCredential(path, secret) {
629
+ const value = Buffer.from(secret, 'utf8');
630
+ if (value.byteLength < 1 || value.byteLength > protectedCredentialMaxBytes || value.includes(0)) {
631
+ value.fill(0);
632
+ throw new Error('lark-channel setup: Linux protected credential value is invalid');
633
+ }
634
+ let file;
635
+ let created = false;
636
+ try {
637
+ await ensureLinuxProtectedCredentialDirectory(path);
638
+ // The locator contains a fresh random version, so an existing final item
639
+ // is a collision or interference. O_EXCL avoids following or replacing it.
640
+ file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
641
+ created = true;
642
+ await file.chmod(0o600);
643
+ await file.writeFile(value);
644
+ await file.sync();
645
+ const metadata = await file.stat();
646
+ const uid = currentLinuxCredentialUid();
647
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1
648
+ || metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o600
649
+ || metadata.size !== value.byteLength) {
650
+ throw new Error('lark-channel setup: Linux protected credential file failed its security check');
651
+ }
652
+ await file.close();
653
+ file = undefined;
654
+ await syncDirectory(dirname(path));
655
+ }
656
+ catch (error) {
657
+ try {
658
+ await file?.close();
659
+ }
660
+ catch { }
661
+ file = undefined;
662
+ if (created) {
663
+ try {
664
+ await unlink(path);
665
+ await syncDirectory(dirname(path));
666
+ }
667
+ catch (cleanupError) {
668
+ if (fileSystemErrorCode(cleanupError) !== 'ENOENT') {
669
+ throw new AggregateError([error, cleanupError], 'lark-channel setup: Linux protected credential write and cleanup both failed');
670
+ }
671
+ }
672
+ }
673
+ throw error;
674
+ }
675
+ finally {
676
+ value.fill(0);
677
+ await file?.close();
678
+ }
679
+ }
680
+ async function readLinuxProtectedCredential(path) {
681
+ await assertLinuxProtectedCredentialDirectory(path);
682
+ let file;
683
+ let value;
684
+ try {
685
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
686
+ const before = await file.stat();
687
+ const uid = currentLinuxCredentialUid();
688
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1
689
+ || before.uid !== uid || (before.mode & 0o7777) !== 0o600
690
+ || before.size < 1 || before.size > protectedCredentialMaxBytes) {
691
+ throw new Error('lark-channel setup: Linux protected credential file failed its security check');
692
+ }
693
+ value = Buffer.alloc(protectedCredentialMaxBytes + 1);
694
+ let bytesRead = 0;
695
+ while (bytesRead < value.byteLength) {
696
+ const result = await file.read(value, bytesRead, value.byteLength - bytesRead, bytesRead);
697
+ if (result.bytesRead === 0)
698
+ break;
699
+ bytesRead += result.bytesRead;
700
+ }
701
+ const after = await file.stat();
702
+ if (bytesRead < 1 || bytesRead > protectedCredentialMaxBytes
703
+ || bytesRead !== after.size
704
+ || after.dev !== before.dev || after.ino !== before.ino
705
+ || !after.isFile() || after.nlink !== 1 || after.uid !== uid || (after.mode & 0o7777) !== 0o600
706
+ || value.subarray(0, bytesRead).includes(0)) {
707
+ throw new Error('lark-channel setup: Linux protected credential value is invalid');
708
+ }
709
+ return value.toString('utf8', 0, bytesRead);
710
+ }
711
+ catch (error) {
712
+ throw new Error('lark-channel setup: Linux protected credential lookup failed', { cause: error });
713
+ }
714
+ finally {
715
+ value?.fill(0);
716
+ await file?.close();
717
+ }
718
+ }
719
+ export async function readSecretFromRawTerminal(prompt, options = {}) {
720
+ const input = options.input ?? process.stdin;
721
+ const output = options.output ?? process.stdout;
722
+ const signals = options.signals ?? process;
723
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') {
724
+ throw new Error('lark-channel setup: no secure interactive terminal is available for the App Secret');
725
+ }
726
+ output.write(prompt);
727
+ return new Promise((resolve, reject) => {
728
+ const bytes = [];
729
+ const wasRaw = input.isRaw;
730
+ const wasPaused = input.isPaused();
731
+ let settled = false;
732
+ const signalHandlers = new Map();
733
+ let onData;
734
+ const finish = (error) => {
735
+ if (settled)
736
+ return;
737
+ settled = true;
738
+ let restorationError;
739
+ try {
740
+ input.off('data', onData);
741
+ for (const [signal, listener] of signalHandlers)
742
+ signals.off(signal, listener);
743
+ input.setRawMode(Boolean(wasRaw));
744
+ if (wasPaused)
745
+ input.pause();
746
+ }
747
+ catch (caught) {
748
+ restorationError = caught;
749
+ }
750
+ finally {
751
+ output.write('\n');
752
+ }
753
+ const secretBytes = Buffer.from(bytes);
754
+ const secret = secretBytes.toString('utf8');
755
+ secretBytes.fill(0);
756
+ bytes.fill(0);
757
+ if (restorationError !== undefined) {
758
+ reject(new Error('lark-channel setup: failed to restore terminal input mode', { cause: restorationError }));
759
+ }
760
+ else if (error !== undefined)
761
+ reject(error);
762
+ else if (secret.length === 0)
763
+ reject(new Error('lark-channel setup: Linux could not read the App Secret securely'));
764
+ else
765
+ resolve(secret);
766
+ };
767
+ onData = (chunk) => {
768
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
769
+ try {
770
+ for (const byte of value) {
771
+ if (byte === 3) {
772
+ finish(new Error('lark-channel setup: App Secret input was cancelled'));
773
+ return;
774
+ }
775
+ if (byte === 10 || byte === 13) {
776
+ finish();
777
+ return;
778
+ }
779
+ if (byte === 8 || byte === 127) {
780
+ bytes.pop();
781
+ continue;
782
+ }
783
+ if (byte < 32 || bytes.length >= protectedCredentialMaxBytes) {
784
+ finish(new Error('lark-channel setup: App Secret input is invalid or too large'));
785
+ return;
786
+ }
787
+ bytes.push(byte);
788
+ }
789
+ }
790
+ finally {
791
+ value.fill(0);
792
+ }
793
+ };
794
+ for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
795
+ const listener = () => finish(new Error(`lark-channel setup: App Secret input interrupted by ${signal}`));
796
+ signalHandlers.set(signal, listener);
797
+ signals.once(signal, listener);
798
+ }
799
+ try {
800
+ input.setRawMode(true);
801
+ input.on('data', onData);
802
+ input.resume();
803
+ }
804
+ catch (error) {
805
+ finish(new Error('lark-channel setup: could not enter secure terminal input mode', { cause: error }));
806
+ }
807
+ });
808
+ }
809
+ async function readLinuxManualSecret() {
810
+ if (defaultLinuxSecretToolAvailable('/usr/bin/systemd-ask-password')) {
811
+ const prompt = spawnSync('/usr/bin/systemd-ask-password', ['飞书 App Secret:'], {
812
+ encoding: 'utf8', env: linuxCredentialEnv(), maxBuffer: 128 * 1024, stdio: ['inherit', 'pipe', 'inherit'],
813
+ });
814
+ const secret = typeof prompt.stdout === 'string' ? prompt.stdout.replace(/[\r\n]+$/u, '') : '';
815
+ if (prompt.status !== 0 || secret.length === 0) {
816
+ throw new Error('lark-channel setup: Linux could not read the App Secret securely');
817
+ }
818
+ return secret;
819
+ }
820
+ return readSecretFromRawTerminal('\n飞书 App Secret(输入不会回显):');
294
821
  }
295
822
  function executeSecretWrite(request, env, failure) {
296
823
  try {
297
824
  const result = spawnSync(request.executable, request.args, {
298
825
  encoding: 'utf8',
299
826
  env,
827
+ ...(request.executable === linuxSecretTool ? { timeout: linuxSecretServiceTimeoutMs } : {}),
300
828
  input: request.input,
301
829
  maxBuffer: 128 * 1024,
302
830
  stdio: ['pipe', 'ignore', 'pipe'],
303
831
  });
304
- if (result.status !== 0)
305
- throw new Error(failure);
832
+ if (result.status !== 0) {
833
+ throw typeof failure === 'string' ? new Error(failure) : failure(result);
834
+ }
306
835
  }
307
836
  finally {
308
837
  request.input.fill(0);
309
838
  }
310
839
  }
311
- function storeSecret(provider, service, account, credentialPath) {
840
+ async function storeSecret(provider, service, account, credentialPath) {
312
841
  if (provider === 'macos-keychain') {
313
842
  process.stdout.write('\n请在 macOS Keychain 的安全提示中输入 App Secret(输入不会回显):\n');
314
843
  runSecurity(['add-generic-password', '-U', '-a', account, '-s', service, '-w'], 'inherit');
315
844
  return;
316
845
  }
317
- if (provider === 'linux-secret-service') {
318
- const prompt = spawnSync('/usr/bin/systemd-ask-password', ['飞书 App Secret:'], {
319
- encoding: 'utf8', env: linuxCredentialEnv(), maxBuffer: 128 * 1024, stdio: ['inherit', 'pipe', 'inherit'],
320
- });
321
- const secret = typeof prompt.stdout === 'string' ? prompt.stdout.replace(/[\r\n]+$/u, '') : '';
322
- if (prompt.status !== 0 || secret.length === 0) {
323
- throw new Error('lark-channel setup: Linux could not read the App Secret securely');
846
+ if (provider === 'linux-secret-service' || provider === 'linux-protected-file') {
847
+ const secret = await readLinuxManualSecret();
848
+ if (provider === 'linux-protected-file') {
849
+ if (credentialPath === undefined)
850
+ throw new Error('lark-channel setup: missing Linux protected credential path');
851
+ await storeLinuxProtectedCredential(credentialPath, secret);
852
+ }
853
+ else {
854
+ const environment = linuxCredentialEnv();
855
+ executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), environment, result => linuxSecretServiceDiagnostic('credential store', result, environment));
324
856
  }
325
- executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), linuxCredentialEnv(), 'lark-channel setup: Linux Secret Service operation failed');
326
857
  return;
327
858
  }
328
859
  if (credentialPath === undefined || (!isAbsolute(credentialPath) && !win32.isAbsolute(credentialPath))) {
@@ -335,7 +866,7 @@ function storeSecret(provider, service, account, credentialPath) {
335
866
  if (result.status !== 0)
336
867
  throw new Error('lark-channel setup: Windows DPAPI operation failed');
337
868
  }
338
- function storeGeneratedSecret(provider, service, account, secret, credentialPath) {
869
+ async function storeGeneratedSecret(provider, service, account, secret, credentialPath) {
339
870
  if (provider === 'macos-keychain') {
340
871
  const request = createKeychainWriteRequest(service, account, secret);
341
872
  try {
@@ -352,7 +883,14 @@ function storeGeneratedSecret(provider, service, account, secret, credentialPath
352
883
  return;
353
884
  }
354
885
  if (provider === 'linux-secret-service') {
355
- executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), linuxCredentialEnv(), 'lark-channel setup: Linux Secret Service operation failed');
886
+ const environment = linuxCredentialEnv();
887
+ executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), environment, result => linuxSecretServiceDiagnostic('credential store', result, environment));
888
+ return;
889
+ }
890
+ if (provider === 'linux-protected-file') {
891
+ if (credentialPath === undefined)
892
+ throw new Error('lark-channel setup: missing Linux protected credential path');
893
+ await storeLinuxProtectedCredential(credentialPath, secret);
356
894
  return;
357
895
  }
358
896
  if (credentialPath === undefined)
@@ -361,19 +899,26 @@ function storeGeneratedSecret(provider, service, account, secret, credentialPath
361
899
  PATH: 'C:\\Windows\\System32;C:\\Windows', SystemRoot: 'C:\\Windows',
362
900
  }, 'lark-channel setup: Windows DPAPI operation failed');
363
901
  }
364
- function readSecret(provider, service, account, credentialPath) {
902
+ async function readSecret(provider, service, account, credentialPath) {
365
903
  let value = '';
366
904
  if (provider === 'macos-keychain') {
367
905
  value = runSecurity(['find-generic-password', '-w', '-a', account, '-s', service], 'pipe');
368
906
  }
369
907
  else if (provider === 'linux-secret-service') {
370
- const result = spawnSync('/usr/bin/secret-tool', ['lookup', 'service', service, 'account', account], {
371
- encoding: 'utf8', env: linuxCredentialEnv(), maxBuffer: 128 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
908
+ const environment = linuxCredentialEnv();
909
+ const result = spawnSync(linuxSecretTool, ['lookup', 'service', service, 'account', account], {
910
+ encoding: 'utf8', env: environment, timeout: linuxSecretServiceTimeoutMs,
911
+ maxBuffer: 128 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
372
912
  });
373
913
  if (result.status !== 0)
374
- throw new Error('lark-channel setup: Linux Secret Service lookup failed');
914
+ throw linuxSecretServiceDiagnostic('credential lookup', result, environment);
375
915
  value = typeof result.stdout === 'string' ? result.stdout.trimEnd() : '';
376
916
  }
917
+ else if (provider === 'linux-protected-file') {
918
+ if (credentialPath === undefined)
919
+ throw new Error('lark-channel setup: missing Linux protected credential path');
920
+ value = await readLinuxProtectedCredential(credentialPath);
921
+ }
377
922
  else {
378
923
  if (credentialPath === undefined)
379
924
  throw new Error('lark-channel setup: missing DPAPI credential path');
@@ -412,7 +957,7 @@ function exactYamlStringSequence(node, expected) {
412
957
  return node.items.every((item, index) => item?.toJSON() === expected[index]);
413
958
  }
414
959
  function exactManagedCredentialHandleShape(handle, provider) {
415
- const expectedKeys = provider === 'windows-dpapi'
960
+ const expectedKeys = provider === 'windows-dpapi' || provider === 'linux-protected-file'
416
961
  ? ['consumers', 'id', 'maxLeaseMs', 'path', 'provider', 'purposes']
417
962
  : ['account', 'consumers', 'id', 'maxLeaseMs', 'provider', 'purposes', 'service'];
418
963
  const value = handle.toJSON();
@@ -424,8 +969,9 @@ function exactManagedCredentialHandleShape(handle, provider) {
424
969
  function credentialHandleAliasesLocator(handle, locator) {
425
970
  if (!isMap(handle) || handle.get('provider') !== locator.provider)
426
971
  return false;
427
- if (locator.provider === 'windows-dpapi')
972
+ if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
428
973
  return handle.get('path') === locator.path;
974
+ }
429
975
  return handle.get('service') === locator.service && handle.get('account') === locator.account;
430
976
  }
431
977
  /**
@@ -460,7 +1006,10 @@ export function findManagedLarkCredentialLocator(input) {
460
1006
  if (!isMap(handle))
461
1007
  return undefined;
462
1008
  const provider = handle.get('provider');
463
- if ((provider !== 'macos-keychain' && provider !== 'linux-secret-service' && provider !== 'windows-dpapi')
1009
+ if ((provider !== 'macos-keychain'
1010
+ && provider !== 'linux-protected-file'
1011
+ && provider !== 'linux-secret-service'
1012
+ && provider !== 'windows-dpapi')
464
1013
  || !exactManagedCredentialHandleShape(handle, provider))
465
1014
  return undefined;
466
1015
  if (provider === 'macos-keychain' || provider === 'linux-secret-service') {
@@ -480,17 +1029,19 @@ export function findManagedLarkCredentialLocator(input) {
480
1029
  return undefined;
481
1030
  return locator;
482
1031
  }
483
- if (provider === 'windows-dpapi') {
1032
+ if (provider === 'windows-dpapi' || provider === 'linux-protected-file') {
484
1033
  const path = handle.get('path');
485
1034
  if (typeof path !== 'string')
486
1035
  return undefined;
487
1036
  const directory = platformJoin(input.dshHome, 'credentials-keychain');
488
- const legacyPath = platformJoin(directory, `lark-${input.profile}-${account}.clixml`);
1037
+ const extension = provider === 'windows-dpapi' ? '.clixml' : '.secret';
1038
+ const legacyPath = platformJoin(directory, `lark-${input.profile}-${account}${extension}`);
489
1039
  const versionPrefix = platformJoin(directory, `lark-${input.profile}-${account}-`);
490
- const version = path.startsWith(versionPrefix) && path.endsWith('.clixml')
491
- ? path.slice(versionPrefix.length, -'.clixml'.length)
1040
+ const version = path.startsWith(versionPrefix) && path.endsWith(extension)
1041
+ ? path.slice(versionPrefix.length, -extension.length)
492
1042
  : undefined;
493
- if (path !== legacyPath && (version === undefined || !credentialVersionPattern.test(version)))
1043
+ const acceptsLegacy = provider === 'windows-dpapi' && path === legacyPath;
1044
+ if (!acceptsLegacy && (version === undefined || !credentialVersionPattern.test(version)))
494
1045
  return undefined;
495
1046
  const locator = { provider, path };
496
1047
  if (handles.items.some(item => item !== handle && credentialHandleAliasesLocator(item, locator)))
@@ -500,33 +1051,43 @@ export function findManagedLarkCredentialLocator(input) {
500
1051
  return undefined;
501
1052
  }
502
1053
  async function removeCredential(locator) {
503
- if (locator.provider === 'windows-dpapi') {
1054
+ if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
504
1055
  try {
505
1056
  await unlink(locator.path);
1057
+ if (locator.provider === 'linux-protected-file')
1058
+ await syncDirectory(dirname(locator.path));
506
1059
  }
507
1060
  catch (error) {
508
1061
  if (fileSystemErrorCode(error) !== 'ENOENT') {
509
- throw new Error('lark-channel setup: Windows DPAPI credential cleanup failed', { cause: error });
1062
+ const backend = locator.provider === 'windows-dpapi' ? 'Windows DPAPI' : 'Linux protected file';
1063
+ throw new Error(`lark-channel setup: ${backend} credential cleanup failed`, { cause: error });
510
1064
  }
511
1065
  }
512
1066
  return;
513
1067
  }
514
- const result = locator.provider === 'macos-keychain'
515
- ? spawnSync('/usr/bin/security', [
516
- 'delete-generic-password', '-a', locator.account, '-s', locator.service,
517
- ], {
518
- encoding: 'utf8', env: { PATH: '/usr/bin:/bin' }, maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
519
- })
520
- : spawnSync('/usr/bin/secret-tool', [
1068
+ if (locator.provider === 'linux-secret-service') {
1069
+ const environment = linuxCredentialEnv();
1070
+ const result = spawnSync(linuxSecretTool, [
521
1071
  'clear', 'service', locator.service, 'account', locator.account,
522
1072
  ], {
523
- encoding: 'utf8', env: linuxCredentialEnv(), maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
1073
+ encoding: 'utf8', env: environment, timeout: linuxSecretServiceTimeoutMs,
1074
+ maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
524
1075
  });
525
- const missingMacosItem = locator.provider === 'macos-keychain' && result.status === 44;
1076
+ if (result.status !== 0 && !isLinuxSecretServiceClearAbsent(result)) {
1077
+ throw linuxSecretServiceDiagnostic('credential cleanup', result, environment);
1078
+ }
1079
+ return;
1080
+ }
1081
+ const result = spawnSync('/usr/bin/security', [
1082
+ 'delete-generic-password', '-a', locator.account, '-s', locator.service,
1083
+ ], {
1084
+ encoding: 'utf8', env: { PATH: '/usr/bin:/bin' }, maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
1085
+ });
1086
+ const missingMacosItem = result.status === 44;
526
1087
  const missingItemMessage = typeof result.stderr === 'string'
527
1088
  && /(?:could not be found|not found|no matching|no such)/iu.test(result.stderr);
528
1089
  if (result.status !== 0 && !missingMacosItem && !missingItemMessage) {
529
- throw new Error(`lark-channel setup: ${locator.provider} credential cleanup failed`);
1090
+ throw new Error('lark-channel setup: macos-keychain credential cleanup failed');
530
1091
  }
531
1092
  }
532
1093
  function credentialIsAlreadyAbsent(error) {
@@ -542,14 +1103,27 @@ async function removeCredentialIdempotently(locator, operation) {
542
1103
  throw error;
543
1104
  }
544
1105
  }
545
- async function retirePreviousCredentialBestEffort(locator, operation) {
1106
+ async function retirePreviousCredential(locator, operation) {
546
1107
  try {
547
1108
  await removeCredentialIdempotently(locator, operation);
548
1109
  }
1110
+ catch (error) {
1111
+ // The profile and Delivery owner already reference the staged credential,
1112
+ // so rollback is unsafe. Callers retain their paired journal and retry the
1113
+ // exact old locator before allowing another credential rotation.
1114
+ throw new AggregateError([error], 'lark-channel setup: profile and owner were committed, but previous credential cleanup is pending; repair the credential provider and rerun setup');
1115
+ }
1116
+ }
1117
+ /**
1118
+ * Compatibility behavior for the legacy non-journal commit helper below.
1119
+ * Full onboarding uses retirePreviousCredential directly so it can retain its
1120
+ * paired journal and retry the exact locator on the next invocation.
1121
+ */
1122
+ async function retirePreviousCredentialBestEffort(locator, operation) {
1123
+ try {
1124
+ await retirePreviousCredential(locator, operation);
1125
+ }
549
1126
  catch {
550
- // The profile and Delivery owner already reference the staged credential.
551
- // Retaining an inactive old secret is safer than blocking all future setup
552
- // recovery or falsely reporting that the committed rotation failed.
553
1127
  process.stderr.write('lark-channel setup: previous credential cleanup failed after commit\n');
554
1128
  }
555
1129
  }
@@ -728,9 +1302,12 @@ export async function withProfileSetupLock(patchPath, operation, options = {}) {
728
1302
  }
729
1303
  async function atomicWrite(path, value) {
730
1304
  const temporary = join(dirname(path), `.cordis.patch.yml.lark-setup-${process.pid}-${randomBytes(8).toString('hex')}`);
1305
+ let created = false;
731
1306
  try {
732
- const file = await open(temporary, 'w', 0o600);
1307
+ const file = await open(temporary, 'wx', 0o600);
1308
+ created = true;
733
1309
  try {
1310
+ await file.chmod(0o600);
734
1311
  await file.writeFile(value, 'utf8');
735
1312
  await file.sync();
736
1313
  }
@@ -741,7 +1318,8 @@ async function atomicWrite(path, value) {
741
1318
  await syncDirectory(dirname(path));
742
1319
  }
743
1320
  catch (error) {
744
- await rm(temporary, { force: true });
1321
+ if (created)
1322
+ await rm(temporary, { force: true });
745
1323
  throw error;
746
1324
  }
747
1325
  }
@@ -762,6 +1340,9 @@ async function syncDirectory(path) {
762
1340
  function setupJournalPath(patchPath) {
763
1341
  return `${patchPath}.lark-setup.journal.json`;
764
1342
  }
1343
+ function deferredCredentialCleanupPath(patchPath) {
1344
+ return `${patchPath}.lark-credential-cleanup.json`;
1345
+ }
765
1346
  function textSha256(value) {
766
1347
  return createHash('sha256').update(value).digest('hex');
767
1348
  }
@@ -771,9 +1352,14 @@ function escapeRegularExpression(value) {
771
1352
  function credentialLocatorEquals(left, right) {
772
1353
  if (left.provider !== right.provider)
773
1354
  return false;
774
- if (left.provider === 'windows-dpapi' && right.provider === 'windows-dpapi')
1355
+ if ((left.provider === 'windows-dpapi' && right.provider === 'windows-dpapi')
1356
+ || (left.provider === 'linux-protected-file' && right.provider === 'linux-protected-file')) {
775
1357
  return left.path === right.path;
776
- if (left.provider === 'windows-dpapi' || right.provider === 'windows-dpapi')
1358
+ }
1359
+ if (left.provider === 'windows-dpapi'
1360
+ || left.provider === 'linux-protected-file'
1361
+ || right.provider === 'windows-dpapi'
1362
+ || right.provider === 'linux-protected-file')
777
1363
  return false;
778
1364
  return left.service === right.service && left.account === right.account;
779
1365
  }
@@ -801,28 +1387,202 @@ function validateJournalCredentialLocator(input) {
801
1387
  }
802
1388
  return { provider: locator.provider, service: locator.service, account: locator.account };
803
1389
  }
804
- if (locator.provider === 'windows-dpapi') {
1390
+ if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
805
1391
  if (typeof locator.path !== 'string') {
806
- throw new Error('lark-channel setup: setup journal DPAPI locator is invalid');
1392
+ throw new Error('lark-channel setup: setup journal file credential locator is invalid');
807
1393
  }
808
1394
  const directory = platformJoin(input.dshHome, 'credentials-keychain');
809
1395
  if (dirname(locator.path) !== directory) {
810
- throw new Error('lark-channel setup: setup journal DPAPI locator is outside the credential directory');
1396
+ throw new Error('lark-channel setup: setup journal file credential locator is outside the credential directory');
811
1397
  }
812
1398
  const name = locator.path.slice(directory.length + 1);
813
1399
  const profile = escapeRegularExpression(input.profile);
814
1400
  const account = input.stagedAccount === undefined
815
1401
  ? '[A-Za-z0-9][A-Za-z0-9._-]{0,63}'
816
1402
  : escapeRegularExpression(input.stagedAccount);
817
- const versioned = new RegExp(`^lark-${profile}-${account}-[0-9a-f]{32}\\.clixml$`, 'u');
1403
+ const extension = locator.provider === 'windows-dpapi' ? 'clixml' : 'secret';
1404
+ const versioned = new RegExp(`^lark-${profile}-${account}-[0-9a-f]{32}\\.${extension}$`, 'u');
818
1405
  const legacy = new RegExp(`^lark-${profile}-${account}\\.clixml$`, 'u');
819
- if (!versioned.test(name) && (input.stagedAccount !== undefined || !legacy.test(name))) {
820
- throw new Error('lark-channel setup: setup journal DPAPI locator is not setup-managed');
1406
+ const acceptsLegacy = locator.provider === 'windows-dpapi'
1407
+ && input.stagedAccount === undefined
1408
+ && legacy.test(name);
1409
+ if (!versioned.test(name) && !acceptsLegacy) {
1410
+ throw new Error('lark-channel setup: setup journal file credential locator is not setup-managed');
821
1411
  }
822
1412
  return { provider: locator.provider, path: locator.path };
823
1413
  }
824
1414
  throw new Error('lark-channel setup: setup journal credential provider is invalid');
825
1415
  }
1416
+ function profileReferencesCredentialLocator(profilePatch, locator) {
1417
+ const document = parseDocument(profilePatch, { uniqueKeys: true });
1418
+ if (document.errors.length > 0 || !isSeq(document.contents)) {
1419
+ throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in an invalid profile');
1420
+ }
1421
+ const rows = document.contents.items.filter(item => isMap(item)
1422
+ && item.get('id') === 'dsh-enhanced-credentials-keychain');
1423
+ for (const row of rows) {
1424
+ const config = row.get('config', true);
1425
+ if (!isMap(config)) {
1426
+ throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in malformed credentials config');
1427
+ }
1428
+ const handles = config.get('handles', true);
1429
+ if (handles === undefined)
1430
+ continue;
1431
+ if (!isSeq(handles)) {
1432
+ throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in malformed handles config');
1433
+ }
1434
+ if (handles.items.some(handle => credentialHandleAliasesLocator(handle, locator)))
1435
+ return true;
1436
+ }
1437
+ return false;
1438
+ }
1439
+ function deferredCleanupChecksum(locators) {
1440
+ return textSha256(JSON.stringify(locators));
1441
+ }
1442
+ async function readDeferredCredentialCleanup(input) {
1443
+ const path = deferredCredentialCleanupPath(input.patchPath);
1444
+ let file;
1445
+ let buffer;
1446
+ let text;
1447
+ try {
1448
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1449
+ const before = await file.stat();
1450
+ const uid = currentLinuxCredentialUid();
1451
+ if (!before.isFile() || before.nlink !== 1 || before.uid !== uid
1452
+ || (before.mode & 0o7777) !== 0o600 || before.size < 1 || before.size > 1024 * 1024) {
1453
+ throw new Error('lark-channel setup: deferred credential cleanup record failed its security check');
1454
+ }
1455
+ buffer = Buffer.alloc(1024 * 1024 + 1);
1456
+ let bytesRead = 0;
1457
+ while (bytesRead < buffer.byteLength) {
1458
+ const result = await file.read(buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead);
1459
+ if (result.bytesRead === 0)
1460
+ break;
1461
+ bytesRead += result.bytesRead;
1462
+ }
1463
+ const after = await file.stat();
1464
+ if (bytesRead !== after.size || after.dev !== before.dev || after.ino !== before.ino
1465
+ || !after.isFile() || after.nlink !== 1 || after.uid !== uid || (after.mode & 0o7777) !== 0o600) {
1466
+ throw new Error('lark-channel setup: deferred credential cleanup record changed while being read');
1467
+ }
1468
+ text = buffer.toString('utf8', 0, bytesRead);
1469
+ }
1470
+ catch (error) {
1471
+ if (fileSystemErrorCode(error) === 'ENOENT')
1472
+ return undefined;
1473
+ throw error;
1474
+ }
1475
+ finally {
1476
+ buffer?.fill(0);
1477
+ await file?.close();
1478
+ }
1479
+ let value;
1480
+ try {
1481
+ value = JSON.parse(text);
1482
+ }
1483
+ catch (error) {
1484
+ throw new Error('lark-channel setup: deferred credential cleanup record is not valid JSON', { cause: error });
1485
+ }
1486
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1487
+ throw new Error('lark-channel setup: deferred credential cleanup record is invalid');
1488
+ }
1489
+ const record = value;
1490
+ const keys = Object.keys(record).sort();
1491
+ if (keys.join('\0') !== ['dshHome', 'locators', 'patchPath', 'profile', 'sha256', 'version'].join('\0')
1492
+ || record.version !== 1
1493
+ || record.patchPath !== input.patchPath
1494
+ || record.dshHome !== input.dshHome
1495
+ || record.profile !== input.profile
1496
+ || !Array.isArray(record.locators)
1497
+ || record.locators.length < 1
1498
+ || record.locators.length > 256) {
1499
+ throw new Error('lark-channel setup: deferred credential cleanup record identity is invalid');
1500
+ }
1501
+ const locators = record.locators.map(locator => validateJournalCredentialLocator({
1502
+ locator,
1503
+ dshHome: input.dshHome,
1504
+ profile: input.profile,
1505
+ }));
1506
+ if (record.sha256 !== deferredCleanupChecksum(locators)
1507
+ || new Set(locators.map(locator => JSON.stringify(locator))).size !== locators.length) {
1508
+ throw new Error('lark-channel setup: deferred credential cleanup record checksum or entries are invalid');
1509
+ }
1510
+ return { version: 1, ...input, locators, sha256: record.sha256 };
1511
+ }
1512
+ async function persistDeferredCredentialCleanup(input) {
1513
+ const identifiers = new Set();
1514
+ const locators = input.locators.map(locator => validateJournalCredentialLocator({
1515
+ locator,
1516
+ dshHome: input.dshHome,
1517
+ profile: input.profile,
1518
+ })).filter(locator => {
1519
+ const identifier = JSON.stringify(locator);
1520
+ if (identifiers.has(identifier))
1521
+ return false;
1522
+ identifiers.add(identifier);
1523
+ return true;
1524
+ });
1525
+ const path = deferredCredentialCleanupPath(input.patchPath);
1526
+ if (locators.length === 0) {
1527
+ try {
1528
+ await unlink(path);
1529
+ await syncDirectory(dirname(path));
1530
+ }
1531
+ catch (error) {
1532
+ if (fileSystemErrorCode(error) !== 'ENOENT')
1533
+ throw error;
1534
+ }
1535
+ return;
1536
+ }
1537
+ if (locators.length > 256)
1538
+ throw new Error('lark-channel setup: too many deferred credential cleanup entries');
1539
+ const record = {
1540
+ version: 1,
1541
+ patchPath: input.patchPath,
1542
+ dshHome: input.dshHome,
1543
+ profile: input.profile,
1544
+ locators,
1545
+ sha256: deferredCleanupChecksum(locators),
1546
+ };
1547
+ await atomicWrite(path, `${JSON.stringify(record)}\n`);
1548
+ }
1549
+ function canDeferSecretServiceCleanup(locator, error) {
1550
+ return locator.provider === 'linux-secret-service' && safeDiagnosticMessages(error).length > 0;
1551
+ }
1552
+ async function deferInactiveCredentialCleanup(input) {
1553
+ if (profileReferencesCredentialLocator(input.profilePatch, input.locator)) {
1554
+ throw new Error('lark-channel setup: refusing to defer cleanup for a credential still referenced by the profile');
1555
+ }
1556
+ const current = await readDeferredCredentialCleanup(input);
1557
+ await persistDeferredCredentialCleanup({
1558
+ patchPath: input.patchPath,
1559
+ dshHome: input.dshHome,
1560
+ profile: input.profile,
1561
+ locators: [...(current?.locators ?? []), input.locator],
1562
+ });
1563
+ }
1564
+ /** Best-effort retry for already proven-inactive credentials. Call while holding the profile lock. */
1565
+ export async function retryDeferredLarkCredentialCleanup(input) {
1566
+ const pending = await readDeferredCredentialCleanup(input);
1567
+ if (pending === undefined)
1568
+ return 0;
1569
+ const profilePatch = await readFile(input.patchPath, 'utf8');
1570
+ const remove = input.removeCredential ?? removeCredential;
1571
+ const remaining = [];
1572
+ for (const locator of pending.locators) {
1573
+ if (profileReferencesCredentialLocator(profilePatch, locator)) {
1574
+ throw new Error('lark-channel setup: a deferred credential cleanup entry became active; refusing cleanup');
1575
+ }
1576
+ try {
1577
+ await removeCredentialIdempotently(locator, remove);
1578
+ }
1579
+ catch {
1580
+ remaining.push(locator);
1581
+ }
1582
+ }
1583
+ await persistDeferredCredentialCleanup({ ...input, locators: remaining });
1584
+ return remaining.length;
1585
+ }
826
1586
  function normalizeSetupJournalInput(input) {
827
1587
  if ((!isAbsolute(input.patchPath) && !win32.isAbsolute(input.patchPath))
828
1588
  || (!isAbsolute(input.dshHome) && !win32.isAbsolute(input.dshHome))
@@ -1406,6 +2166,23 @@ async function withDeliveryOwnerSetupLock(databasePath, operation, options = {})
1406
2166
  await mkdir(dirname(resourcePath), { recursive: true, mode: 0o700 });
1407
2167
  return withProfileSetupLock(`${resourcePath}.lark-owner`, operation, options);
1408
2168
  }
2169
+ async function removeInactiveCredentialForRecovery(input) {
2170
+ try {
2171
+ await removeCredentialIdempotently(input.locator, input.remove);
2172
+ }
2173
+ catch (error) {
2174
+ if (!input.recovery.allowDeferredSecretServiceCleanup
2175
+ || !canDeferSecretServiceCleanup(input.locator, error))
2176
+ throw error;
2177
+ await deferInactiveCredentialCleanup({
2178
+ patchPath: input.recovery.patchPath,
2179
+ dshHome: input.recovery.dshHome,
2180
+ profile: input.recovery.profile,
2181
+ profilePatch: input.profilePatch,
2182
+ locator: input.locator,
2183
+ });
2184
+ }
2185
+ }
1409
2186
  async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
1410
2187
  const remove = input.operations?.removeCredential ?? removeCredential;
1411
2188
  const pair = input.operations?.pairPrincipal ?? defaultPairPrincipal;
@@ -1416,7 +2193,12 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
1416
2193
  if (current !== journal.originalPatch) {
1417
2194
  throw new Error('lark-channel setup: staging journal conflicts with the live profile');
1418
2195
  }
1419
- await removeCredentialIdempotently(journal.stagedCredential, remove);
2196
+ await removeInactiveCredentialForRecovery({
2197
+ recovery: input,
2198
+ locator: journal.stagedCredential,
2199
+ profilePatch: current,
2200
+ remove,
2201
+ });
1420
2202
  await clearLarkSetupJournal(input.patchPath);
1421
2203
  return true;
1422
2204
  }
@@ -1427,7 +2209,12 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
1427
2209
  throw new Error('lark-channel setup: candidate recovery refused because the live profile diverged');
1428
2210
  }
1429
2211
  if (journal.stagedCredential !== undefined) {
1430
- await removeCredentialIdempotently(journal.stagedCredential, remove);
2212
+ await removeInactiveCredentialForRecovery({
2213
+ recovery: input,
2214
+ locator: journal.stagedCredential,
2215
+ profilePatch: journal.originalPatch,
2216
+ remove,
2217
+ });
1431
2218
  }
1432
2219
  await clearLarkSetupJournal(input.patchPath);
1433
2220
  return true;
@@ -1446,7 +2233,22 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
1446
2233
  await persistLarkSetupJournal(journalPersistInput(journal, 'paired'));
1447
2234
  if (journal.previousCredential !== undefined
1448
2235
  && !credentialLocatorEquals(journal.previousCredential, journal.stagedCredential)) {
1449
- await retirePreviousCredentialBestEffort(journal.previousCredential, remove);
2236
+ try {
2237
+ await retirePreviousCredential(journal.previousCredential, remove);
2238
+ }
2239
+ catch (error) {
2240
+ const migratingToProtectedFile = journal.stagedCredential?.provider === 'linux-protected-file';
2241
+ if ((!input.allowDeferredSecretServiceCleanup && !migratingToProtectedFile)
2242
+ || !canDeferSecretServiceCleanup(journal.previousCredential, error))
2243
+ throw error;
2244
+ await deferInactiveCredentialCleanup({
2245
+ patchPath: input.patchPath,
2246
+ dshHome: input.dshHome,
2247
+ profile: input.profile,
2248
+ profilePatch: journal.updatedPatch,
2249
+ locator: journal.previousCredential,
2250
+ });
2251
+ }
1450
2252
  }
1451
2253
  if (journal.installService)
1452
2254
  await install();
@@ -1493,6 +2295,14 @@ async function recoverValidatedRefreshJournalUnlocked(input, journal) {
1493
2295
  return true;
1494
2296
  }
1495
2297
  async function recoverLarkSetupJournalUnlocked(input) {
2298
+ await retryDeferredLarkCredentialCleanup({
2299
+ patchPath: input.patchPath,
2300
+ dshHome: input.dshHome,
2301
+ profile: input.profile,
2302
+ ...(input.operations?.removeCredential === undefined
2303
+ ? {}
2304
+ : { removeCredential: input.operations.removeCredential }),
2305
+ });
1496
2306
  const journal = await readLarkSetupJournal(input);
1497
2307
  if (journal === undefined)
1498
2308
  return false;
@@ -1626,15 +2436,7 @@ export async function commitValidatedLarkOwnerSetup(input) {
1626
2436
  }
1627
2437
  const previous = input.credentialTransition?.previous;
1628
2438
  if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(input.credentialTransition?.staged)) {
1629
- try {
1630
- await remove(previous);
1631
- }
1632
- catch {
1633
- // The profile and owner are already committed. Keep the now-inactive
1634
- // credential rather than reporting a false setup failure or rolling back
1635
- // a successful handoff; a later setup can retire the orphan safely.
1636
- process.stderr.write('lark-channel setup: previous credential cleanup failed after commit\n');
1637
- }
2439
+ await retirePreviousCredentialBestEffort(previous, remove);
1638
2440
  }
1639
2441
  }
1640
2442
  async function abortStagingJournal(input) {
@@ -1644,6 +2446,22 @@ async function abortStagingJournal(input) {
1644
2446
  cause: input.setupError,
1645
2447
  });
1646
2448
  }
2449
+ if (input.stagedCredential.provider === 'linux-protected-file'
2450
+ && fileSystemErrorCode(input.setupError) === 'EEXIST') {
2451
+ // O_EXCL proves this setup never created the colliding path. Removing it
2452
+ // here would delete somebody else's file merely because our random locator
2453
+ // collided (or was deliberately pre-created). Clear only our journal; the
2454
+ // next invocation will generate a new versioned locator.
2455
+ try {
2456
+ await clearLarkSetupJournal(input.patchPath);
2457
+ }
2458
+ catch (journalError) {
2459
+ throw new AggregateError([input.setupError, journalError], 'lark-channel setup: protected credential path collided and setup journal cleanup also failed');
2460
+ }
2461
+ throw new Error('lark-channel setup: protected credential path collision; rerun setup', {
2462
+ cause: input.setupError,
2463
+ });
2464
+ }
1647
2465
  try {
1648
2466
  await removeCredentialIdempotently(input.stagedCredential, input.remove);
1649
2467
  await clearLarkSetupJournal(input.patchPath);
@@ -1690,6 +2508,7 @@ export async function executeLarkSetupProfileTransaction(input) {
1690
2508
  readEffectiveProfile,
1691
2509
  ...(input.operations.afterCommit === undefined ? {} : { installService: input.operations.afterCommit }),
1692
2510
  },
2511
+ allowDeferredSecretServiceCleanup: input.credentialProvider === 'linux-protected-file',
1693
2512
  });
1694
2513
  const originalPatch = await readFile(input.patchPath, 'utf8');
1695
2514
  const effectiveProfile = await readEffectiveProfile(input.args.profile);
@@ -1716,7 +2535,8 @@ export async function executeLarkSetupProfileTransaction(input) {
1716
2535
  profile: input.args.profile,
1717
2536
  account: input.args.account,
1718
2537
  });
1719
- if (stagedCredential.provider === 'windows-dpapi') {
2538
+ if (stagedCredential.provider === 'windows-dpapi'
2539
+ || stagedCredential.provider === 'linux-protected-file') {
1720
2540
  await mkdir(dirname(stagedCredential.path), { recursive: true, mode: 0o700 });
1721
2541
  }
1722
2542
  const transactionId = randomBytes(16).toString('hex');
@@ -1756,7 +2576,9 @@ export async function executeLarkSetupProfileTransaction(input) {
1756
2576
  }
1757
2577
  const keychainService = stagedCredential.provider === 'windows-dpapi'
1758
2578
  ? `dsh/lark/${input.args.profile}/${input.args.account}/versions/windows-dpapi`
1759
- : stagedCredential.service;
2579
+ : stagedCredential.provider === 'linux-protected-file'
2580
+ ? `dsh/lark/${input.args.profile}/${input.args.account}/versions/linux-protected-file`
2581
+ : stagedCredential.service;
1760
2582
  return {
1761
2583
  owner,
1762
2584
  updatedPatch: configureLarkProfilePatch({
@@ -1771,7 +2593,10 @@ export async function executeLarkSetupProfileTransaction(input) {
1771
2593
  keychainAccount: input.args.account,
1772
2594
  credentialProvider: input.credentialProvider,
1773
2595
  agentTools: input.args.agentTools,
1774
- ...(stagedCredential.provider === 'windows-dpapi' ? { credentialPath: stagedCredential.path } : {}),
2596
+ ...(stagedCredential.provider === 'windows-dpapi'
2597
+ || stagedCredential.provider === 'linux-protected-file'
2598
+ ? { credentialPath: stagedCredential.path }
2599
+ : {}),
1775
2600
  }),
1776
2601
  };
1777
2602
  }
@@ -1876,7 +2701,21 @@ export async function executeLarkSetupProfileTransaction(input) {
1876
2701
  // the idempotent exact-owner handoff and converges forward.
1877
2702
  await persistLarkSetupJournal({ ...candidateJournal, phase: 'paired' });
1878
2703
  if (previousCredential !== undefined && !credentialLocatorEquals(previousCredential, stagedCredential)) {
1879
- await retirePreviousCredentialBestEffort(previousCredential, remove);
2704
+ try {
2705
+ await retirePreviousCredential(previousCredential, remove);
2706
+ }
2707
+ catch (error) {
2708
+ if (input.credentialProvider !== 'linux-protected-file'
2709
+ || !canDeferSecretServiceCleanup(previousCredential, error))
2710
+ throw error;
2711
+ await deferInactiveCredentialCleanup({
2712
+ patchPath: input.patchPath,
2713
+ dshHome: input.dshHome,
2714
+ profile: input.args.profile,
2715
+ profilePatch: updatedPatch,
2716
+ locator: previousCredential,
2717
+ });
2718
+ }
1880
2719
  }
1881
2720
  await input.operations.afterCommit?.();
1882
2721
  await clearLarkSetupJournal(input.patchPath);
@@ -1896,6 +2735,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
1896
2735
  const validate = runtime.validateProfile ?? validateProfile;
1897
2736
  const readEffectiveProfile = runtime.readEffectiveProfile ?? dumpProfile;
1898
2737
  const installResidentService = runtime.installResidentService ?? installDshResidentService;
2738
+ const prepareResidentService = runtime.prepareResidentService ?? prepareDshSystemdUserService;
1899
2739
  const recoveryOperations = {
1900
2740
  ...runtime.journalOperations,
1901
2741
  readEffectiveProfile: runtime.journalOperations?.readEffectiveProfile ?? readEffectiveProfile,
@@ -1986,6 +2826,11 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
1986
2826
  return;
1987
2827
  }
1988
2828
  if (args.installServiceOnly) {
2829
+ if (process.platform === 'linux') {
2830
+ const prepared = await prepareResidentService({ dshHome, profile: args.profile });
2831
+ if (prepared?.enabledLinger)
2832
+ process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
2833
+ }
1989
2834
  const service = await withProfileSetupLock(patchPath, async () => {
1990
2835
  await recoverLarkSetupJournalUnlocked({
1991
2836
  patchPath,
@@ -2017,6 +2862,31 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2017
2862
  + `状态:${service.statusCommand}\n日志:${service.logCommand}\n`);
2018
2863
  return;
2019
2864
  }
2865
+ const preferredCredentialProvider = credentialProviderForPlatform(process.platform);
2866
+ if (process.platform === 'linux' && args.manageService) {
2867
+ const prepared = await prepareResidentService({ dshHome, profile: args.profile });
2868
+ if (prepared?.enabledLinger)
2869
+ process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
2870
+ }
2871
+ let credentialProvider;
2872
+ if (runtime.preflightCredentialProvider === undefined) {
2873
+ const selection = await selectLarkCredentialProvider(preferredCredentialProvider, {
2874
+ dshHome,
2875
+ mode: args.linuxCredentialProvider,
2876
+ });
2877
+ credentialProvider = selection.provider;
2878
+ if (selection.fallbackReason !== undefined) {
2879
+ process.stdout.write('Linux Secret Service 当前不可用;向导已自动切换到当前用户 0600 私有凭据文件。'
2880
+ + '该文件未额外加密,同一 UID 与 root 可读取,但不会进入 profile、命令参数、环境变量或日志。\n');
2881
+ }
2882
+ }
2883
+ else {
2884
+ if (args.linuxCredentialProvider !== 'auto') {
2885
+ throw new Error('lark-channel setup: an injected credential preflight cannot be combined with an explicit Linux provider');
2886
+ }
2887
+ credentialProvider = await runtime.preflightCredentialProvider(preferredCredentialProvider)
2888
+ ?? preferredCredentialProvider;
2889
+ }
2020
2890
  // Resolve a prior crash before prompting the user or mutating a cloud app.
2021
2891
  // The mutation transaction repeats this check after registration to close
2022
2892
  // the gap against a different setup process that completed in the meantime.
@@ -2026,6 +2896,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2026
2896
  dshHome,
2027
2897
  profile: args.profile,
2028
2898
  operations: recoveryOperations,
2899
+ allowDeferredSecretServiceCleanup: credentialProvider === 'linux-protected-file',
2029
2900
  });
2030
2901
  const databasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
2031
2902
  await withDeliveryOwnerSetupLock(databasePath, async () => {
@@ -2037,7 +2908,6 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2037
2908
  });
2038
2909
  }, runtime.profileLockOptions);
2039
2910
  }, runtime.profileLockOptions);
2040
- const credentialProvider = credentialProviderForPlatform(process.platform);
2041
2911
  let appId = args.appId;
2042
2912
  let createApp = args.createApp;
2043
2913
  if (appId === undefined && !createApp) {
@@ -2081,24 +2951,31 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2081
2951
  credentialProvider,
2082
2952
  ...(runtime.profileLockOptions === undefined ? {} : { profileLockOptions: runtime.profileLockOptions }),
2083
2953
  operations: {
2084
- storeCredential(locator) {
2954
+ async storeCredential(locator) {
2085
2955
  const service = locator.provider === 'windows-dpapi'
2086
2956
  ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
2087
- : locator.service;
2088
- const credentialPath = locator.provider === 'windows-dpapi' ? locator.path : undefined;
2957
+ : locator.provider === 'linux-protected-file'
2958
+ ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
2959
+ : locator.service;
2960
+ const credentialPath = locator.provider === 'windows-dpapi'
2961
+ || locator.provider === 'linux-protected-file' ? locator.path : undefined;
2089
2962
  if (generatedSecret === undefined) {
2090
- storeSecret(credentialProvider, service, args.account, credentialPath);
2963
+ await storeSecret(credentialProvider, service, args.account, credentialPath);
2091
2964
  }
2092
2965
  else {
2093
- storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
2966
+ await storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
2094
2967
  }
2095
- process.stdout.write(`候选凭据已安全暂存到 ${credentialProvider};旧凭据仍保持有效。\n`);
2968
+ process.stdout.write(`候选凭据已暂存到 ${credentialProvider};旧凭据仍保持有效。\n`);
2096
2969
  },
2097
2970
  readCredential(locator) {
2098
2971
  const service = locator.provider === 'windows-dpapi'
2099
2972
  ? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
2100
- : locator.service;
2101
- return readSecret(credentialProvider, service, args.account, locator.provider === 'windows-dpapi' ? locator.path : undefined);
2973
+ : locator.provider === 'linux-protected-file'
2974
+ ? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
2975
+ : locator.service;
2976
+ return readSecret(credentialProvider, service, args.account, locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file'
2977
+ ? locator.path
2978
+ : undefined);
2102
2979
  },
2103
2980
  readEffectiveProfile,
2104
2981
  async discoverOwner(ownerInput) {
@@ -2136,7 +3013,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
2136
3013
  }
2137
3014
  if (process.argv[1] !== undefined && isMainEntry(import.meta.url, process.argv[1])) {
2138
3015
  void runLarkSetup().catch(error => {
2139
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
3016
+ process.stderr.write(`${formatLarkSetupError(error)}\n`);
2140
3017
  process.exitCode = 1;
2141
3018
  });
2142
3019
  }