termdock 1.4.345 → 1.4.346

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.
@@ -69,6 +69,9 @@ export const COLLAB_HELP = `td collab — durable messages; no agent-specific ho
69
69
  消息历史只覆盖仍保留记录;不存在的 after-id 返回 MESSAGE_NOT_FOUND,不能据此声称历史完整。
70
70
  session create --group <id> --launch-profile <id> --cwd <absolute-path> --idempotency-key <key>
71
71
  session get <id> | session restore <id> --idempotency-key <key>
72
+ 启动一致性/原shell有限核验需服务1.4.346:session_launch_reload_fence/session_shell_startup_wait。
73
+ 只等同一承载的前台shell证明,不判断应用input-ready,不自动重试失败启动。
74
+ 独立Agent数据目录可由管理员在profile配置nativeIdentity;需Linux及session_profile_native_identity。
72
75
  get 可对账 tmux recovery 后仍指向旧 pane 的 IntegrationSession;需 Linux 服务1.4.328、session_recovery_binding_reconcile=true。
73
76
  仅凭成功恢复记录、唯一归属/承载与当前精确 native writer-lock 证明更新 binding/generation;证据不足仍拒绝。
74
77
  不另起 Agent、不重放已写入消息;成功后以 get 返回的 generation/terminal_binding 为准。
@@ -78,6 +78,16 @@ export const INTEGRATION_HELP = `td integration — local scoped integration adm
78
78
  Prior versions cannot reconstruct missing launch evidence retroactively.
79
79
  create --file <policy.json> --credential-file <private-output.json>
80
80
  update --file <policy.json> (same identity/group; preserves the credential)
81
+ 1.4.346 launch profiles may declare nativeIdentity={kind:"linux-flock-owner",
82
+ directory:"{cwd}/.agent-home/cli/thread-writer-locks"} for an isolated Agent home.
83
+ Linux only; session_profile_native_identity=true required on the running service.
84
+ This configures read-only identity proof; it does not set Agent environment,
85
+ install hooks, create directories, or modify old session snapshots.
86
+ session_launch_reload_fence/session_shell_startup_wait (minimum 1.4.346)
87
+ protect launch tokens during disk reload and wait at most 5s for the same
88
+ unique foreground shell. This does not wait for application input-ready.
89
+ diagnostics may include shell_check (reason/pane/pgid/tpgid/samples/waited_ms),
90
+ never shell argv/output. A timed-out launch does not automatically retry.
81
91
  revoke <principal-id>
82
92
  Credentials are saved as a private file and never printed. Workers set
83
93
  TERMDOCK_INTEGRATION_CREDENTIAL_FILE, then use td collab --principal <id> …
@@ -248,6 +258,8 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
248
258
  throw new Error('update requires only --file');
249
259
  const policy = JSON.parse(fs.readFileSync(options['--file'], 'utf8'));
250
260
  const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
261
+ if (policy.launchProfiles?.some((profile) => profile.nativeIdentity !== undefined) && capabilities.session_profile_native_identity !== true)
262
+ throw new CollaborationError('SESSION_PROFILE_NATIVE_IDENTITY_UNSUPPORTED', 'CLI and Linux service must support profile native identity (minimum 1.4.346)', 409);
251
263
  if (capabilities.integration_policy_update !== true || policy.launchProfiles?.some((profile) => profile.startupInput !== undefined) && capabilities.startup_input_conditions !== true)
252
264
  throw new CollaborationError('STARTUP_INPUT_CONDITION_UNSUPPORTED', 'Running service does not support this policy update', 409);
253
265
  write(JSON.stringify(parsed(await request(socketPath, 'POST', `/admin/principals/${encodeURIComponent(policy.id)}/policy`, policy, headers(undefined, adminToken)))));
@@ -257,9 +269,11 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
257
269
  if (!options['--file'] || !options['--credential-file'] || Object.keys(options).length !== 2)
258
270
  throw new Error('create requires --file and --credential-file');
259
271
  const policy = JSON.parse(fs.readFileSync(options['--file'], 'utf8'));
260
- if (policy.launchProfiles?.some((profile) => profile.startupInput !== undefined)) {
272
+ if (policy.launchProfiles?.some((profile) => profile.startupInput !== undefined || profile.nativeIdentity !== undefined)) {
261
273
  const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
262
- if (capabilities.startup_input_conditions !== true)
274
+ if (policy.launchProfiles.some((profile) => profile.nativeIdentity !== undefined) && capabilities.session_profile_native_identity !== true)
275
+ throw new CollaborationError('SESSION_PROFILE_NATIVE_IDENTITY_UNSUPPORTED', 'CLI and Linux service must support profile native identity (minimum 1.4.346)', 409);
276
+ if (policy.launchProfiles.some((profile) => profile.startupInput !== undefined) && capabilities.startup_input_conditions !== true)
263
277
  throw new CollaborationError('STARTUP_INPUT_CONDITION_UNSUPPORTED', 'Running service does not support startup input conditions', 409);
264
278
  }
265
279
  const destination = path.resolve(options['--credential-file']);
@@ -144,7 +144,7 @@ export class IntegrationRuntime {
144
144
  app.post('/admin/principals/:id/policy', run(req => { admin(req); if (req.body?.id !== req.params.id)
145
145
  integrationError('INVALID_INTEGRATION_POLICY', 'Policy identity must match the requested identity'); this.group(req.body?.groupId); this.store.update(req.body); return { ok: true, id: req.params.id }; }));
146
146
  app.post('/admin/principals/:id/revoke', run(req => { admin(req); this.store.revoke(req.params.id); return { ok: true }; }));
147
- app.get('/capabilities', run(req => { const p = principal(req); return { ...this.capabilities(), principal: { id: p.id, group_id: p.groupId, permissions: p.permissions, launch_profiles: p.launchProfiles.map(profile => ({ id: profile.id, agent_slug: profile.agentSlug, cwd_roots: profile.cwdRoots, startup_input_condition: !!profile.startupInput })) } }; }));
147
+ app.get('/capabilities', run(req => { const p = principal(req); return { ...this.capabilities(), principal: { id: p.id, group_id: p.groupId, permissions: p.permissions, launch_profiles: p.launchProfiles.map(profile => ({ id: profile.id, agent_slug: profile.agentSlug, cwd_roots: profile.cwdRoots, startup_input_condition: !!profile.startupInput, native_identity_scope: !!profile.nativeIdentity })) } }; }));
148
148
  app.get('/tasks', run(req => { const p = principal(req); this.authorize(p, 'task.read', req.query.group); return { tasks: options.tasks.list(p.groupId, null, req.query.purpose) }; }));
149
149
  app.get('/tasks/:id', run(async (req) => { const p = principal(req); this.taskPermission(p, req.params.id, 'task.read'); return { task: await options.tasks.get(req.params.id, null) }; }));
150
150
  app.post('/tasks', run(async (req) => {
@@ -299,6 +299,9 @@ export class IntegrationRuntime {
299
299
  terminal_input_runtime_fence: true, terminal_input_runtime_fence_min_version: '1.4.313',
300
300
  agent_launch_generation_consistency: true, agent_launch_generation_consistency_min_version: '1.4.322',
301
301
  terminal_input_check_diagnostics: true,
302
+ session_launch_reload_fence: true, session_launch_reload_fence_min_version: '1.4.346',
303
+ session_shell_startup_wait: true, session_shell_startup_wait_min_version: '1.4.346',
304
+ session_profile_native_identity: process.platform === 'linux', session_profile_native_identity_min_version: '1.4.346',
302
305
  session_launch_diagnostics: true, session_launch_diagnostics_min_version: '1.4.314',
303
306
  session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
304
307
  session_rename: true, session_rename_min_version: '1.4.315',
@@ -0,0 +1,33 @@
1
+ import { CollaborationError } from './collaborationProtocol.js';
2
+ export class IntegrationShellWaitError extends CollaborationError {
3
+ shellEvidence;
4
+ constructor(code, shellEvidence) {
5
+ super(code, 'The original runtime did not prove a unique foreground shell before launch', 409);
6
+ this.shellEvidence = shellEvidence;
7
+ }
8
+ }
9
+ const samePane = (a, b) => a.serverPid === b.serverPid && a.sessionId === b.sessionId && a.paneId === b.paneId && a.panePid === b.panePid;
10
+ /** Wait for kernel/process evidence, never a prompt, fixed startup sleep, or
11
+ * application readiness. This observes one runtime and never sends input. */
12
+ export async function waitForIntegrationShell(input) {
13
+ const now = input.now ?? Date.now, started = now();
14
+ const pause = input.pause ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
15
+ let pinned = input.expected ?? null, samples = 0;
16
+ for (;;) {
17
+ const observation = await input.observe();
18
+ samples++;
19
+ const evidence = { samples, waited_ms: now() - started,
20
+ reason: observation.reason, binding: observation.binding, pgid: observation.pgid, tpgid: observation.tpgid };
21
+ if (!observation.binding || pinned && !samePane(pinned, observation.binding)) {
22
+ throw new IntegrationShellWaitError('SESSION_IDENTITY_MISMATCH', evidence);
23
+ }
24
+ pinned ??= observation.binding;
25
+ if (observation.agent)
26
+ throw new IntegrationShellWaitError('SESSION_TARGET_NOT_SHELL', evidence);
27
+ if (observation.confirmed)
28
+ return pinned;
29
+ if (now() - started >= (input.timeoutMs ?? 5000))
30
+ throw new IntegrationShellWaitError('SESSION_TARGET_NOT_SHELL', evidence);
31
+ await pause(Math.min(100, (input.timeoutMs ?? 5000) - (now() - started)));
32
+ }
33
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { CollaborationError } from './collaborationProtocol.js';
5
+ import { resolveProfileNativeIdentity } from './nativeProcessIdentity.js';
5
6
  export const INTEGRATION_PROTOCOL = 1;
6
7
  export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release', 'session.rename', 'session.close'];
7
8
  export function integrationError(code, message, status = 400) { throw new CollaborationError(code, message, status); }
@@ -54,6 +55,9 @@ export function validatePolicy(value) {
54
55
  integrationError('INVALID_INTEGRATION_POLICY', 'resumeArgv requires one {sessionId} and one {launchArgs}; --last is forbidden');
55
56
  if (!Array.isArray(profile.cwdRoots) || !profile.cwdRoots.length || profile.cwdRoots.some(root => typeof root !== 'string' || !path.isAbsolute(root) || /[\x00-\x1f\x7f]/.test(root)))
56
57
  integrationError('INVALID_INTEGRATION_POLICY', 'cwdRoots must be absolute paths');
58
+ if (profile.nativeIdentity !== undefined && profile.cwdRoots.some(root => !resolveProfileNativeIdentity(profile.nativeIdentity, root))) {
59
+ integrationError('INVALID_INTEGRATION_POLICY', 'nativeIdentity must be an explicit owned-home writer-lock directory or a safe {cwd}/ relative template');
60
+ }
57
61
  if (profile.startupInput !== undefined) {
58
62
  const condition = profile.startupInput;
59
63
  const validTerms = (terms) => Array.isArray(terms) && terms.length <= 16
@@ -14,6 +14,27 @@ export function nativeIdentityDirectory(config) {
14
14
  const directory = path.resolve(expanded), relative = path.relative(os.homedir(), directory);
15
15
  return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? directory : null;
16
16
  }
17
+ /** Administrator-declared profile scope, never inferred from process env.
18
+ * {cwd} is permitted only as a leading directory anchor and cannot escape it. */
19
+ export function resolveProfileNativeIdentity(config, cwd) {
20
+ if (!config || typeof config !== 'object' || Array.isArray(config))
21
+ return null;
22
+ const value = config;
23
+ if (value.kind !== 'linux-flock-owner' || typeof value.directory !== 'string'
24
+ || Object.keys(value).some(key => !['kind', 'directory'].includes(key)))
25
+ return null;
26
+ let directory = value.directory;
27
+ if (directory.startsWith('{cwd}/')) {
28
+ const suffix = directory.slice(6);
29
+ if (/[{}]/.test(suffix) || suffix.split(/[\\/]/).includes('..') || !path.isAbsolute(cwd))
30
+ return null;
31
+ directory = path.join(cwd, suffix);
32
+ }
33
+ else if (/[{}]/.test(directory))
34
+ return null;
35
+ const normalized = nativeIdentityDirectory({ kind: value.kind, directory });
36
+ return normalized ? { kind: 'linux-flock-owner', directory: normalized } : null;
37
+ }
17
38
  const unavailable = { nativeId: null, outcome: 'unavailable' };
18
39
  function processStat(text, pid) {
19
40
  const close = text.lastIndexOf(')'), open = text.indexOf('(');
@@ -1,5 +1,6 @@
1
1
  import { CollaborationError } from './collaborationProtocol.js';
2
2
  import { TerminalInputTargetChangedError } from './terminalInput.js';
3
+ import { IntegrationShellWaitError } from './integrationShellWait.js';
3
4
  export function beginLaunchDiagnostics(operation_id, generation, kind) {
4
5
  const now = Date.now();
5
6
  return { operation_id, generation, kind, phase: 'configuration', updated_at: now,
@@ -22,5 +23,6 @@ export function launchFailure(current, error, error_code) {
22
23
  : typeof value?.code === 'string' && standard.has(value.code) ? value.code
23
24
  : typeof value?.message === 'string' && standard.has(value.message) ? value.message : 'UNCLASSIFIED_LAUNCH_ERROR';
24
25
  return { ...current, updated_at: Date.now(), error_code, reason_code,
26
+ ...(error instanceof IntegrationShellWaitError ? { shell_check: structuredClone(error.shellEvidence) } : {}),
25
27
  ...(error instanceof TerminalInputTargetChangedError ? { target_change: error.evidence } : {}) };
26
28
  }
@@ -6,6 +6,7 @@ export function mergeSessionLaunchState(candidate, current, change) {
6
6
  return current ? { ...candidate,
7
7
  agentLaunchGeneration: current.agentLaunchGeneration ?? null,
8
8
  agentLaunchCheck: current.agentLaunchCheck ?? null,
9
+ agentNativeIdentity: current.agentNativeIdentity ?? null,
9
10
  } : candidate;
10
11
  if (!current || (current.agentLaunchGeneration ?? null) !== change.expectedGeneration
11
12
  || (current.agentLaunchCheck?.id ?? null) !== change.expectedCheckId) {
@@ -16,5 +17,38 @@ export function mergeSessionLaunchState(candidate, current, change) {
16
17
  check_phase: 'launch_state_commit', checked_at: Date.now(),
17
18
  });
18
19
  }
19
- return { ...candidate, agentLaunchGeneration: change.generation, agentLaunchCheck: change.check };
20
+ return { ...candidate, agentLaunchGeneration: change.generation, agentLaunchCheck: change.check,
21
+ agentNativeIdentity: change.nativeIdentity === undefined ? current.agentNativeIdentity ?? null : change.nativeIdentity };
22
+ }
23
+ /** A file watcher can finish reading an old snapshot after an in-memory launch
24
+ * or deletion. It must not revert server-owned fields or resurrect that ID. */
25
+ export function mergeReloadedSessionState(disk, beforeRead, current) {
26
+ const before = new Map(beforeRead.map(record => [record.sessionId, record]));
27
+ const live = new Map(current.map(record => [record.sessionId, record]));
28
+ const result = [];
29
+ const seen = new Set();
30
+ for (const candidate of disk) {
31
+ const id = candidate.sessionId, now = live.get(id), old = before.get(id);
32
+ if (seen.has(id) || old && !now)
33
+ continue;
34
+ seen.add(id);
35
+ result.push(now ? JSON.stringify(now) !== JSON.stringify(old) ? now
36
+ : mergeSessionLaunchState(candidate, now) : candidate);
37
+ }
38
+ for (const record of current) {
39
+ if (!seen.has(record.sessionId) && (JSON.stringify(record) !== JSON.stringify(before.get(record.sessionId))
40
+ || record.agentLaunchGeneration || record.agentNativeIdentity))
41
+ result.push(record);
42
+ }
43
+ return result;
44
+ }
45
+ /** Serialize durable global snapshots, taking a fresh value when each write
46
+ * acquires the queue. A delayed old rename must not overwrite a later launch. */
47
+ export function createSessionStateWriter(current, write) {
48
+ let pending = Promise.resolve();
49
+ return () => {
50
+ const next = pending.catch(() => undefined).then(() => write(structuredClone(current())));
51
+ pending = next;
52
+ return next;
53
+ };
20
54
  }
@@ -4,7 +4,7 @@ import { isConfirmedEmptyTmuxServer } from '../utils/tmuxEmptyServerProbe.js';
4
4
  import { collaborationSessionDisplayName } from '../utils/sessionDisplayName.js';
5
5
  import { integrationError } from '../agent/integrationStore.js';
6
6
  import { assertNativeResumeAvailable, collectNativeResumeOwnerCandidates } from '../agent/nativeResumeOwner.js';
7
- import { readNativeProcessIdentity } from '../agent/nativeProcessIdentity.js';
7
+ import { nativeIdentityDirectory, readNativeProcessIdentity, resolveProfileNativeIdentity } from '../agent/nativeProcessIdentity.js';
8
8
  import { closeSessionRuntime, foregroundProcessInstance, processStartIdentity } from '../agent/sessionManagement.js';
9
9
  import { releaseSessionRuntime } from '../agent/sessionLifecycle.js';
10
10
  import { resumeConfigurationFingerprint } from '../agent/sessionResumeConfiguration.js';
@@ -59,7 +59,8 @@ import { assessAgentResourceBudget, collectHostResourceSnapshot, configuredAgent
59
59
  import { AutomationStore, normalizeAutomationSchedule } from '../agent/automationStore.js';
60
60
  import { writeBracketedPrompt, canDeliverPromptToAgent } from '../agent/promptDelivery.js';
61
61
  import { captureTerminalInputGuard, captureRuntimeInputGuard, waitAndWriteTerminalInput } from '../agent/terminalInput.js';
62
- import { mergeSessionLaunchState } from '../agent/sessionLaunchState.js';
62
+ import { createSessionStateWriter, mergeReloadedSessionState, mergeSessionLaunchState } from '../agent/sessionLaunchState.js';
63
+ import { waitForIntegrationShell, IntegrationShellWaitError } from '../agent/integrationShellWait.js';
63
64
  import { agentLaunchBlockReason, normalizeAgentLaunchCheck } from '../agent/agentLaunchCheck.js';
64
65
  import { prepareSupervisionEnable } from '../utils/enableSupervision.js';
65
66
  import { buildServerHealthState, dismissServerHealth } from '../utils/serverHealth.js';
@@ -531,12 +532,14 @@ async function migrateFromClientStatesFile() {
531
532
  return null;
532
533
  }
533
534
  }
534
- async function loadGlobalSessionStateFromDisk() {
535
+ async function loadGlobalSessionStateFromDisk(reload = false) {
536
+ const beforeRead = structuredClone(globalSessionState.sessions);
535
537
  try {
536
538
  const data = await readJsonFileIfExists(GLOBAL_SESSION_STATE_FILE);
537
539
  if (data) {
540
+ const sessions = deduplicateGlobalSessions((data.sessions || []).map(s => normalizePersistedClientSession(s)).filter((s) => s !== null));
538
541
  globalSessionState = {
539
- sessions: deduplicateGlobalSessions((data.sessions || []).map(s => normalizePersistedClientSession(s)).filter((s) => s !== null)),
542
+ sessions: reload ? mergeReloadedSessionState(sessions, beforeRead, globalSessionState.sessions) : sessions,
540
543
  updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : Date.now(),
541
544
  };
542
545
  console.log(`[session-persist] Loaded ${globalSessionState.sessions.length} sessions from global state`);
@@ -587,7 +590,7 @@ async function persistTmuxRecoveryState() {
587
590
  async function reloadGlobalSessionStateFromDisk(source) {
588
591
  try {
589
592
  const previous = JSON.stringify(globalSessionState);
590
- await loadGlobalSessionStateFromDisk();
593
+ await loadGlobalSessionStateFromDisk(true);
591
594
  const next = JSON.stringify(globalSessionState);
592
595
  if (previous === next) {
593
596
  return;
@@ -637,12 +640,13 @@ async function watchGlobalSessionStateFile() {
637
640
  reportFailure('[session-persist] Failed to watch global state file:', error);
638
641
  }
639
642
  }
643
+ const writeGlobalSessionState = createSessionStateWriter(() => globalSessionState, snapshot => writeJsonFile(GLOBAL_SESSION_STATE_FILE, snapshot));
640
644
  function schedulePersistGlobalState() {
641
645
  if (persistGlobalStateTimer)
642
646
  clearTimeout(persistGlobalStateTimer);
643
647
  persistGlobalStateTimer = setTimeout(() => {
644
648
  try {
645
- void writeJsonFile(GLOBAL_SESSION_STATE_FILE, globalSessionState).catch((error) => {
649
+ void writeGlobalSessionState().catch((error) => {
646
650
  console.warn('[session-persist] Failed to persist global state:', getErrorMessage(error));
647
651
  });
648
652
  }
@@ -657,7 +661,7 @@ async function persistGlobalStateNow(strict = false) {
657
661
  persistGlobalStateTimer = null;
658
662
  }
659
663
  try {
660
- await writeJsonFile(GLOBAL_SESSION_STATE_FILE, globalSessionState);
664
+ await writeGlobalSessionState();
661
665
  }
662
666
  catch (error) {
663
667
  console.warn('[session-persist] Failed to persist global state:', getErrorMessage(error));
@@ -1002,6 +1006,8 @@ function normalizePersistedClientSession(input) {
1002
1006
  agentLaunchCheck: normalizeAgentLaunchCheck(candidate.agentLaunchCheck),
1003
1007
  agentLaunchGeneration: typeof candidate.agentLaunchGeneration === 'string' ? candidate.agentLaunchGeneration
1004
1008
  : normalizeAgentLaunchCheck(candidate.agentLaunchCheck)?.id ?? null,
1009
+ agentNativeIdentity: nativeIdentityDirectory(candidate.agentNativeIdentity)
1010
+ ? { kind: 'linux-flock-owner', directory: nativeIdentityDirectory(candidate.agentNativeIdentity) } : null,
1005
1011
  };
1006
1012
  }
1007
1013
  function generateTmuxSessionName() {
@@ -1485,14 +1491,18 @@ function searchMetadataForBackend(backendSessionId, session) {
1485
1491
  updatedAt: session.lastActivity,
1486
1492
  };
1487
1493
  }
1488
- async function beginAgentLaunchCheck(frontendSessionId, agentSlug, nativeSessionId = null) {
1494
+ async function beginAgentLaunchCheck(frontendSessionId, agentSlug, nativeSessionId = null, nativeIdentity) {
1489
1495
  const record = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
1490
1496
  if (!record)
1491
1497
  throw new Error('SESSION_REMOVED');
1492
1498
  const id = randomUUID();
1493
1499
  const updated = upsertGlobalSessionRecord(record, { expectedGeneration: record.agentLaunchGeneration ?? null,
1494
1500
  expectedCheckId: record.agentLaunchCheck?.id ?? null, generation: id,
1495
- check: { id, agentSlug, nativeSessionId, deadline: Date.now() + 120_000 } });
1501
+ check: { id, agentSlug, nativeSessionId, deadline: Date.now() + 120_000 },
1502
+ nativeIdentity: nativeIdentity === undefined
1503
+ ? record.agentResume?.slug === agentSlug || collaborationRouting.get(frontendSessionId)?.agentSlug === agentSlug
1504
+ ? record.agentNativeIdentity ?? null : null
1505
+ : nativeIdentity });
1496
1506
  // Capture the guard before persistence yields. The launch command must use
1497
1507
  // this exact generation, even if another launch occurs during an await.
1498
1508
  const assertCurrent = terminalInputGuard(updated);
@@ -1711,7 +1721,7 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
1711
1721
  * agent-keyed selector can never reach it. Delivery still needs an agent (the
1712
1722
  * confirm gate and prompt formatting assume a TUI); driving a terminal —
1713
1723
  * run/capture — is a shell operation and works on any pane. */
1714
- async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false, describeNativeFailure = false) {
1724
+ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false, describeNativeFailure = false, identityOverride) {
1715
1725
  if (!binding.tmuxSessionName)
1716
1726
  return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
1717
1727
  const name = `=${binding.tmuxSessionName}`;
@@ -1737,7 +1747,7 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1737
1747
  // pane_current_command alone cannot prove an exact resumed conversation.
1738
1748
  const program = await resolveTmuxPaneProgram(pane, true, nativeProcessIdentity || freshProcess);
1739
1749
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1740
- const identity = nativeProcessIdentity ? await observeProgramNativeIdentity(agent, program)
1750
+ const identity = nativeProcessIdentity ? await observeProgramNativeIdentity(agent, program, identityOverride ?? globalSessionState.sessions.find(record => record.sessionId === binding.sessionId)?.agentNativeIdentity ?? undefined)
1741
1751
  : { nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null, failure: null, source: null };
1742
1752
  if (identity.failure && !describeNativeFailure)
1743
1753
  integrationError(identity.failure === 'NATIVE_SESSION_IDENTITY_CONFLICT'
@@ -2050,6 +2060,37 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
2050
2060
  async function integrationPaneIsIdleShell(pane) {
2051
2061
  return (await inspectIntegrationShell(pane)).confirmed;
2052
2062
  }
2063
+ function integrationNativeIdentity(record) {
2064
+ if (!record.profile.nativeIdentity)
2065
+ return undefined;
2066
+ if (process.platform !== 'linux')
2067
+ integrationError('SESSION_PROFILE_NATIVE_IDENTITY_UNSUPPORTED', 'Profile writer-lock identity requires Linux', 409);
2068
+ const config = resolveProfileNativeIdentity(record.profile.nativeIdentity, record.cwd);
2069
+ if (!config)
2070
+ integrationError('INVALID_NATIVE_IDENTITY_CONFIG', 'Stored native identity scope is invalid', 409);
2071
+ return config;
2072
+ }
2073
+ async function waitForOwnedIntegrationShell(record, expected) {
2074
+ return waitForIntegrationShell({ expected, async observe() {
2075
+ const terminal = integrationTerminalRecord(record);
2076
+ if (!terminal)
2077
+ return { binding: null, confirmed: false, agent: false, reason: 'RUNTIME_NOT_REGISTERED', pgid: null, tpgid: null };
2078
+ const layout = await getTmuxLayout(terminal.tmuxSessionName);
2079
+ if (layout.windows.flatMap(window => window.panes).length !== 1) {
2080
+ return { binding: null, confirmed: false, agent: false, reason: 'MULTIPLE_TERMINAL_PANES', pgid: null, tpgid: null };
2081
+ }
2082
+ const inspected = await inspectCollaborationTmux({ sessionId: record.session_id, backendSessionId: terminal.backendSessionId,
2083
+ mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: expected }, null, true, false, true);
2084
+ const pane = inspected.pane;
2085
+ if (inspected.state !== 'ready' || !pane)
2086
+ return { binding: null, confirmed: false, agent: false,
2087
+ reason: inspected.reason ?? 'PANE_UNCONFIRMED', pgid: null, tpgid: null };
2088
+ const shell = await inspectIntegrationShell(pane);
2089
+ return { binding: { serverPid: pane.serverPid, sessionId: pane.sessionId, paneId: pane.paneId, panePid: pane.panePid,
2090
+ agentSlug: '', nativeSessionId: null }, confirmed: shell.confirmed, agent: !!pane.agentSlug,
2091
+ reason: pane.isShell ? shell.failure : 'PANE_NOT_SHELL', pgid: shell.pgid, tpgid: shell.tpgid };
2092
+ } });
2093
+ }
2053
2094
  async function inspectIntegrationShell(pane) {
2054
2095
  if (!pane.isShell)
2055
2096
  return { confirmed: false, failure: null, pgid: null, tpgid: null };
@@ -2157,7 +2198,7 @@ async function observeIntegrationCloseTarget(record) {
2157
2198
  if (!record.agent_native_session_id || agent.slug !== record.agent_slug || program?.source !== 'tmux-tty' || !program.processPid) {
2158
2199
  integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'A running Agent requires a live exact native identity', 409);
2159
2200
  }
2160
- const identity = await observeProgramNativeIdentity(agent, program);
2201
+ const identity = await observeProgramNativeIdentity(agent, program, integrationNativeIdentity(record));
2161
2202
  if (identity.failure || identity.nativeId !== record.agent_native_session_id)
2162
2203
  integrationError('SESSION_IDENTITY_MISMATCH', 'Current Agent native identity differs from the owned record', 409);
2163
2204
  kind = 'agent';
@@ -2246,7 +2287,7 @@ export const integrationSessionAdapter = {
2246
2287
  if (!agent || !command)
2247
2288
  integrationError('EXACT_RESUME_UNSUPPORTED', 'Agent must support exact native recovery', 409);
2248
2289
  return { version: 1, slug: agent.slug, aliases: agent.aliases, capabilities: agent.capabilities ?? [],
2249
- resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: getPluginNativeIdentityConfig(agent.slug) ?? null };
2290
+ resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: integrationNativeIdentity(record) ?? getPluginNativeIdentityConfig(agent.slug) ?? null };
2250
2291
  },
2251
2292
  async runtimePresent(record) {
2252
2293
  if (integrationTerminalRecord(record))
@@ -2303,7 +2344,7 @@ export const integrationSessionAdapter = {
2303
2344
  const binding = { sessionId: record.session_id, backendSessionId: terminal.backendSessionId,
2304
2345
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: record.agent_slug,
2305
2346
  nativeSessionId: record.agent_native_session_id, pane: record.terminal_binding ?? null };
2306
- const inspected = await inspectCollaborationTmux(binding, undefined, false, true);
2347
+ const inspected = await inspectCollaborationTmux(binding, undefined, false, true, false, false, integrationNativeIdentity(record));
2307
2348
  if (inspected.state === 'offline')
2308
2349
  return { exists: false, running: false, shell: false, agentSlug: null, nativeId: null };
2309
2350
  if (inspected.state !== 'ready' || !inspected.pane)
@@ -2443,7 +2484,7 @@ async function assertIntegrationNativeResumeAvailable(record) {
2443
2484
  return Promise.all(layout.windows.flatMap(window => window.panes).map(async (pane) => {
2444
2485
  const program = await resolveTmuxPaneProgram(pane, true, true);
2445
2486
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
2446
- const identity = await observeProgramNativeIdentity(agent, program);
2487
+ const identity = await observeProgramNativeIdentity(agent, program, globalSessionState.sessions.find(record => record.sessionId === candidate.sessionId)?.agentNativeIdentity ?? integrationNativeIdentity(record));
2447
2488
  const shell = await inspectIntegrationShell({ panePid: pane.pid,
2448
2489
  isShell: !agent && shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
2449
2490
  return { confirmed: !identity.failure && (shell.confirmed || program?.source === 'tmux-tty' && Boolean(program.rawArgs)),
@@ -2512,13 +2553,17 @@ async function launchIntegrationTerminal(record, restoring, prepared, progress,
2512
2553
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: null, nativeSessionId: null,
2513
2554
  // A recreated tmux may get a new pane only during explicit exact resume.
2514
2555
  pane: original.exists ? record.terminal_binding ?? null : null }, null, true);
2515
- if (inspected.state !== 'ready' || !inspected.pane || !await integrationPaneIsIdleShell(inspected.pane))
2516
- integrationError('SESSION_TARGET_NOT_SHELL', 'Refusing to launch into an unverified shell', 409);
2556
+ if (inspected.state !== 'ready' || !inspected.pane)
2557
+ throw new IntegrationShellWaitError('SESSION_TARGET_NOT_SHELL', {
2558
+ samples: 1, waited_ms: 0, reason: inspected.reason ?? 'PANE_UNCONFIRMED', binding: null, pgid: null, tpgid: null
2559
+ });
2517
2560
  const pane = inspected.pane;
2518
2561
  const pinned = { serverPid: pane.serverPid, sessionId: pane.sessionId,
2519
2562
  paneId: pane.paneId, panePid: pane.panePid, agentSlug: record.agent_slug, nativeSessionId: record.agent_native_session_id };
2520
2563
  progress?.('binding_persist');
2521
2564
  prepared(pinned); // Must persist before the command enters the terminal.
2565
+ progress?.('runtime_shell_wait');
2566
+ await waitForOwnedIntegrationShell(record, pinned);
2522
2567
  collaborationRouting.bind({ sessionId: record.session_id, backendSessionId: opened.terminalSession.sessionId,
2523
2568
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: record.agent_slug, nativeSessionId: record.agent_native_session_id, pane: pinned });
2524
2569
  const latest = collaborationStore.getGroup(group.id);
@@ -2540,17 +2585,14 @@ async function launchIntegrationTerminal(record, restoring, prepared, progress,
2540
2585
  await assertIntegrationNativeResumeAvailable(record);
2541
2586
  }
2542
2587
  progress?.('runtime_verify');
2543
- const shell = await inspectCollaborationTmux({ sessionId: record.session_id, backendSessionId: opened.terminalSession.sessionId,
2544
- mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: pinned });
2545
- if (shell.state !== 'ready' || !shell.pane || !await integrationPaneIsIdleShell(shell.pane))
2546
- integrationError('SESSION_TARGET_NOT_SHELL', 'Owned shell changed before launch', 409);
2588
+ await waitForOwnedIntegrationShell(record, pinned);
2547
2589
  // This owned pane is a verified shell; clear stale rich state before a new
2548
2590
  // process starts, so its new SessionStart must supply the native binding.
2549
2591
  backend.agentSession = null;
2550
2592
  backend.agent = null;
2551
2593
  backend.agentResumeRecovered = false;
2552
2594
  progress?.('launch_intent');
2553
- const launchGuard = await beginAgentLaunchCheck(record.session_id, record.agent_slug, restoring ? record.agent_native_session_id : null);
2595
+ const launchGuard = await beginAgentLaunchCheck(record.session_id, record.agent_slug, restoring ? record.agent_native_session_id : null, integrationNativeIdentity(record) ?? null);
2554
2596
  await writeAutomaticTerminalPrompt(record.session_id, integrationLaunchCommand(record, restoring), progress, launchGuard);
2555
2597
  broadcastClientState();
2556
2598
  });
@@ -4532,9 +4574,9 @@ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = fa
4532
4574
  }
4533
4575
  return { command, source: 'tmux-pane', rawArgs: null };
4534
4576
  }
4535
- async function observeProgramNativeIdentity(agent, program) {
4577
+ async function observeProgramNativeIdentity(agent, program, override) {
4536
4578
  const fromArgs = agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null;
4537
- const config = agent ? getPluginNativeIdentityConfig(agent.slug) : undefined;
4579
+ const config = agent ? override ?? getPluginNativeIdentityConfig(agent.slug) : undefined;
4538
4580
  if (config && program?.processPid) {
4539
4581
  const proof = await readNativeProcessIdentity(program.processPid, config, program.processComm, program.rawArgs ?? undefined);
4540
4582
  if (proof.outcome === 'ambiguous' || proof.outcome === 'process_changed' || proof.outcome === 'unconfirmed')
@@ -4546,7 +4588,11 @@ async function observeProgramNativeIdentity(agent, program) {
4546
4588
  return { nativeId: null, source: null, failure: 'NATIVE_SESSION_IDENTITY_CONFLICT' };
4547
4589
  return { nativeId: proof.nativeId, source: 'linux-flock-owner', failure: null };
4548
4590
  }
4591
+ if (override)
4592
+ return { nativeId: null, source: null, failure: 'NATIVE_SESSION_OWNERSHIP_UNCONFIRMED' };
4549
4593
  }
4594
+ if (override && agent)
4595
+ return { nativeId: null, source: null, failure: 'NATIVE_SESSION_OWNERSHIP_UNCONFIRMED' };
4550
4596
  return { nativeId: fromArgs, source: fromArgs ? 'argv' : null, failure: null };
4551
4597
  }
4552
4598
  /**
@@ -1354,3 +1354,32 @@ flock-owner 身份独立核验出原 agent slug 与原生 UUID。目标元组会
1354
1354
  fail-closed,旧绑定与原消息保留,`session get` 仍报告身份错误;不会按 tmux 名称、PID
1355
1355
  缓存、cwd、argv 中的 resume 参数或显示名猜测认领。此 capability 不新增权限;仅通过
1356
1356
  `session.read` 认证的所属 principal 可触发其自身记录的对账。
1357
+
1358
+ ## 1.4.346:启动磁盘重载、原 shell 核验与独立原生目录
1359
+
1360
+ 最低 CLI / 实际服务均为 **1.4.346**。通过所属 principal 的 `capabilities` 回读:
1361
+
1362
+ - `session_launch_reload_fence=true`:服务运行期间的磁盘重载保留当前服务生成的 launch token、已解除的一次性 check 和显式原生目录。读取期间新增的会话不会消失,已删除的会话不会由旧读盘复活;全局状态写入串行,并在执行写入时采集当前快照。服务首次启动仍加载磁盘记录。这修复了磁盘 watch 路径绕过元数据 upsert 保护的缺口,不把每一例历史 `TERMINAL_INPUT_TARGET_CHANGED` 都归因为此。
1363
+ - `session_shell_startup_wait=true`:启动前先记录唯一原 pane 身份,再最多等待 5 秒核实该 shell 的前台关系和合格 shell 参数。只观察同一 tmux server/session/pane/PID,采样使用新鲜进程证据;承载改变、pane 不唯一或已有 Agent 时拒绝。不输入探针、不清理画面、不重建承载、不发送额外回车,也不把 shell 核验当成应用 input-ready。
1364
+ - `session_profile_native_identity=true`:当前仅 Linux 支持 profile 级只读 writer-lock 目录,覆盖该受管会话的插件缺省目录,不改变共享插件配置。三个字段分别有同名 `_min_version` 字段,值为 `1.4.346`。能力支持不等于 principal 已被授予权限。
1365
+
1366
+ 本机管理员可在现有完整 policy 的目标 `launchProfiles` 中加:
1367
+
1368
+ ```json
1369
+ "nativeIdentity": {
1370
+ "kind": "linux-flock-owner",
1371
+ "directory": "{cwd}/.agent-home/cli/thread-writer-locks"
1372
+ }
1373
+ ```
1374
+
1375
+ 目录是示例,必须按实际 Agent 的持锁协议填写。支持管理员指定的绝对目录、`~/` 目录,以及仅以前缀 `{cwd}/` 锚定的模板;模板不允许 `..` 或其它变量,解析后的目录须在 TD 服务用户 home 内。调用现有 `td integration update --file <完整policy.json>`,不增加权限、不重建 principal 或凭据。CLI 在旧服务/非 Linux 服务上拒绝该配置,不能静默忽略。原有不配置此项的 profile 保持插件缺省行为。
1376
+
1377
+ wrapper 负责实际设置 Agent 自己的数据目录、准备 hooks/参数,并最终运行真正的 Agent;这个字段只声明 TD 的核验目录,不设置环境、不执行脚本、不创建目录,不自动读取 `TRAECLI_HOME` 等私有变量。TD 仍检查目录与文件权限、进程 starttime、前台关系、实际 FD/inode 和当前内核 flock,要求唯一 native UUID。显式 profile 目录不可证明时不降级到 argv/hook/旧 UUID,多个持锁 UUID 仍拒绝。目录语义纳入恢复配置指纹,保存至启动代次;服务重启、metadata 更新、check 解除不会丢失它。
1378
+
1379
+ 该配置只用于之后创建的 session;旧 session 保留原 profile 快照,policy update 不迁移旧记录或修改运行 Agent。不要为旧未知结果自动 create 新 ID 或 retry-cold。原 UUID 恢复、重复 owner 保护、关闭仍遵守现有身份/代次/幂等门禁;独立数据目录不构成对同 UID 进程的安全隔离。
1380
+
1381
+ 启动失败管理员用 `td integration diagnostics <原TD-ID>` 回读;shell 等待失败新增 `shell_check={samples,waited_ms,reason,binding,pgid,tpgid}`,不包含 argv/env/终端正文。`SESSION_TARGET_NOT_SHELL` 表示有限核验未通过,不表示 wrapper 已执行;`paste/submit=not_attempted` 与后续 `runtime_present` 现场回读分开保存。失败不自动再次启动,只有符合原 `session retry-cold` 所有条件才可明确重试。
1382
+
1383
+ 用 stdin 文件给启动命令附带业务 prompt 是接入方的 bootstrap:TD 不为其中业务副作用提供消息幂等或执行确认;无 TD message 历史也不等于没有业务执行。冷启动与精确 resume 应分开处理,恢复时不得重放初始业务文件。host 收取 final/hook receipt 是 host 侧证据,不伪造成 TD 明确 reply。仍只粘贴一次、等待 50ms、写一次 CR。
1384
+
1385
+ 回归包含确定性交错的旧读盘覆盖/串行写入/删除不复活、同 pane shell 初始化等待与超时/换 pane/已有 Agent 拒绝、CLI 对旧服务拒绝,以及 Linux 真实 tmux/内核 flock 的独立目录核验。私有 TraeX/DevBox 的启动兼容仍需接入方维护窗实机验证;维护机合成测试不冒充该项通过。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.345",
3
+ "version": "1.4.346",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.345",
4
+ "version": "1.4.346",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-ObG1OGq7e7fMFQmYoPxPp3jRodEusYHulVNa5mT1aW8=",
9
- "serverBundleHash": "sha256-Nfa8USqdvNijehQjbAcl1xDcfyRMfMUVkHMqbUgXXAA=",
9
+ "serverBundleHash": "sha256-14J5QvRzetON18qbhx5HXdLAx08WrjndjMlOCMpgfOc=",
10
10
  "clientBundleHash": "sha256-kn3Tg+BTj+H4rZe0g9OE5JUEQNsBGiUSGew8Epm5A38=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"