termdock 1.4.305 → 1.4.306

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.
@@ -147,6 +147,7 @@ export class IntegrationRuntime {
147
147
  startup_input_conditions: true,
148
148
  integration_policy_update: true,
149
149
  session_restore_diagnostics: true,
150
+ native_identity_linux_flock_owner: process.platform === 'linux',
150
151
  permissions: ['task.read', 'task.create', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore'] };
151
152
  }
152
153
  error(error) {
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function nativeIdentityDirectory(config) {
5
+ if (!config || typeof config !== 'object' || Array.isArray(config))
6
+ return null;
7
+ const c = config;
8
+ if (c.kind !== 'linux-flock-owner' || Object.keys(c).some(k => !['kind', 'directory'].includes(k))
9
+ || typeof c.directory !== 'string' || c.directory.length > 1024 || /[\x00-\x1f\x7f]/.test(c.directory))
10
+ return null;
11
+ const expanded = c.directory.startsWith('~/') ? path.join(os.homedir(), c.directory.slice(2)) : c.directory;
12
+ if (!path.isAbsolute(expanded))
13
+ return null;
14
+ const directory = path.resolve(expanded), relative = path.relative(os.homedir(), directory);
15
+ return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? directory : null;
16
+ }
17
+ const unavailable = { nativeId: null, outcome: 'unavailable' };
18
+ function processStat(text, pid) {
19
+ const close = text.lastIndexOf(')'), open = text.indexOf('(');
20
+ if (open < 0 || close <= open || Number(text.slice(0, open).trim()) !== pid)
21
+ return null;
22
+ const fields = text.slice(close + 1).trim().split(/\s+/);
23
+ const pgid = Number(fields[2]), tpgid = Number(fields[5]), start = fields[19];
24
+ return fields[0] !== 'Z' && pgid > 0 && pgid === tpgid && /^\d+$/.test(start ?? '') ? { pgid, tpgid, start, comm: text.slice(open + 1, close) } : null;
25
+ }
26
+ function safeFile(stat, uid) {
27
+ return stat.isFile() && stat.uid === BigInt(uid) && (stat.mode & 18n) === 0n;
28
+ }
29
+ function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino; }
30
+ function kernelWriter(locks, pid, stat) {
31
+ // Linux dev_t encoding, matched against /proc/locks major:minor:inode.
32
+ const major = (stat.dev >> 8n & 0xfffn) | (stat.dev >> 32n & 0xfffff000n);
33
+ const minor = (stat.dev & 0xffn) | (stat.dev >> 12n & 0xffffff00n);
34
+ return locks.split('\n').some(line => {
35
+ const m = line.match(/^\d+:\s+FLOCK\s+ADVISORY\s+WRITE\s+(\d+)\s+([0-9a-f]+):([0-9a-f]+):(\d+)\s+0\s+EOF\s*$/i);
36
+ return !!m && Number(m[1]) === pid && BigInt(`0x${m[2]}`) === major && BigInt(`0x${m[3]}`) === minor && BigInt(m[4]) === stat.ino;
37
+ });
38
+ }
39
+ async function readOwner(file, uid) {
40
+ const handle = await fs.promises.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
41
+ try {
42
+ const stat = await handle.stat({ bigint: true });
43
+ if (!safeFile(stat, uid) || stat.size > 4096n)
44
+ return null;
45
+ const buffer = Buffer.alloc(4097), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
46
+ if (bytesRead > 4096)
47
+ return null;
48
+ const value = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'));
49
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
50
+ }
51
+ finally {
52
+ await handle.close();
53
+ }
54
+ }
55
+ /** Confirm exactly one UUID using a current foreground PID, owner starttime,
56
+ * its real open FD/inode, and the kernel's exclusive whole-file flock. */
57
+ export async function readNativeProcessIdentity(pid, config, expectedComm, expectedArgs) {
58
+ if (process.platform !== 'linux' || !Number.isSafeInteger(pid) || pid <= 1 || !process.getuid)
59
+ return unavailable;
60
+ const directory = nativeIdentityDirectory(config), uid = process.getuid();
61
+ if (!directory)
62
+ return unavailable;
63
+ let hadNativeDescriptor = false;
64
+ try {
65
+ const root = await fs.promises.lstat(directory, { bigint: true });
66
+ if (!root.isDirectory() || root.uid !== BigInt(uid) || (root.mode & 18n) !== 0n)
67
+ return unavailable;
68
+ const canonical = await fs.promises.realpath(directory), canonicalHome = await fs.promises.realpath(os.homedir());
69
+ if (!canonical.startsWith(`${canonicalHome}${path.sep}`))
70
+ return unavailable;
71
+ const proc = `/proc/${pid}`, initial = processStat(await fs.promises.readFile(`${proc}/stat`, 'utf8'), pid);
72
+ if (!initial || expectedComm && initial.comm !== expectedComm || (await fs.promises.stat(proc)).uid !== uid)
73
+ return { nativeId: null, outcome: 'process_changed' };
74
+ const argsMatch = async () => {
75
+ if (expectedArgs === undefined)
76
+ return true;
77
+ const handle = await fs.promises.open(`${proc}/cmdline`, fs.constants.O_RDONLY);
78
+ try {
79
+ const buffer = Buffer.alloc(16385), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
80
+ return bytesRead < buffer.length && buffer.subarray(0, bytesRead).toString('utf8').replace(/\0$/, '').split('\0').join(' ').trim() === expectedArgs.trim();
81
+ }
82
+ finally {
83
+ await handle.close();
84
+ }
85
+ };
86
+ if (!await argsMatch())
87
+ return { nativeId: null, outcome: 'process_changed' };
88
+ const lockDescriptors = async () => {
89
+ const descriptors = await fs.promises.readdir(`${proc}/fd`);
90
+ if (descriptors.length > 4096)
91
+ throw new Error('descriptor bound');
92
+ const files = [];
93
+ for (const descriptor of descriptors) {
94
+ if (!/^\d+$/.test(descriptor))
95
+ continue;
96
+ const fd = `${proc}/fd/${descriptor}`;
97
+ let target;
98
+ try {
99
+ target = await fs.promises.readlink(fd);
100
+ }
101
+ catch (error) {
102
+ if (error.code === 'ENOENT')
103
+ continue;
104
+ throw error;
105
+ }
106
+ if (path.dirname(target) === canonical && /\.lock(?: \(deleted\))?$/.test(target))
107
+ files.push({ fd, target });
108
+ }
109
+ return files.sort((a, b) => a.fd.localeCompare(b.fd));
110
+ };
111
+ const descriptors = await lockDescriptors();
112
+ hadNativeDescriptor = descriptors.length > 0;
113
+ const locks = await fs.promises.readFile('/proc/locks', 'utf8');
114
+ const verified = new Map();
115
+ let unproved = false;
116
+ for (const { fd, target } of descriptors) {
117
+ if (!target.endsWith('.lock')) {
118
+ unproved = true;
119
+ continue;
120
+ }
121
+ const nativeId = path.basename(target).slice(0, -5);
122
+ if (!/^[A-Za-z0-9._-]{1,256}$/.test(nativeId) || nativeId === '.' || nativeId === '..') {
123
+ unproved = true;
124
+ continue;
125
+ }
126
+ try {
127
+ const lock = await fs.promises.lstat(target, { bigint: true }), held = await fs.promises.stat(fd, { bigint: true });
128
+ const ownerFile = path.join(canonical, `${nativeId}.owner.json`), owner = await readOwner(ownerFile, uid);
129
+ if (!safeFile(lock, uid) || !sameFile(lock, held) || !kernelWriter(locks, pid, lock)
130
+ || owner?.pid !== pid || owner.process_start_id !== `linux:${initial.start}`) {
131
+ unproved = true;
132
+ continue;
133
+ }
134
+ verified.set(nativeId, { fd, stat: lock, owner: ownerFile });
135
+ }
136
+ catch {
137
+ unproved = true;
138
+ }
139
+ }
140
+ if (verified.size > 1)
141
+ return { nativeId: null, outcome: 'ambiguous' };
142
+ if (unproved)
143
+ return { nativeId: null, outcome: 'unconfirmed' };
144
+ if (verified.size !== 1)
145
+ return unavailable;
146
+ const final = processStat(await fs.promises.readFile(`${proc}/stat`, 'utf8'), pid);
147
+ const currentLocks = await fs.promises.readFile('/proc/locks', 'utf8');
148
+ const [nativeId, evidence] = [...verified][0];
149
+ const current = await fs.promises.lstat(path.join(canonical, `${nativeId}.lock`), { bigint: true });
150
+ const held = await fs.promises.stat(evidence.fd, { bigint: true }), owner = await readOwner(evidence.owner, uid);
151
+ if (!final || final.start !== initial.start || final.comm !== initial.comm || final.pgid !== initial.pgid || final.tpgid !== initial.tpgid
152
+ || !await argsMatch()
153
+ || JSON.stringify(await lockDescriptors()) !== JSON.stringify(descriptors)
154
+ || !safeFile(current, uid) || !sameFile(current, evidence.stat) || !sameFile(current, held)
155
+ || !kernelWriter(currentLocks, pid, current) || owner?.pid !== pid || owner.process_start_id !== `linux:${final.start}`)
156
+ return { nativeId: null, outcome: 'process_changed' };
157
+ return { nativeId, outcome: 'verified' };
158
+ }
159
+ catch {
160
+ return hadNativeDescriptor ? { nativeId: null, outcome: 'unconfirmed' } : unavailable;
161
+ }
162
+ }
@@ -46,6 +46,8 @@ function diagnostic(candidate, process, reason) {
46
46
  return { session_id: text(candidate.sessionId), backend_session_id: candidate.backendSessionId.startsWith('inventory:') ? null : text(candidate.backendSessionId),
47
47
  backend_attached: candidate.backendAttached ?? null, tmux_session_name: text(candidate.tmuxSessionName),
48
48
  tmux_session_id: text(process?.tmuxSessionId), pane_id: text(process?.paneId), pane_pid: pid(process?.panePid),
49
+ process_pid: pid(process?.processPid), native_identity_source: process?.nativeIdentitySource === 'argv'
50
+ || process?.nativeIdentitySource === 'linux-flock-owner' ? process.nativeIdentitySource : null,
49
51
  program: text(process?.program), process_source: text(process?.processSource), arguments_observed: process?.argumentsObserved === true,
50
52
  agent_slug: text(process?.agentSlug), last_known_native_id: text(candidate.cachedNativeId), observed_native_id: text(process?.nativeId),
51
53
  pgid: Number.isInteger(process?.pgid) ? process.pgid : null,
@@ -72,7 +74,7 @@ export async function assertNativeResumeAvailable(target, excludedBackendSession
72
74
  throw new NativeResumeOwnerError('NATIVE_SESSION_ALREADY_RUNNING', blockers);
73
75
  }
74
76
  if (!process.confirmed)
75
- blockers.push(diagnostic(candidate, process, process.shellFailure
77
+ blockers.push(diagnostic(candidate, process, process.nativeIdentityFailure ?? process.shellFailure
76
78
  ?? (process.argumentsObserved === false ? 'PROCESS_ARGUMENTS_UNAVAILABLE' : 'FOREGROUND_PROCESS_UNCONFIRMED')));
77
79
  else if (process.agentSlug === target.slug && !process.nativeId)
78
80
  blockers.push(diagnostic(candidate, process, 'NATIVE_SESSION_ID_MISSING'));
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
+ import { nativeIdentityDirectory } from './nativeProcessIdentity.js';
16
17
  export const PLUGINS_DIR = path.join(os.homedir(), '.termdock', 'agent-plugins');
17
18
  export const MANIFEST_FILE = 'manifest.json';
18
19
  export const ICON_FILE = 'icon.svg';
@@ -323,6 +324,13 @@ export function validateManifest(raw, dir) {
323
324
  };
324
325
  }
325
326
  }
327
+ let nativeIdentity;
328
+ if (m.nativeIdentity !== undefined) {
329
+ if (!nativeIdentityDirectory(m.nativeIdentity))
330
+ errors.push('nativeIdentity must declare linux-flock-owner with a directory under home');
331
+ else
332
+ nativeIdentity = { kind: 'linux-flock-owner', directory: m.nativeIdentity.directory };
333
+ }
326
334
  // Optional automatic-title provider. Commands are argv arrays rather than
327
335
  // shell snippets so a UI-installed manifest cannot gain accidental shell
328
336
  // interpolation beyond the executable it explicitly declares.
@@ -411,6 +419,7 @@ export function validateManifest(raw, dir) {
411
419
  statuses,
412
420
  hooks,
413
421
  resume: resumeConfig,
422
+ nativeIdentity,
414
423
  titleNamer,
415
424
  },
416
425
  };
@@ -315,6 +315,7 @@ export function buildResumeCommand(agent, sessionId, launchArgv) {
315
315
  // ---------------------------------------------------------------------------
316
316
  /** Plugin-defined resume configs, keyed by slug. */
317
317
  const pluginResumeConfigs = new Map();
318
+ const pluginNativeIdentityConfigs = new Map();
318
319
  /**
319
320
  * Register plugin agents into the lookup maps. Plugins whose slug or aliases
320
321
  * collide with built-in agents are skipped. Call `clearPluginAgents()` first
@@ -362,6 +363,8 @@ export function registerPluginAgents(plugins) {
362
363
  staleFlags: manifest.resume.staleFlags ?? [],
363
364
  });
364
365
  }
366
+ if (manifest.nativeIdentity)
367
+ pluginNativeIdentityConfigs.set(manifest.slug, { ...manifest.nativeIdentity });
365
368
  registered++;
366
369
  }
367
370
  return { registered, skipped };
@@ -376,6 +379,11 @@ export function clearPluginAgents() {
376
379
  BY_ALIAS.delete(alias);
377
380
  }
378
381
  pluginResumeConfigs.clear();
382
+ pluginNativeIdentityConfigs.clear();
383
+ }
384
+ export function getPluginNativeIdentityConfig(slug) {
385
+ const config = pluginNativeIdentityConfigs.get(slug);
386
+ return config ? { ...config } : undefined;
379
387
  }
380
388
  export function getPluginResumeConfig(slug) {
381
389
  return pluginResumeConfigs.get(slug);
@@ -1,6 +1,7 @@
1
1
  import { collaborationSessionDisplayName } from '../utils/sessionDisplayName.js';
2
2
  import { integrationError } from '../agent/integrationStore.js';
3
3
  import { assertNativeResumeAvailable, collectNativeResumeOwnerCandidates } from '../agent/nativeResumeOwner.js';
4
+ import { readNativeProcessIdentity } from '../agent/nativeProcessIdentity.js';
4
5
  import { integrationLaunchCommand } from '../agent/integrationSessions.js';
5
6
  import { ensureTeam } from '../agent/collaborationTeam.js';
6
7
  import { androidRecordings } from '../android/recording.js';
@@ -38,7 +39,7 @@ import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fi
38
39
  import { writeResponseChunk } from '../utils/httpResponse.js';
39
40
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
40
41
  import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, } from '../utils/tmuxProgramDetection.js';
41
- import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
42
+ import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, getPluginNativeIdentityConfig, listAgents, } from '../agent/registry.js';
42
43
  import { applyAgentEvent, agentEventMatchesCurrentAgent, defaultAgentSessionState, parseAgentEvent, } from '../agent/session.js';
43
44
  import { canRestoreDeadAgentShell, isConfirmedAgentResumeProcess, normalizePersistedAgentResumeInfo, } from '../agent/resumePersistence.js';
44
45
  import { AgentResumeHistoryStore } from '../agent/resumeHistory.js';
@@ -1577,7 +1578,7 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
1577
1578
  * agent-keyed selector can never reach it. Delivery still needs an agent (the
1578
1579
  * confirm gate and prompt formatting assume a TUI); driving a terminal —
1579
1580
  * run/capture — is a shell operation and works on any pane. */
1580
- async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false) {
1581
+ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false) {
1581
1582
  if (!binding.tmuxSessionName)
1582
1583
  return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
1583
1584
  const name = `=${binding.tmuxSessionName}`;
@@ -1596,12 +1597,19 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1596
1597
  const serverPid = Number((await runTmux(['display-message', '-p', '#{pid}'])).trim());
1597
1598
  if (!Number.isInteger(serverPid) || serverPid <= 0)
1598
1599
  throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
1600
+ if (nativeProcessIdentity && process.platform === 'linux')
1601
+ await getProcessSnapshot(true);
1599
1602
  const panes = await Promise.all(layout.windows.flatMap((window) => window.panes).map(async (pane) => {
1600
1603
  // Native identity needs the foreground argv even for a named binary;
1601
1604
  // pane_current_command alone cannot prove an exact resumed conversation.
1602
1605
  const program = await resolveTmuxPaneProgram(pane, true);
1603
1606
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1604
- const nativeSessionId = agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null;
1607
+ const identity = nativeProcessIdentity ? await observeProgramNativeIdentity(agent, program)
1608
+ : { nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null, failure: null };
1609
+ if (identity.failure)
1610
+ integrationError(identity.failure === 'NATIVE_SESSION_IDENTITY_CONFLICT'
1611
+ ? 'NATIVE_SESSION_ID_MISMATCH' : 'NATIVE_SESSION_OWNER_UNCONFIRMED', 'Current process native identity evidence conflicts or cannot be uniquely verified', 409);
1612
+ const nativeSessionId = identity.nativeId;
1605
1613
  return { serverPid, sessionId: layout.sessionId, paneId: pane.id, panePid: pane.pid,
1606
1614
  agentSlug: agent?.slug ?? '', nativeSessionId, cwd: pane.currentPath,
1607
1615
  isShell: shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') };
@@ -1872,7 +1880,7 @@ export const integrationSessionAdapter = {
1872
1880
  const binding = { sessionId: record.session_id, backendSessionId: terminal.backendSessionId,
1873
1881
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: record.agent_slug,
1874
1882
  nativeSessionId: record.agent_native_session_id, pane: record.terminal_binding ?? null };
1875
- const inspected = await inspectCollaborationTmux(binding);
1883
+ const inspected = await inspectCollaborationTmux(binding, undefined, false, true);
1876
1884
  if (inspected.state === 'offline')
1877
1885
  return { exists: false, running: false, shell: false, agentSlug: null, nativeId: null };
1878
1886
  if (inspected.state !== 'ready' || !inspected.pane)
@@ -1938,11 +1946,13 @@ async function assertIntegrationNativeResumeAvailable(record) {
1938
1946
  return Promise.all(layout.windows.flatMap(window => window.panes).map(async (pane) => {
1939
1947
  const program = await resolveTmuxPaneProgram(pane, true, true);
1940
1948
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1949
+ const identity = await observeProgramNativeIdentity(agent, program);
1941
1950
  const shell = await inspectIntegrationShell({ panePid: pane.pid,
1942
1951
  isShell: !agent && shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
1943
- return { confirmed: shell.confirmed || program?.source === 'tmux-tty' && Boolean(program.rawArgs),
1952
+ return { confirmed: !identity.failure && (shell.confirmed || program?.source === 'tmux-tty' && Boolean(program.rawArgs)),
1944
1953
  agentSlug: agent?.slug ?? null,
1945
- nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null,
1954
+ nativeId: identity.nativeId, nativeIdentityFailure: identity.failure, nativeIdentitySource: identity.source,
1955
+ processPid: program?.processPid,
1946
1956
  paneId: pane.id, panePid: pane.pid, tmuxSessionId: layout.sessionId, program: program?.command ?? null,
1947
1957
  processSource: program?.source, argumentsObserved: !!program?.rawArgs,
1948
1958
  shellFailure: shell.failure, pgid: shell.pgid, tpgid: shell.tpgid };
@@ -3948,13 +3958,15 @@ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = fa
3948
3958
  }
3949
3959
  const selected = selectTmuxForegroundProgram({
3950
3960
  panePid: pane.pid,
3961
+ includePaneProcess: requireArgs,
3951
3962
  rows,
3952
3963
  shellNames: shellNamesBackend,
3953
3964
  genericProgramNames,
3954
3965
  extractProgramLabel,
3955
3966
  });
3956
3967
  if (selected) {
3957
- return { command: selected.command, source: 'tmux-tty', rawArgs: selected.rawArgs };
3968
+ return { command: selected.command, source: 'tmux-tty', rawArgs: selected.rawArgs,
3969
+ ...(selected.processPid ? { processPid: selected.processPid, processComm: selected.processComm } : {}) };
3958
3970
  }
3959
3971
  }
3960
3972
  catch {
@@ -3962,6 +3974,23 @@ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = fa
3962
3974
  }
3963
3975
  return { command, source: 'tmux-pane', rawArgs: null };
3964
3976
  }
3977
+ async function observeProgramNativeIdentity(agent, program) {
3978
+ const fromArgs = agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null;
3979
+ const config = agent ? getPluginNativeIdentityConfig(agent.slug) : undefined;
3980
+ if (config && program?.processPid) {
3981
+ const proof = await readNativeProcessIdentity(program.processPid, config, program.processComm, program.rawArgs ?? undefined);
3982
+ if (proof.outcome === 'ambiguous' || proof.outcome === 'process_changed' || proof.outcome === 'unconfirmed')
3983
+ return { nativeId: null, source: null,
3984
+ failure: proof.outcome === 'ambiguous' ? 'NATIVE_SESSION_IDENTITY_AMBIGUOUS'
3985
+ : proof.outcome === 'unconfirmed' ? 'NATIVE_SESSION_OWNERSHIP_UNCONFIRMED' : 'NATIVE_SESSION_PROCESS_CHANGED' };
3986
+ if (proof.nativeId) {
3987
+ if (fromArgs && fromArgs !== proof.nativeId)
3988
+ return { nativeId: null, source: null, failure: 'NATIVE_SESSION_IDENTITY_CONFLICT' };
3989
+ return { nativeId: proof.nativeId, source: 'linux-flock-owner', failure: null };
3990
+ }
3991
+ }
3992
+ return { nativeId: fromArgs, source: fromArgs ? 'argv' : null, failure: null };
3993
+ }
3965
3994
  /**
3966
3995
  * Extract a human-readable program label from a full command line.
3967
3996
  *
@@ -86,7 +86,10 @@ function isDirectProgramMatch(row, command) {
86
86
  export function selectTmuxForegroundProgram(input) {
87
87
  const helperProgramNames = input.helperProgramNames ?? DEFAULT_TMUX_HELPER_PROGRAM_NAMES;
88
88
  const rowsByPid = new Map(input.rows.map((row) => [row.pid, row]));
89
- const foregroundRows = input.rows.filter((row) => row.pid !== input.panePid && row.tpgid > 0 && row.pgid === row.tpgid && !row.stat.startsWith('Z'));
89
+ const foregroundRows = input.rows.filter((row) => (row.pid !== input.panePid || input.includePaneProcess === true
90
+ && !input.shellNames.has(lowerProgramName(row.comm)?.replace(/^-/, '') ?? '')
91
+ && !input.shellNames.has(lowerProgramName(input.extractProgramLabel(row.args) ?? row.comm)?.replace(/^-/, '') ?? ''))
92
+ && row.tpgid > 0 && row.pgid === row.tpgid && !row.stat.startsWith('Z'));
90
93
  if (foregroundRows.length === 0) {
91
94
  return null;
92
95
  }
@@ -130,6 +133,7 @@ export function selectTmuxForegroundProgram(input) {
130
133
  return {
131
134
  command: selected.command,
132
135
  rawArgs: selected.row.args,
136
+ ...(input.includePaneProcess ? { processPid: selected.row.pid, processComm: selected.row.comm } : {}),
133
137
  };
134
138
  }
135
139
  export function normalizeTmuxMetadataProgram(program, options) {
@@ -6,7 +6,8 @@
6
6
  **实际运行服务至少 1.4.302**。退出后的重复原生会话检查修复要求
7
7
  **实际运行服务至少 1.4.303**。没有 PTY 附着的已登记 tmux 的重复保护修复要求
8
8
  **实际运行服务至少 1.4.304**。管理员恢复诊断要求 **CLI / 实际服务均至少 1.4.305**;
9
- 建议 CLI 与服务同步安装 1.4.305(既有接入命令的 CLI 最低仍为 1.4.301)。
9
+ 直接 exec 为 pane PID 的原生 argv 核验及可选 Linux 持锁身份要求 **实际服务至少 1.4.306**;
10
+ 建议 CLI 与服务同步安装 1.4.306(既有接入命令的 CLI 最低仍为 1.4.301,诊断 CLI 最低为 1.4.305)。
10
11
  现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
11
12
  `task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
12
13
 
@@ -311,6 +312,48 @@ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒
311
312
  仅在确认 tmux 已消失或当前进程排除冲突时继续;未知进程、同 Agent 缺少精确 UUID、
312
313
  观察失败均返回 `NATIVE_SESSION_OWNER_UNCONFIRMED`,不以旧 UUID 证明没有冲突。
313
314
 
315
+ ### 可选的当前进程原生身份(1.4.306)
316
+
317
+ 某些 Agent 首次启动的 argv 没有 UUID。支持以下所有权协议的 Agent 插件可以在
318
+ 原 `manifest.json` 中增加一个可选字段;现有别名、恢复命令和其他配置保持原样:
319
+
320
+ ```json
321
+ "nativeIdentity": {
322
+ "kind": "linux-flock-owner",
323
+ "directory": "~/.my-agent/native-writer-locks"
324
+ }
325
+ ```
326
+
327
+ 这是通用协议声明,TD 没有内置任何私有 Agent 名称或路径。锁目录须是当前用户
328
+ home 内的绝对目录或 `~/` 路径;运行时目录、文件须由当前 UID 拥有,且不能被
329
+ 组或其他用户写入。目录/文件本身不能为软链接;目录的真实路径仍须在 home 内。
330
+ 锁是 `<native-id>.lock` 普通文件,配套 `<native-id>.owner.json` 至少含
331
+ `{"pid":123,"process_start_id":"linux:456"}`;其他字段不用来证明身份。
332
+
333
+ 插件配置在服务启动时载入。升级、保留原配置并加入声明后,重启实际服务一次;
334
+ Linux 服务 `capabilities.native_identity_linux_flock_owner=true` 表示支持此证据类型,
335
+ 不能替代核验插件声明与真实持锁情况。其他平台返回 false,继续使用原 argv 证据。
336
+ 不需要改 integration policy、凭据、原启动 argv/model/cwd/包装器或业务任务。
337
+
338
+ TD 从当前终端的真实前台进程取得 PID(包括直接 exec 后就是 pane PID 的进程),
339
+ 再只读核验:该 PID 的 UID、前台进程组、当前 `/proc/<pid>/stat` 启动时间、owner
340
+ 文件中的 PID/start_id、它实际打开的 FD 与目录内锁文件的 dev/inode、以及
341
+ `/proc/locks` 的同 PID 独占整文件 `FLOCK ADVISORY WRITE`。读取前后复核进程实例及 argv、
342
+ 相关 FD 集合、inode、owner 和内核持锁,任一无法成立都不推导身份。
343
+ owner JSON 读取限 4096 字节、argv 比对限 16384 字节、进程 FD 数量限 4096;不会读会话正文/环境或尝试取得写锁。
344
+
345
+ 仅一个完整匹配的原生 ID 才可用于当前身份:明确持有目标 ID 拒绝重复启动;明确
346
+ 持有其他 ID 可以排除该进程。多个持锁 ID、argv 与锁身份矛盾、检查间进程变化
347
+ 均继续拒绝。owner 文件存在、PID 存活、旧登记或 hook 历史均不能单独确认身份。
348
+ 尚未建立可证明身份、协议不符或读权限不足时仍保留未确认保护;没有声明此字段的
349
+ 旧插件继续使用原有 argv 路径。此证据仅支持本机 Linux tmux 进程,不宣称能覆盖
350
+ 未登记外部进程、远端 Agent 或其他操作系统。
351
+
352
+ 维护机已用真实隔离 tmux/内核 flock 验证首次启动的 UUID 捕获、直接 exec 的 pane
353
+ PID、同 ID 拒绝/不同 ID 排除、遗留 owner、错误 start_id/PID、inode 替换、没有真实
354
+ 持锁、多个 ID 和 argv 冲突。**私有 TraeX 的 1.4.306 身份接入仍需使用方实机复验**。
355
+ 原 `startupInput` 仍独立控制首次写入;核验 native ID 不表示输入初始化完成。
356
+
314
357
  ### 管理员恢复诊断(1.4.305)
315
358
 
316
359
  `capabilities.session_restore_diagnostics=true` 表示服务支持持久恢复诊断。
@@ -336,13 +379,18 @@ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启
336
379
  每个阻塞候选含 `session_id / backend_session_id / backend_attached`、
337
380
  `tmux_session_name / tmux_session_id / pane_id / pane_pid`、`program / process_source`、
338
381
  `arguments_observed / agent_slug / last_known_native_id / observed_native_id`、
339
- `pgid / tpgid / reason / observed_at`;无法观测的值为 null。backend 不存在或仅有旧
382
+ `pgid / tpgid / reason / observed_at`;306 另含真实前台 `process_pid` 及
383
+ `native_identity_source`(argv / linux-flock-owner / null)。无法观测的值为 null。backend 不存在或仅有旧
340
384
  登记仍可通过 tmux 核验;`pane_pid` 是承载 pane 的 PID,不冒充 Agent 前台 PID。
341
385
  报告最多保存 128 个阻塞候选;`total_blockers` 保留总数,确定的同 UUID 所有者优先展示。
342
386
 
343
387
  | reason | 实际证据缺口或冲突 |
344
388
  | --- | --- |
345
- | NATIVE_SESSION_MATCH | 当前前台进程明确携带目标 UUID |
389
+ | NATIVE_SESSION_MATCH | 当前前台进程的 argv 或完整持锁证据明确证明目标 UUID |
390
+ | NATIVE_SESSION_IDENTITY_CONFLICT | argv 与完整持锁证据指向不同 UUID |
391
+ | NATIVE_SESSION_IDENTITY_AMBIGUOUS | 当前进程持有多个可证明的原生 UUID |
392
+ | NATIVE_SESSION_PROCESS_CHANGED | 持锁检查间进程实例或所有权证据变化 |
393
+ | NATIVE_SESSION_OWNERSHIP_UNCONFIRMED | 当前打开了原生锁文件,但持锁/owner/inode 证据不能完整成立 |
346
394
  | NATIVE_SESSION_ID_MISSING | 当前同 Agent 的进程已观测,但没有可回读的精确 UUID |
347
395
  | PROCESS_ARGUMENTS_UNAVAILABLE | 无法取得可靠的当前进程 argv |
348
396
  | FOREGROUND_PROCESS_UNCONFIRMED | 无法可靠核验前台进程 |
@@ -383,9 +431,11 @@ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启
383
431
  遗漏;维护机已用“旧 UUID C、实际运行 UUID A、无 PTY 附着”的真实隔离 tmux 复现,
384
432
  并验证 1.4.304 编译后检查在该状态下拒绝再次启动。
385
433
  使用方已报告 1.4.304 在未挂接网页/PTY、旧登记 UUID C 而实际运行 A 的场景下,
386
- 正确拒绝同 UUID 恢复且没有第二个进程。所有者退出后仍有未确认候选,不能仅凭
387
- 错误码判断是哪一个 pane;1.4.305 提供管理员持久诊断,并保持原拒绝条件。
388
- **1.4.305 的真实 TraeX 阻塞候选诊断与稳定输入条件仍须使用方实机复验**。
434
+ 正确拒绝同 UUID 恢复且没有第二个进程。1.4.305 实机诊断已定位四个同 Agent
435
+ 无 argv UUID 的候选,另一个候选主进程直接 exec 为 pane PID。使用方提供了当前
436
+ PID/start_id、实际 FD/inode 与内核 flock 一致的私有原生所有权证据;1.4.306 提供
437
+ 上述可选通用插件声明,未修改私有 Agent 或放宽未确认保护。
438
+ **1.4.306 的私有 TraeX 持锁身份接入与稳定输入条件仍须使用方实机复验**。
389
439
  回读 capabilities 后,应核对原 TD ID、真实 TraeX UUID 与原 argv/cwd/包装器快照,
390
440
  并验证 Agent 真正加载原对话。
391
441
  先完成测试环境的创建→追问→关联答复,再验重复键、追加咨询、断线重投和精确恢复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.305",
3
+ "version": "1.4.306",
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.305",
4
+ "version": "1.4.306",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-E9GDa4nrCXADRXdyPKufusZt6IcCr4m2mGdx5h34KMQ=",
9
- "serverBundleHash": "sha256-XkM6+bMn6xdVpuXe8oaPunofP3J+MgVfh7lwiOhSY4I=",
9
+ "serverBundleHash": "sha256-X8EpOnbvlZo/rtZ5QLd5zVKFUOFeDkxeqJmY0hUBQnE=",
10
10
  "clientBundleHash": "sha256-HOoNMOYhnph9XwUhSYE5W2CdI9Vb4g0c64ubW49LnXE=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"