termdock 1.4.304 → 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.
@@ -8,6 +8,7 @@ import { executeCollaborationCommand, duration } from './collaborationCli.js';
8
8
  import { getTermdockVersion } from '../utils/version.js';
9
9
  export const INTEGRATION_HELP = `td integration — local scoped integration administration
10
10
  groups | list
11
+ diagnostics <TD-session-id> (read the last restore check; administrator only)
11
12
  create --file <policy.json> --credential-file <private-output.json>
12
13
  update --file <policy.json> (same identity/group; preserves the credential)
13
14
  revoke <principal-id>
@@ -58,13 +59,22 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
58
59
  write(INTEGRATION_HELP);
59
60
  return 0;
60
61
  }
62
+ const socketPath = integrationSocketPath(port);
63
+ if (action === 'diagnostics') {
64
+ if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
65
+ throw new Error('diagnostics requires one TD session id');
66
+ const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
67
+ if (capabilities.session_restore_diagnostics !== true)
68
+ throw new CollaborationError('SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED', 'Running service does not support stored restore diagnostics', 409);
69
+ write(JSON.stringify(parsed(await request(socketPath, 'GET', `/admin/sessions/${encodeURIComponent(argv[1])}/restore-diagnostics`, undefined, headers(undefined, adminToken)))));
70
+ return 0;
71
+ }
61
72
  for (let index = 1; index < argv.length; index += 2) {
62
73
  const flag = argv[index];
63
74
  if (!['--file', '--credential-file'].includes(flag) || !argv[index + 1] || options[flag])
64
75
  throw new Error('Invalid integration arguments');
65
76
  options[flag] = argv[index + 1];
66
77
  }
67
- const socketPath = integrationSocketPath(port);
68
78
  if (action === 'groups' || action === 'list') {
69
79
  if (argv.length !== 1)
70
80
  throw new Error('Unexpected arguments');
@@ -61,6 +61,7 @@ export class IntegrationRuntime {
61
61
  };
62
62
  app.get('/admin/groups', run(req => { admin(req); return { groups: options.messages.list().filter(g => !g.federated).map(g => ({ id: g.id, name: g.name })) }; }));
63
63
  app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
64
+ app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return this.sessions.restoreDiagnostics(req.params.id); }));
64
65
  app.get('/admin/principals', run(req => { admin(req); return { integrations: this.store.policies() }; }));
65
66
  app.post('/admin/principals', run(req => { admin(req); this.group(req.body?.groupId); const provisioned = this.store.provision(req.body); try {
66
67
  this.sync();
@@ -145,6 +146,8 @@ export class IntegrationRuntime {
145
146
  idempotent_session_create: true, exact_session_restore: true, external_validation: false,
146
147
  startup_input_conditions: true,
147
148
  integration_policy_update: true,
149
+ session_restore_diagnostics: true,
150
+ native_identity_linux_flock_owner: process.platform === 'linux',
148
151
  permissions: ['task.read', 'task.create', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore'] };
149
152
  }
150
153
  error(error) {
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
4
  import { atomicJson, integrationError } from './integrationStore.js';
5
+ import { NativeResumeOwnerError } from './nativeResumeOwner.js';
5
6
  /** Quote each configured argument as literal shell data, including paths. */
6
7
  export function integrationLaunchCommand(record, restore) {
7
8
  const launchArgs = record.profile.argv.map(arg => arg === '{cwd}' ? record.cwd : arg);
@@ -15,8 +16,8 @@ function startupInput(record, now) {
15
16
  return condition ? { state: 'pending', deadline: now + (condition.timeoutMs ?? 120000), matched_since: null, observed_at: null } : undefined;
16
17
  }
17
18
  export function publicIntegrationSession(record) {
18
- const { profile: _, deadline: _deadline, launch_submitted: _submitted, startup_condition: _condition, ...publicRecord } = record;
19
- return publicRecord;
19
+ const { profile: _, deadline: _deadline, launch_submitted: _submitted, startup_condition: _condition, restore_diagnostics: diagnostics, ...publicRecord } = record;
20
+ return { ...publicRecord, ...(diagnostics ? { restore_diagnostics_available: true } : {}) };
20
21
  }
21
22
  export function permittedCwd(profile, cwd) {
22
23
  if (typeof cwd !== 'string' || !path.isAbsolute(cwd) || /[\x00-\x1f\x7f]/.test(cwd))
@@ -168,7 +169,9 @@ export class IntegrationSessions {
168
169
  catch (error) {
169
170
  const current = this.doc.sessions.find(s => s.session_id === record.session_id);
170
171
  const code = error.code;
171
- this.saveRecord({ ...current, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_LAUNCH_FAILED', updated_at: Date.now() });
172
+ this.saveRecord({ ...current, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_LAUNCH_FAILED',
173
+ restore_diagnostics: error instanceof NativeResumeOwnerError ? { ...structuredClone(error.diagnostics), operation_id: current.operation_id } : undefined,
174
+ updated_at: Date.now() });
172
175
  }
173
176
  return structuredClone(this.doc.sessions.find(s => s.session_id === record.session_id));
174
177
  })().finally(() => this.pending.delete(record.session_id));
@@ -176,6 +179,14 @@ export class IntegrationSessions {
176
179
  return promise;
177
180
  }
178
181
  async get(principal, id) { this.lookup(principal, id); await this.refresh(id); return this.lookup(principal, id); }
182
+ /** Read the stored check only: no process probe, PTY attachment, or new launch. */
183
+ restoreDiagnostics(id) {
184
+ const record = this.doc.sessions.find(session => session.session_id === id);
185
+ if (!record)
186
+ return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Integration session does not exist', 404);
187
+ return structuredClone({ session_id: record.session_id, operation_id: record.operation_id,
188
+ state: record.state, error_code: record.error_code, diagnostics: record.restore_diagnostics ?? null });
189
+ }
179
190
  async restore(principal, id, idempotencyKey) {
180
191
  const request = this.request(principal, idempotencyKey, ['restore', id]);
181
192
  if (request.previous)
@@ -215,6 +226,7 @@ export class IntegrationSessions {
215
226
  record.operation_id = randomUUID();
216
227
  record.deadline = Date.now() + this.bindingTimeout;
217
228
  record.updated_at = Date.now();
229
+ record.restore_diagnostics = undefined;
218
230
  if (!record.profile.startupInput && !record.startup_condition)
219
231
  record.startup_condition = structuredClone(principal.launchProfiles.find(profile => profile.id === record.launch_profile)?.startupInput);
220
232
  record.startup_input = startupInput(record, Date.now());
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { integrationError } from './integrationStore.js';
1
+ import { CollaborationError } from './collaborationProtocol.js';
2
2
  /** A tmux remains observable without a browser/PTY attachment, including after
3
3
  * service restart. Inventory UUIDs never filter the candidate set. */
4
4
  export function collectNativeResumeOwnerCandidates(inventory, backends) {
@@ -9,6 +9,7 @@ export function collectNativeResumeOwnerCandidates(inventory, backends) {
9
9
  continue;
10
10
  const backend = entry.backendSessionId ? backends.get(entry.backendSessionId) : null;
11
11
  candidates.push({ backendSessionId: entry.backendSessionId ?? `inventory:${entry.sessionId}`,
12
+ sessionId: entry.sessionId, backendAttached: !!backend,
12
13
  tmuxSessionName: entry.tmuxSessionName, cachedSlug: backend?.agent?.slug ?? entry.agentResume?.slug ?? null,
13
14
  cachedNativeId: backend?.agentSession?.sessionId ?? entry.agentResume?.sessionId ?? null });
14
15
  seenTmux.add(entry.tmuxSessionName);
@@ -18,17 +19,44 @@ export function collectNativeResumeOwnerCandidates(inventory, backends) {
18
19
  for (const [backendSessionId, backend] of backends) {
19
20
  if (seenBackend.has(backendSessionId) || backend.mode === 'tmux' && backend.tmuxSessionName && seenTmux.has(backend.tmuxSessionName))
20
21
  continue;
21
- candidates.push({ backendSessionId, tmuxSessionName: backend.mode === 'tmux' ? backend.tmuxSessionName : null,
22
+ candidates.push({ backendSessionId, sessionId: inventory.find(entry => entry.backendSessionId === backendSessionId)?.sessionId ?? null,
23
+ backendAttached: true, tmuxSessionName: backend.mode === 'tmux' ? backend.tmuxSessionName : null,
22
24
  cachedSlug: backend.agent?.slug ?? null, cachedNativeId: backend.agentSession?.sessionId ?? null });
23
25
  if (backend.mode === 'tmux' && backend.tmuxSessionName)
24
26
  seenTmux.add(backend.tmuxSessionName);
25
27
  }
26
28
  return candidates;
27
29
  }
30
+ export class NativeResumeOwnerError extends CollaborationError {
31
+ diagnostics;
32
+ constructor(code, blockers) {
33
+ super(code, code === 'NATIVE_SESSION_ALREADY_RUNNING'
34
+ ? 'Another terminal process proves the exact native conversation is running'
35
+ : 'A possible native owner cannot be verified; inspect its terminal before restoring', 409);
36
+ // Preserve the proven owner even when earlier uncertain panes exceed the bound.
37
+ const ordered = code === 'NATIVE_SESSION_ALREADY_RUNNING' ? [blockers.at(-1), ...blockers.slice(0, -1)] : blockers;
38
+ this.diagnostics = { code, checked_at: Date.now(), candidates: ordered.slice(0, 128), total_blockers: blockers.length, truncated: blockers.length > 128 };
39
+ }
40
+ }
41
+ function diagnostic(candidate, process, reason) {
42
+ // Store only named locator/evidence fields. Never serialize a callback error,
43
+ // argv, terminal text, cwd, or plugin/wrapper payload into diagnostics.
44
+ const text = (value) => typeof value === 'string' && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value) ? value : null;
45
+ const pid = (value) => Number.isInteger(value) && value > 0 ? value : null;
46
+ return { session_id: text(candidate.sessionId), backend_session_id: candidate.backendSessionId.startsWith('inventory:') ? null : text(candidate.backendSessionId),
47
+ backend_attached: candidate.backendAttached ?? null, tmux_session_name: text(candidate.tmuxSessionName),
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,
51
+ program: text(process?.program), process_source: text(process?.processSource), arguments_observed: process?.argumentsObserved === true,
52
+ agent_slug: text(process?.agentSlug), last_known_native_id: text(candidate.cachedNativeId), observed_native_id: text(process?.nativeId),
53
+ pgid: Number.isInteger(process?.pgid) ? process.pgid : null,
54
+ tpgid: Number.isInteger(process?.tpgid) ? process.tpgid : null, reason, observed_at: Date.now() };
55
+ }
28
56
  /** Only process observations prove presence or absence of another owner.
29
57
  * Cached UUIDs cannot exclude a terminal; the caller verifies its own pane. */
30
58
  export async function assertNativeResumeAvailable(target, excludedBackendSessionId, candidates, observe) {
31
- let uncertain = false;
59
+ const blockers = [];
32
60
  for (const candidate of candidates) {
33
61
  if (candidate.backendSessionId === excludedBackendSessionId)
34
62
  continue;
@@ -37,17 +65,21 @@ export async function assertNativeResumeAvailable(target, excludedBackendSession
37
65
  processes = await observe(candidate);
38
66
  }
39
67
  catch {
40
- uncertain = true;
68
+ blockers.push(diagnostic(candidate, null, 'TERMINAL_OBSERVATION_FAILED'));
41
69
  continue;
42
70
  }
43
71
  for (const process of processes) {
44
72
  if (process.confirmed && process.agentSlug === target.slug && process.nativeId === target.nativeSessionId) {
45
- integrationError('NATIVE_SESSION_ALREADY_RUNNING', 'Another terminal process proves the exact native conversation is running', 409);
73
+ blockers.push(diagnostic(candidate, process, 'NATIVE_SESSION_MATCH'));
74
+ throw new NativeResumeOwnerError('NATIVE_SESSION_ALREADY_RUNNING', blockers);
46
75
  }
47
- if (!process.confirmed || process.agentSlug === target.slug && !process.nativeId)
48
- uncertain = true;
76
+ if (!process.confirmed)
77
+ blockers.push(diagnostic(candidate, process, process.nativeIdentityFailure ?? process.shellFailure
78
+ ?? (process.argumentsObserved === false ? 'PROCESS_ARGUMENTS_UNAVAILABLE' : 'FOREGROUND_PROCESS_UNCONFIRMED')));
79
+ else if (process.agentSlug === target.slug && !process.nativeId)
80
+ blockers.push(diagnostic(candidate, process, 'NATIVE_SESSION_ID_MISSING'));
49
81
  }
50
82
  }
51
- if (uncertain)
52
- integrationError('NATIVE_SESSION_OWNER_UNCONFIRMED', 'A possible native owner cannot be verified; inspect its terminal before restoring', 409);
83
+ if (blockers.length)
84
+ throw new NativeResumeOwnerError('NATIVE_SESSION_OWNER_UNCONFIRMED', blockers);
53
85
  }
@@ -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() ?? '') };
@@ -1825,19 +1833,26 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
1825
1833
  /** Restricted session adapter: only preconfigured argv and the exact owned
1826
1834
  * terminal can be launched; no remote drive or arbitrary shell commands. */
1827
1835
  async function integrationPaneIsIdleShell(pane) {
1836
+ return (await inspectIntegrationShell(pane)).confirmed;
1837
+ }
1838
+ async function inspectIntegrationShell(pane) {
1828
1839
  if (!pane.isShell)
1829
- return false;
1840
+ return { confirmed: false, failure: null, pgid: null, tpgid: null };
1830
1841
  try {
1831
1842
  const { stdout } = await execFileAsync('ps', ['-p', String(pane.panePid), '-o', 'pgid=,tpgid=,args='], { timeout: 3000, maxBuffer: 16384 });
1832
- const row = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
1833
- if (!row || row[1] !== row[2])
1834
- return false;
1843
+ const row = stdout.trim().match(/^(-?\d+)\s+(-?\d+)\s+(.+)$/);
1844
+ if (!row)
1845
+ return { confirmed: false, failure: 'SHELL_PROCESS_NOT_FOUND', pgid: null, tpgid: null };
1846
+ const pgid = Number(row[1]), tpgid = Number(row[2]);
1847
+ if (pgid <= 0 || pgid !== tpgid)
1848
+ return { confirmed: false, failure: 'SHELL_FOREGROUND_MISMATCH', pgid, tpgid };
1835
1849
  const argv = splitCommandToArgv(row[3]);
1836
1850
  const command = normalizeProgramName(argv[0]?.replace(/^-/, ''))?.toLowerCase();
1837
- return !!command && shellNamesBackend.has(command) && argv.slice(1).every(arg => ['-l', '-i', '-il', '-li', '--login', '--interactive'].includes(arg));
1851
+ const confirmed = !!command && shellNamesBackend.has(command) && argv.slice(1).every(arg => ['-l', '-i', '-il', '-li', '--login', '--interactive'].includes(arg));
1852
+ return { confirmed, failure: confirmed ? null : 'SHELL_ARGUMENTS_UNSUPPORTED', pgid, tpgid };
1838
1853
  }
1839
1854
  catch {
1840
- return false;
1855
+ return { confirmed: false, failure: 'SHELL_OBSERVATION_FAILED', pgid: null, tpgid: null };
1841
1856
  }
1842
1857
  }
1843
1858
  function integrationTerminalRecord(record) {
@@ -1865,7 +1880,7 @@ export const integrationSessionAdapter = {
1865
1880
  const binding = { sessionId: record.session_id, backendSessionId: terminal.backendSessionId,
1866
1881
  mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: record.agent_slug,
1867
1882
  nativeSessionId: record.agent_native_session_id, pane: record.terminal_binding ?? null };
1868
- const inspected = await inspectCollaborationTmux(binding);
1883
+ const inspected = await inspectCollaborationTmux(binding, undefined, false, true);
1869
1884
  if (inspected.state === 'offline')
1870
1885
  return { exists: false, running: false, shell: false, agentSlug: null, nativeId: null };
1871
1886
  if (inspected.state !== 'ready' || !inspected.pane)
@@ -1931,11 +1946,16 @@ async function assertIntegrationNativeResumeAvailable(record) {
1931
1946
  return Promise.all(layout.windows.flatMap(window => window.panes).map(async (pane) => {
1932
1947
  const program = await resolveTmuxPaneProgram(pane, true, true);
1933
1948
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1934
- const shell = !agent && await integrationPaneIsIdleShell({ panePid: pane.pid,
1935
- isShell: shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
1936
- return { confirmed: shell || program?.source === 'tmux-tty' && Boolean(program.rawArgs),
1949
+ const identity = await observeProgramNativeIdentity(agent, program);
1950
+ const shell = await inspectIntegrationShell({ panePid: pane.pid,
1951
+ isShell: !agent && shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
1952
+ return { confirmed: !identity.failure && (shell.confirmed || program?.source === 'tmux-tty' && Boolean(program.rawArgs)),
1937
1953
  agentSlug: agent?.slug ?? null,
1938
- nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null };
1954
+ nativeId: identity.nativeId, nativeIdentityFailure: identity.failure, nativeIdentitySource: identity.source,
1955
+ processPid: program?.processPid,
1956
+ paneId: pane.id, panePid: pane.pid, tmuxSessionId: layout.sessionId, program: program?.command ?? null,
1957
+ processSource: program?.source, argumentsObserved: !!program?.rawArgs,
1958
+ shellFailure: shell.failure, pgid: shell.pgid, tpgid: shell.tpgid };
1939
1959
  }));
1940
1960
  }
1941
1961
  if (!backend)
@@ -1943,7 +1963,8 @@ async function assertIntegrationNativeResumeAvailable(record) {
1943
1963
  const program = await detectShellActiveProgram(backend);
1944
1964
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1945
1965
  return [{ confirmed: !!program && program.source !== 'unknown', agentSlug: agent?.slug ?? null,
1946
- nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null }];
1966
+ nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null,
1967
+ program: program?.command ?? null, processSource: program?.source, argumentsObserved: !!program?.rawArgs }];
1947
1968
  });
1948
1969
  }
1949
1970
  async function launchIntegrationTerminal(record, restoring, prepared) {
@@ -3937,13 +3958,15 @@ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = fa
3937
3958
  }
3938
3959
  const selected = selectTmuxForegroundProgram({
3939
3960
  panePid: pane.pid,
3961
+ includePaneProcess: requireArgs,
3940
3962
  rows,
3941
3963
  shellNames: shellNamesBackend,
3942
3964
  genericProgramNames,
3943
3965
  extractProgramLabel,
3944
3966
  });
3945
3967
  if (selected) {
3946
- 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 } : {}) };
3947
3970
  }
3948
3971
  }
3949
3972
  catch {
@@ -3951,6 +3974,23 @@ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = fa
3951
3974
  }
3952
3975
  return { command, source: 'tmux-pane', rawArgs: null };
3953
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
+ }
3954
3994
  /**
3955
3995
  * Extract a human-readable program label from a full command line.
3956
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) {
@@ -5,7 +5,9 @@
5
5
  插件原生 ID 的前台 argv 回读及服务重启后的运行中 restore 核验修复要求
6
6
  **实际运行服务至少 1.4.302**。退出后的重复原生会话检查修复要求
7
7
  **实际运行服务至少 1.4.303**。没有 PTY 附着的已登记 tmux 的重复保护修复要求
8
- **实际运行服务至少 1.4.304**;建议 CLI 与服务同步安装 1.4.304(CLI 最低仍为 1.4.301)。
8
+ **实际运行服务至少 1.4.304**。管理员恢复诊断要求 **CLI / 实际服务均至少 1.4.305**;
9
+ 直接 exec 为 pane PID 的原生 argv 核验及可选 Linux 持锁身份要求 **实际服务至少 1.4.306**;
10
+ 建议 CLI 与服务同步安装 1.4.306(既有接入命令的 CLI 最低仍为 1.4.301,诊断 CLI 最低为 1.4.305)。
9
11
  现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
10
12
  `task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
11
13
 
@@ -310,9 +312,102 @@ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒
310
312
  仅在确认 tmux 已消失或当前进程排除冲突时继续;未知进程、同 Agent 缺少精确 UUID、
311
313
  观察失败均返回 `NATIVE_SESSION_OWNER_UNCONFIRMED`,不以旧 UUID 证明没有冲突。
312
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
+
357
+ ### 管理员恢复诊断(1.4.305)
358
+
359
+ `capabilities.session_restore_diagnostics=true` 表示服务支持持久恢复诊断。
360
+ 遇到 `NATIVE_SESSION_OWNER_UNCONFIRMED` 或 `NATIVE_SESSION_ALREADY_RUNNING` 后,
361
+ 本机管理员执行:
362
+
363
+ ```sh
364
+ td integration diagnostics <TD-session-id>
365
+ ```
366
+
367
+ 命令经原私有 Unix socket 和本机管理员凭据读取报告,不使用 `--principal`。
368
+ 服务能力缺失时返回 `SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED`,不会假装诊断成功。
369
+ 命令仅读取已存记录,不重新扫描进程、不挂接 PTY、不试键、不创建恢复意图。
370
+ 304 及更早的失败没有此报告;升级后应使用新的 restore 幂等键执行一次明确检查,
371
+ 再读报告。旧键不会执行一次新检查。
372
+
373
+ 响应包含当前 `session_id / operation_id / state / error_code`,以及可为空的
374
+ `diagnostics`。报告含其自身 `operation_id / checked_at / code`、`candidates`、
375
+ `total_blockers / truncated`;它是该次检查的历史事实,不能代表读取时的当前进程。
376
+ 只有上一次**实际发起**的恢复检查会保存报告;下一次实际恢复清除旧报告,运行中
377
+ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启后回读。
378
+
379
+ 每个阻塞候选含 `session_id / backend_session_id / backend_attached`、
380
+ `tmux_session_name / tmux_session_id / pane_id / pane_pid`、`program / process_source`、
381
+ `arguments_observed / agent_slug / last_known_native_id / observed_native_id`、
382
+ `pgid / tpgid / reason / observed_at`;306 另含真实前台 `process_pid` 及
383
+ `native_identity_source`(argv / linux-flock-owner / null)。无法观测的值为 null。backend 不存在或仅有旧
384
+ 登记仍可通过 tmux 核验;`pane_pid` 是承载 pane 的 PID,不冒充 Agent 前台 PID。
385
+ 报告最多保存 128 个阻塞候选;`total_blockers` 保留总数,确定的同 UUID 所有者优先展示。
386
+
387
+ | reason | 实际证据缺口或冲突 |
388
+ | --- | --- |
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 证据不能完整成立 |
394
+ | NATIVE_SESSION_ID_MISSING | 当前同 Agent 的进程已观测,但没有可回读的精确 UUID |
395
+ | PROCESS_ARGUMENTS_UNAVAILABLE | 无法取得可靠的当前进程 argv |
396
+ | FOREGROUND_PROCESS_UNCONFIRMED | 无法可靠核验前台进程 |
397
+ | SHELL_PROCESS_NOT_FOUND | shell 的 ps 输出未形成可解析记录 |
398
+ | SHELL_FOREGROUND_MISMATCH | shell 未持有有效前台进程组;记录 pgid/tpgid |
399
+ | SHELL_ARGUMENTS_UNSUPPORTED | 当前 shell 命令或启动参数未满足既有安全检查 |
400
+ | SHELL_OBSERVATION_FAILED | shell 的进程观察失败 |
401
+ | TERMINAL_OBSERVATION_FAILED | tmux 或终端观察失败,无法排除该候选 |
402
+
403
+ 完整候选可能来自其他组,因此仅管理员接口返回;principal 的 session 响应与推送
404
+ 事件只增加 `restore_diagnostics_available=true`,不含候选标识/UUID。报告不保存或
405
+ 返回原始 argv、cwd、终端正文、包装器异常、凭据。诊断不自动放宽任何拒绝条件,
406
+ 不能把旧 UUID、native ready 或 Agent 忙闲推测当作消除冲突的证据。
407
+
313
408
  常见失败码(每项都是完整字符串):
314
409
 
315
- - 配置/权限:`LAUNCH_PROFILE_DENIED`, `LAUNCHER_UNAVAILABLE`, `COLLAB_AGENT_UNAVAILABLE`, `SESSION_CWD_MISSING`, `SESSION_CWD_DENIED`, `INTEGRATION_SESSION_NOT_FOUND`, `INTEGRATION_SESSION_LIMIT`。
410
+ - 配置/权限:`LAUNCH_PROFILE_DENIED`, `LAUNCHER_UNAVAILABLE`, `COLLAB_AGENT_UNAVAILABLE`, `SESSION_CWD_MISSING`, `SESSION_CWD_DENIED`, `INTEGRATION_SESSION_NOT_FOUND`, `INTEGRATION_SESSION_LIMIT`, `SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED`。
316
411
  - 终端/身份:`SESSION_BACKEND_UNAVAILABLE`, `SESSION_TARGET_NOT_SHELL`, `SESSION_IDENTITY_MISMATCH`, `NATIVE_SESSION_ID_MISMATCH`, `NATIVE_SESSION_ID_INVALID`, `NATIVE_SESSION_ID_MISSING`。
317
412
  - 启动/恢复:`SESSION_CREATE_OUTCOME_UNKNOWN`, `SESSION_OPERATION_IN_PROGRESS`, `SESSION_BINDING_TIMEOUT`, `EXACT_RESUME_UNSUPPORTED`, `NATIVE_SESSION_ALREADY_RUNNING`, `NATIVE_SESSION_OWNER_UNCONFIRMED`, `AGENT_NOT_RUNNING`。
318
413
  - 未分类异常:`SESSION_LAUNCH_FAILED`, `SESSION_OBSERVATION_FAILED`;错误不包含包装器输出、密钥或原始堆栈。
@@ -335,7 +430,12 @@ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒
335
430
  使用方已报告 1.4.303 退出后精确恢复通过,也发现未挂接 PTY 的已登记 tmux 重复保护
336
431
  遗漏;维护机已用“旧 UUID C、实际运行 UUID A、无 PTY 附着”的真实隔离 tmux 复现,
337
432
  并验证 1.4.304 编译后检查在该状态下拒绝再次启动。
338
- **1.4.304 的真实 TraeX 重复保护与稳定输入条件仍须使用方实机复验**。
433
+ 使用方已报告 1.4.304 在未挂接网页/PTY、旧登记 UUID C 而实际运行 A 的场景下,
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 持锁身份接入与稳定输入条件仍须使用方实机复验**。
339
439
  回读 capabilities 后,应核对原 TD ID、真实 TraeX UUID 与原 argv/cwd/包装器快照,
340
440
  并验证 Agent 真正加载原对话。
341
441
  先完成测试环境的创建→追问→关联答复,再验重复键、追加咨询、断线重投和精确恢复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.304",
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.304",
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-7LaEwk0ur5djmnXCjBBkhMPg2z5dB5dcYm7tmh14Pko=",
9
+ "serverBundleHash": "sha256-X8EpOnbvlZo/rtZ5QLd5zVKFUOFeDkxeqJmY0hUBQnE=",
10
10
  "clientBundleHash": "sha256-HOoNMOYhnph9XwUhSYE5W2CdI9Vb4g0c64ubW49LnXE=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"